Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 85 additions & 24 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1461,22 +1461,39 @@ def dispatch_command(cmd: str) -> None:
elif cmd == "save-result":
# graphify save-result --question Q --answer A [--type T] [--nodes N1 N2 ...]
# [--outcome useful|dead_end|corrected] [--correction TEXT]
#
# --question-file/--answer-file/--correction-file read the value from a
# file instead of a command-line argument: skill instructions that build
# this command from free text (the user's verbatim question, an LLM's
# generated answer, of unbounded length and content) must not substitute
# that text directly into a shell command string, since embedded quotes,
# backticks, or $() would corrupt or escape the command entirely
# (#3439). Writing the value to a file first has no such injection
# surface at all.
import argparse as _ap

p = _ap.ArgumentParser(prog="graphify save-result")
p.add_argument("--question", required=True)
p.add_argument("--question", default=None)
p.add_argument("--question-file", dest="question_file", default=None)
p.add_argument("--answer", default=None)
p.add_argument("--answer-file", dest="answer_file", default=None)
p.add_argument("--type", dest="query_type", default="query")
p.add_argument("--nodes", nargs="*", default=[])
p.add_argument("--outcome", choices=("useful", "dead_end", "corrected"), default=None)
p.add_argument("--correction", default=None)
p.add_argument("--correction-file", dest="correction_file", default=None)
p.add_argument("--memory-dir", default=str(Path(_GRAPHIFY_OUT) / "memory"))
opts = p.parse_args(sys.argv[2:])
if opts.question_file:
opts.question = Path(opts.question_file).read_text(encoding="utf-8").strip()
elif not opts.question:
p.error("--question or --question-file is required")
if opts.answer_file:
opts.answer = Path(opts.answer_file).read_text(encoding="utf-8").strip()
elif not opts.answer:
p.error("--answer or --answer-file is required")
if opts.correction_file:
opts.correction = Path(opts.correction_file).read_text(encoding="utf-8").strip()
from graphify.ingest import save_query_result as _sqr

out = _sqr(
Expand Down Expand Up @@ -1942,32 +1959,76 @@ def dispatch_command(cmd: str) -> None:
print(format_diagnostic_report(summary))

elif cmd == "add":
if len(sys.argv) < 3:
print(
"Usage: graphify add <url> [--author Name] [--contributor Name] [--dir ./raw]",
file=sys.stderr,
)
# --from-file reads {"url": ..., "author": ..., "contributor": ..., "dir": ...}
# (author/contributor/dir optional) instead of taking url/--author/
# --contributor as command-line text: skill instructions that build this
# command from a user-supplied URL and name have no way to shell-quote
# values they cannot predict the content of, so substituting them
# directly into a command string risks corrupting or escaping it
# (#3439). Writing the payload to a file first has no such injection
# surface -- only the file's own (agent-controlled) path is a shell
# argument.
args = sys.argv[2:]
from_file = None
from_file_requested = False
for i, a in enumerate(args):
if a == "--from-file":
from_file_requested = True
if i + 1 < len(args):
from_file = args[i + 1]
break
if from_file_requested and not from_file:
print("error: --from-file requires a path argument", file=sys.stderr)
sys.exit(1)
if from_file:
try:
payload = json.loads(Path(from_file).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"error: could not read --from-file payload: {exc}", file=sys.stderr)
sys.exit(1)
if not isinstance(payload, dict):
print(
"error: --from-file payload must be a JSON object with a "
"'url' key, not " + type(payload).__name__,
file=sys.stderr,
)
sys.exit(1)
try:
url = payload["url"]
except KeyError:
print("error: --from-file payload is missing required key 'url'", file=sys.stderr)
sys.exit(1)
author = payload.get("author")
contributor = payload.get("contributor")
target_dir = Path(payload.get("dir") or "raw")
else:
if len(sys.argv) < 3:
print(
"Usage: graphify add <url> [--author Name] [--contributor Name] "
"[--dir ./raw] | graphify add --from-file payload.json",
file=sys.stderr,
)
sys.exit(1)
url = sys.argv[2]
author = None
contributor = None
target_dir = Path("raw")
args = sys.argv[3:]
i = 0
while i < len(args):
if args[i] == "--author" and i + 1 < len(args):
author = args[i + 1]
i += 2
elif args[i] == "--contributor" and i + 1 < len(args):
contributor = args[i + 1]
i += 2
elif args[i] == "--dir" and i + 1 < len(args):
target_dir = Path(args[i + 1])
i += 2
else:
i += 1
from graphify.ingest import ingest as _ingest

url = sys.argv[2]
author: str | None = None
contributor: str | None = None
target_dir = Path("raw")
args = sys.argv[3:]
i = 0
while i < len(args):
if args[i] == "--author" and i + 1 < len(args):
author = args[i + 1]
i += 2
elif args[i] == "--contributor" and i + 1 < len(args):
contributor = args[i + 1]
i += 2
elif args[i] == "--dir" and i + 1 < len(args):
target_dir = Path(args[i + 1])
i += 2
else:
i += 1
try:
saved = _ingest(url, target_dir, author=author, contributor=contributor)
print(f"Saved to {saved}")
Expand Down
42 changes: 26 additions & 16 deletions graphify/skills/agents/references/add-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,35 @@ Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither i

Fetch a URL and add it to the corpus, then update the graph.

The URL and any author/contributor name are free text you do not control the
content of - do not build a command or inline script by substituting them
into a string; an embedded quote or shell character corrupts or escapes it.
Reserve a unique file path first - a fixed, shared filename risks a
concurrent graphify session overwriting or reading a stale payload:

```bash
mktemp /tmp/graphify_add_payload.XXXXXX.json
```

Using your file-write tool (not a shell heredoc, which has the same
quoting problem one level down), write a JSON file with those values to
the path that command printed, then pass only that path - not its content
- to `graphify add`:

```json
{"url": "URL", "author": "AUTHOR", "contributor": "CONTRIBUTOR", "dir": "./raw"}
```

Replace `URL` with the actual URL, `AUTHOR` with the user's name if
provided (omit the key entirely if not), `CONTRIBUTOR` likewise, then run:

```bash
$(cat graphify-out/.graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path

try:
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
print(f'Saved to {out}')
except ValueError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
"
$(cat graphify-out/.graphify_python) -m graphify add --from-file PAYLOAD_PATH
```

Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
Replace `PAYLOAD_PATH` with the path `mktemp` printed. If the command exits with an error, tell the user what went wrong - do not
silently continue. After a successful save, automatically run the `--update`
pipeline on `./raw` to merge the new file into the existing graph.

Supported URL types (auto-detected):
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
Expand Down
59 changes: 51 additions & 8 deletions graphify/skills/agents/references/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,31 @@ print(output)

Replace `QUESTION` with the **expanded** query string, `MODE` with `bfs` or `dfs`, and `BUDGET` with the token budget (default `2000`, or whatever `--budget N` specifies). Then answer based on the subgraph output above, using only what the graph contains.

After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the answer text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node.

The question and answer are free text you do not control the content of - a
quote, backtick, or `$()` embedded in either one corrupts or escapes a
command it's substituted into. Reserve two unique file paths first - a
fixed, shared filename risks a concurrent graphify session overwriting or
reading a stale value:

```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
mktemp /tmp/graphify_question.XXXXXX
mktemp /tmp/graphify_answer.XXXXXX
```

Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
Using your file-write tool, write the user's verbatim question to the path
the first command printed and your full answer text (containing the
expanded-token trace) to the path the second one printed, then pass those
exact paths - not their content - on the command line:

```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question-file QUESTION_PATH --answer-file ANSWER_PATH --type query --nodes NODE1 NODE2
```

**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and `--correction "the right answer"` when correcting):
Replace `QUESTION_PATH`/`ANSWER_PATH` with the paths `mktemp` printed and `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.

**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and, when correcting, reserve one more unique path with `mktemp`, write what was right to it, and pass `--correction-file CORRECTION_PATH` the same way):

- `useful` — the cited nodes answered the question well (they become *preferred sources*).
- `dead_end` — the question/path led nowhere; don't re-derive it next time.
Expand Down Expand Up @@ -243,10 +259,24 @@ except nx.NodeNotFound as e:

Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.

After writing the explanation, save it back:
After writing the explanation, save it back. `NODE_A`/`NODE_B` are node
labels, which can come from extracted document content and so are not
guaranteed free of shell characters either - treat the question the same
as the explanation. Reserve two unique file paths first (a fixed, shared
filename risks a concurrent graphify session overwriting or reading a
stale value):

```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
mktemp /tmp/graphify_question.XXXXXX
mktemp /tmp/graphify_answer.XXXXXX
```

Using your file-write tool, write `Path from NODE_A to NODE_B` (with the
actual node names) to the first path and the explanation to the second,
then pass only those paths, the same way as for `/graphify query` above:

```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question-file QUESTION_PATH --answer-file ANSWER_PATH --type path_query --nodes NODE_A NODE_B
```

---
Expand Down Expand Up @@ -304,8 +334,21 @@ for neighbor in G.neighbors(nid):

Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.

After writing the explanation, save it back:
After writing the explanation, save it back. `NODE_NAME` is a node label,
which can come from extracted document content and so is not guaranteed
free of shell characters either - treat the question the same as the
explanation. Reserve two unique file paths first (a fixed, shared filename
risks a concurrent graphify session overwriting or reading a stale value):

```bash
mktemp /tmp/graphify_question.XXXXXX
mktemp /tmp/graphify_answer.XXXXXX
```

Using your file-write tool, write `Explain NODE_NAME` (with the actual
node name) to the first path and the explanation to the second, then pass
only those paths, the same way as for `/graphify query` above:

```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
$(cat graphify-out/.graphify_python) -m graphify save-result --question-file QUESTION_PATH --answer-file ANSWER_PATH --type explain --nodes NODE_NAME
```
42 changes: 26 additions & 16 deletions graphify/skills/amp/references/add-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,35 @@ Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither i

Fetch a URL and add it to the corpus, then update the graph.

The URL and any author/contributor name are free text you do not control the
content of - do not build a command or inline script by substituting them
into a string; an embedded quote or shell character corrupts or escapes it.
Reserve a unique file path first - a fixed, shared filename risks a
concurrent graphify session overwriting or reading a stale payload:

```bash
mktemp /tmp/graphify_add_payload.XXXXXX.json
```

Using your file-write tool (not a shell heredoc, which has the same
quoting problem one level down), write a JSON file with those values to
the path that command printed, then pass only that path - not its content
- to `graphify add`:

```json
{"url": "URL", "author": "AUTHOR", "contributor": "CONTRIBUTOR", "dir": "./raw"}
```

Replace `URL` with the actual URL, `AUTHOR` with the user's name if
provided (omit the key entirely if not), `CONTRIBUTOR` likewise, then run:

```bash
$(cat graphify-out/.graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path

try:
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
print(f'Saved to {out}')
except ValueError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
"
$(cat graphify-out/.graphify_python) -m graphify add --from-file PAYLOAD_PATH
```

Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
Replace `PAYLOAD_PATH` with the path `mktemp` printed. If the command exits with an error, tell the user what went wrong - do not
silently continue. After a successful save, automatically run the `--update`
pipeline on `./raw` to merge the new file into the existing graph.

Supported URL types (auto-detected):
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
Expand Down
Loading
Loading