From d8cd740b032245887feaaa4e6d0af12508e37ee4 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:46:55 +0800 Subject: [PATCH 1/5] feat(recall): add portable session recovery and authoring paths --- README.md | 22 ++ package.json | 2 +- profiles/upstream-manifest.json | 6 +- scripts/history.test.mjs | 177 +++++++++++ skills/automate-me/SKILL.md | 34 ++- .../meta-mode/playbooks/authoring-a-skill.md | 38 ++- skills/recall/SKILL.md | 12 +- skills/recall/references/history-sources.md | 103 ++++++- skills/recall/scripts/history.mjs | 280 ++++++++++++++++++ skills/reflect/SKILL.md | 29 +- 10 files changed, 661 insertions(+), 42 deletions(-) create mode 100644 scripts/history.test.mjs create mode 100644 skills/recall/scripts/history.mjs diff --git a/README.md b/README.md index ca88ceb..d233b78 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,28 @@ configuration, and `claude-haiku-4-5-20251001`. The check required Claude Code's native `Skill` tool to invoke `meta-mode` and return an exact marker; the remote fixture and temporary credentials were removed afterward. +## Recover recent context + +`recall` includes a history reader in `recall/scripts/history.mjs`; it also works +when only the skill directory is installed. List session metadata for the active +workspace before selecting a session to read: + +```bash +node /scripts/history.mjs list --harness codex --workspace --exclude +node /scripts/history.mjs read --harness codex --workspace --session --query parser +``` + +The reader supports Codex, Claude Code, OpenCode, and pi, with configured storage +roots, session exclusions, branch selection where available, and bounded text +output. OpenCode exports are sanitized by default; explicitly use `--local-text` +for private local recovery because sanitization can remove all message text. +See [history sources](./skills/recall/references/history-sources.md) for supported +formats and limits. Tests use disposable sessions; no user transcript is bundled. + +The [authoring playbook](./skills/meta-mode/playbooks/authoring-a-skill.md) gives +`automate-me` and `reflect` a concrete draft, description review, and validation +workflow even when no native skill creator or repository validator is installed. + ## Repository layout - `skills/` contains the canonical, harness-neutral skill files. diff --git a/package.json b/package.json index 53a5507..503ecbe 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "install-skills": "node scripts/install.mjs", "optimize-context": "node scripts/optimize-context.mjs", "reconcile-context": "node scripts/reconcile-context.mjs", - "test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs" + "test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs" }, "bin": { "mstack": "scripts/install.mjs" diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index dab55e0..9c6fe65 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -73,7 +73,7 @@ }, "automate-me": { "source": "c80a6024fa6bebfa6875975c74293415fa6f729087377998751dc1343c865e75", - "target": "b87d5af4db107ca6e3c2019f41875e36aeeda32b8748d8d92fbe133091529bb5" + "target": "6ab6c4c4a4b50a7cb182149408bd1758229d4386fbb8b1a66cf6927847b02c27" }, "blast-radius": { "source": "20cb2945f5fe62055166f745107274af5f19428ca033e53a9b8bc69650d3ded1", @@ -209,11 +209,11 @@ }, "recall": { "source": "d265b0e5b6dc5b89c0407f3387d24462c988e1990a37d5359735137908128c5a", - "target": "fbf2ae86fe5f3aeff6a6454b1a0f0899eebb5311a37ba44a3a0088fc2523ca77" + "target": "be67c9e064a2b6d2ec52e915c19f6507f9f389a112e9961eb93b3c793b565e70" }, "reflect": { "source": "6a4d4ccaace9ce88c3e268fcbedf3cfd40e527162a5e5aad6de8edbaf5e2b83f", - "target": "008d424f5a4570c0452852322591a50a75c4e90828f85bbb8662b5dabcc26e47" + "target": "5478382b85614482fdf8799c9b0113d0c12c6595ee1003aa62c91f6288b91089" }, "setup-mstack": { "source": "a11137437e12831c73f228f449bdfefab487bf7e2c69a4b306189e8fb1e6ab3e", diff --git a/scripts/history.test.mjs b/scripts/history.test.mjs new file mode 100644 index 0000000..7106fc7 --- /dev/null +++ b/scripts/history.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { history } from "../skills/recall/scripts/history.mjs"; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "mstack-history-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const workspace = join(root, "project with spaces"); + mkdirSync(workspace); + return { root, workspace }; +} + +function jsonl(path, rows) { + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, rows.map((row) => typeof row === "string" ? row : JSON.stringify(row)).join("\n") + "\n"); +} + +test("Codex lists metadata only, honors CODEX_HOME and excludes unrelated/current/child sessions", async (t) => { + const { root, workspace } = fixture(t); + const store = join(root, "codex"); + const meta = (id, cwd = workspace, source = "cli") => ({ type: "session_meta", payload: { id, cwd, source } }); + jsonl(join(store, "sessions", "wanted.jsonl"), [meta("wanted"), { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Fix parser" }] } }]); + jsonl(join(store, "archived_sessions", "other.jsonl"), [meta("other", join(root, "unrelated")), "PRIVATE INVALID BODY"]); + jsonl(join(store, "sessions", "current.jsonl"), [meta("current")]); + jsonl(join(store, "sessions", "child.jsonl"), [meta("child", workspace, { subagent: { thread_spawn: {} } })]); + const options = { harness: "codex", workspace, env: { CODEX_HOME: store, CODEX_THREAD_ID: "current" } }; + const listing = await history({ ...options, command: "list" }); + assert.deepEqual(listing.sessions.map((session) => session.id), ["wanted"]); + assert.doesNotMatch(JSON.stringify(listing), /Fix parser|PRIVATE/); + assert.deepEqual(listing.warnings, []); + const reading = await history({ ...options, command: "read", session: "wanted" }); + assert.deepEqual(reading.messages.map(({ role, text }) => ({ role, text })), [{ role: "user", text: "Fix parser" }]); + await assert.rejects(history({ ...options, command: "read", session: "other" }), /not found in the requested workspace/); +}); + +test("Claude scopes its project directory and follows the selected conversation branch", async (t) => { + const { root, workspace } = fixture(t); + const store = join(root, "claude"); + const slug = workspace.replace(/[^a-zA-Z0-9]/g, "-"); + const row = (uuid, parentUuid, text) => ({ type: "user", sessionId: "claude-session", cwd: workspace, uuid, parentUuid, message: { role: "user", content: text } }); + jsonl(join(store, "projects", slug, "claude-session.jsonl"), [ + { type: "file-history-snapshot" }, row("a", null, "Start"), row("discarded", "a", "Old approach"), row("chosen", "a", "New approach"), + ]); + jsonl(join(store, "projects", "unrelated", "private.jsonl"), ["PRIVATE INVALID RECORD"]); + const options = { harness: "claude", workspace, env: { CLAUDE_CONFIG_DIR: store }, command: "read", session: "claude-session" }; + const reading = await history(options); + assert.deepEqual(reading.messages.map((message) => message.text), ["Start", "New approach"]); + assert.deepEqual((await history({ ...options, leaf: "discarded" })).messages.map((message) => message.text), ["Start", "Old approach"]); +}); + +test("discovery sorts by modification time, deduplicates newest records and honors the time window", async (t) => { + const { root, workspace } = fixture(t); + const meta = { type: "session_meta", payload: { id: "same", cwd: workspace } }; + const old = join(root, "archived_sessions", "same.jsonl"); + const newest = join(root, "sessions", "same.jsonl"); + const message = (text) => ({ type: "event_msg", payload: { type: "agent_message", message: text } }); + jsonl(old, [meta, message("Archived response")]); + jsonl(newest, [meta, message("Current response")]); + utimesSync(old, new Date("2026-01-01"), new Date("2026-01-01")); + const options = { harness: "codex", workspace, root, env: {}, since: "2025-01-01" }; + assert.equal((await history({ ...options, command: "list" })).sessions.length, 1); + assert.equal((await history({ ...options, command: "read", session: "same" })).messages[0].text, "Current response"); + assert.deepEqual((await history({ ...options, command: "list", since: "2100-01-01" })).sessions, []); +}); + +test("pi reads version 3 parent trees, excludes tool results and bounds matching output", async (t) => { + const { root, workspace } = fixture(t); + const store = join(root, "pi"); + const message = (id, parentId, role, text) => ({ type: "message", id, parentId, message: { role, content: [{ type: "text", text }] } }); + jsonl(join(store, "branch.jsonl"), [ + { type: "session", version: 3, id: "pi-session", cwd: workspace }, + message("a", null, "user", "Parser question"), message("b", "a", "assistant", "Discarded answer"), + message("c", "a", "assistant", "Parser fix"), message("d", "c", "toolResult", "PRIVATE TOOL OUTPUT"), + ]); + const options = { harness: "pi", workspace, env: { PI_CODING_AGENT_SESSION_DIR: store }, command: "read", session: "pi-session" }; + assert.deepEqual((await history(options)).messages.map((message) => message.text), ["Parser question", "Parser fix"]); + const bounded = await history({ ...options, query: "parser", limit: 1, maxChars: 6 }); + assert.deepEqual(bounded.messages.map((message) => message.text), ["Parser"]); + assert.equal(bounded.truncated, true); + const newest = await history({ ...options, maxChars: 12 }); + assert.deepEqual(newest.messages.map((message) => message.text), ["Pa", "Parser fix"]); + assert.equal(newest.truncated, true); + await assert.rejects(history({ ...options, leaf: "missing" }), /Unknown leaf/); +}); + +test("pi default storage uses only the requested workspace slug and keeps branch summaries", async (t) => { + const { root, workspace } = fixture(t); + const slug = `--${workspace.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + jsonl(join(root, "sessions", slug, "session.jsonl"), [ + { type: "session", version: 3, id: "summary", cwd: workspace }, + { type: "branch_summary", id: "s", parentId: null, summary: "The first attempt was reverted." }, + ]); + jsonl(join(root, "sessions", "unrelated", "private.jsonl"), ["PRIVATE INVALID HEADER"]); + const result = await history({ harness: "pi", workspace, env: { PI_CODING_AGENT_DIR: root }, command: "read", session: "summary" }); + assert.deepEqual(result.messages, [{ role: "summary", text: "The first attempt was reverted." }]); + assert.deepEqual(result.warnings, []); +}); + +test("OpenCode filters list metadata before exporting one sanitized session", async (t) => { + const { root, workspace } = fixture(t); + const calls = []; + const runOpenCode = (args, cwd) => { + calls.push({ args, cwd }); + if (args[0] === "session") return [ + { id: "other", directory: join(root, "other"), updated: Date.now() }, + { id: "wanted", directory: workspace, updated: Date.now() }, + { id: "child", directory: workspace, parentID: "wanted", updated: Date.now() }, + ]; + return { info: { id: "wanted", directory: "[redacted:session-directory:wanted]" }, messages: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Sanitized answer" }, { type: "tool", text: "Do not emit" }] }] }; + }; + const options = { harness: "opencode", workspace, runOpenCode, env: {} }; + const listing = await history({ ...options, command: "list" }); + assert.deepEqual(listing.sessions.map((session) => session.id), ["wanted"]); + assert.equal(calls.length, 1); + const reading = await history({ ...options, command: "read", session: "wanted" }); + assert.deepEqual(calls.at(-1), { args: ["export", "wanted", "--sanitize", "--pure"], cwd: workspace }); + assert.equal(reading.messages[0].text, "Sanitized answer"); + await assert.rejects(history({ ...options, command: "read", session: "other" }), /not found/); + assert.equal(calls.filter((call) => call.args[0] === "export").length, 1); + for (const directory of [join(root, "other"), "[redacted:session-directory:other]"]) { + const wrongExport = (args, cwd) => args[0] === "session" ? runOpenCode(args, cwd) + : { info: { id: "wanted", directory }, messages: [] }; + await assert.rejects(history({ ...options, runOpenCode: wrongExport, command: "read", session: "wanted" }), /does not match/); + } +}); + +test("missing stores and malformed records produce controlled diagnostics without raw content", async (t) => { + const { root, workspace } = fixture(t); + const options = { harness: "codex", workspace, root: join(root, "missing"), command: "list", env: {} }; + assert.deepEqual((await history(options)).sessions, []); + jsonl(join(options.root, "sessions", "broken.jsonl"), ["PRIVATE INVALID HEADER"]); + const broken = await history(options); + assert.equal(broken.warnings.length, 1); + assert.doesNotMatch(JSON.stringify(broken), /PRIVATE/); + jsonl(join(options.root, "sessions", "good.jsonl"), [{ type: "session_meta", payload: { id: "good", cwd: workspace } }, "PRIVATE INVALID BODY"]); + const reading = await history({ ...options, command: "read", session: "good" }); + assert.deepEqual(reading.messages, []); + assert.ok(reading.warnings.some((warning) => /malformed record/.test(warning))); + assert.doesNotMatch(JSON.stringify(reading), /PRIVATE/); +}); + +test("OpenCode warns when sanitizer removes text and requires an explicit local-text read to recover it", async (t) => { + const { workspace } = fixture(t); + const runOpenCode = (args) => args[0] === "session" ? [{ id: "session", directory: workspace, updated: Date.now() }] + : { info: { id: "session", directory: args.includes("--sanitize") ? "[redacted:session-directory:session]" : workspace }, + messages: [{ info: { role: "user" }, parts: [{ type: "text", text: args.includes("--sanitize") ? "[redacted:text:part]" : "Local fixture prompt" }] }] }; + const options = { harness: "opencode", workspace, runOpenCode, command: "read", session: "session" }; + const sanitized = await history(options); + assert.equal(sanitized.sanitized, true); + assert.match(sanitized.warnings[0], /redacted message text/); + const local = await history({ ...options, localText: true }); + assert.equal(local.sanitized, false); + assert.equal(local.messages[0].text, "Local fixture prompt"); + assert.deepEqual(local.warnings, []); + await assert.rejects(history({ ...options, command: "list", localText: true }), /only for OpenCode read/); + await assert.rejects(history({ ...options, harness: "codex", localText: true }), /only for OpenCode read/); +}); + +test("the copied recall skill runs without repository scripts and validates CLI arguments", (t) => { + const { root, workspace } = fixture(t); + const installed = join(root, "installed-recall"); + cpSync(resolve("skills/recall"), installed, { recursive: true }); + const script = join(installed, "scripts", "history.mjs"); + const args = [script, "list", "--harness", "codex", "--workspace", workspace, "--root", join(root, "empty")]; + const result = spawnSync(process.execPath, args, { encoding: "utf8", cwd: root }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).sessions, []); + for (const suffix of [["--root"], ["--limit", "0"], ["--since", "invalid"], ["--unknown", "value"], ["--local-text"]]) { + const failed = spawnSync(process.execPath, [...args, ...suffix], { encoding: "utf8", cwd: root }); + assert.notEqual(failed.status, 0); + assert.doesNotMatch(failed.stderr, /at file:/); + } +}); diff --git a/skills/automate-me/SKILL.md b/skills/automate-me/SKILL.md index 8c185b8..6e1f812 100644 --- a/skills/automate-me/SKILL.md +++ b/skills/automate-me/SKILL.md @@ -7,7 +7,10 @@ description: "Use for \"automate me\", \"create/update/refresh my -mode skill\", A guided flow for turning the user's working conventions into a skill agents will follow. The output is one `-mode` skill tailored to them (e.g. `jay-mode`, `priya-mode`). -This skill orchestrates three others: an inline mining pass (see step 1), the repository's skill-authoring workflow, and the **unslop** skill (prose discipline). It sequences them. It doesn't replace them. +This skill sequences an inline mining pass (see step 1), the bundled +[authoring playbook](../meta-mode/playbooks/authoring-a-skill.md), and the +**unslop** skill. If a selected-skills installation omits either sibling, use +step 4's standalone draft rules and check the prose directly. ## Flow @@ -25,7 +28,12 @@ Update mode changes the rest of the flow: ### 1. Mine their history -Locate the active workspace's transcripts before fanning out. The active Harness's history adapter names the workspace-scoped transcript directory. Use only that path. Do not glob across unrelated project stores. +Locate the active workspace's transcripts before fanning out. Use +[recall's history reader](../recall/references/history-sources.md) to list scoped +session IDs and read bounded excerpts, excluding the current session. If recall +is not installed, use the harness's documented workspace-scoped history view. +If no history source is available, record that limitation and continue with the +user's stated preferences. Do not glob across unrelated project stores. Survey recent agent conversations within that scope for recurring patterns. Run multiple parallel subagents across slices of history (e.g. last 2-4 weeks, split into 3 slices so each has enough material). Each slice mining subagent reads transcripts from the workspace-scoped path the parent provides, looks for the signals below, and returns a short structured list of patterns it saw with evidence pointers. Default signals worth hunting: @@ -40,9 +48,14 @@ Cross-check across slices before elevating a signal. Patterns seen in 2+ slices ### 2. Ask the user directly -Mining misses intent that hasn't come up yet. Use the `ask the user` tool (structured multi-choice) rather than asking the user to type from scratch. +Mining misses intent that hasn't come up yet. Use the host's structured question +tool when available, within its supported option count. Otherwise ask one short +question in chat with concrete examples. -Shape: one or two questions with 4-6 options each, `allow_multiple: true` for category questions. Start broad ("Which areas matter most?"), then follow up on selected areas with specific options. After the structured rounds, one free-form chat question catches anything the options missed. +Start broad ("Which areas matter most?"), then follow up on selected areas with +specific options. Use multiple selection only when the host supports it. After +the structured rounds, one free-form chat question catches anything the options +missed. Don't dump 20 questions. @@ -63,7 +76,9 @@ The **meta-mode** skill shows the shape. Read it for granularity. Don't copy its ### 4. Draft the skill -Use the repository's skill-authoring workflow to author the skill. Follow its local authoring guidance for placement: +Follow the [authoring playbook](../meta-mode/playbooks/authoring-a-skill.md), +including its description review. These draft rules also work when only +`automate-me` is installed: - Path: preserve an existing mode skill's category. For a new mode, use the active Harness's project skill root and its user-level skill root when the user prefers a personal skill. - Handle: the user's first name or chosen identifier. @@ -71,6 +86,11 @@ Use the repository's skill-authoring workflow to author the skill. Follow its lo - Frontmatter formatting: follow the authoring workflow's YAML rules. Keep `description` as one YAML scalar. Quote it or use `description: >-` with indented continuation lines when punctuation or wrapping requires it. - Keep the mode explicit by default. Apply it on every turn only when the user asks for that behavior. +Check that every linked file exists and every tool reference is available. Try +two requests that should select this mode and two nearby requests that should +not. Tighten the description when it selects the wrong cases. Use a local +validator if one exists; otherwise inspect the frontmatter and links directly. + ### 5. Iterate on prose Apply the **unslop** skill and the authoring workflow's writing guidelines to every line. @@ -79,7 +99,9 @@ Show the draft to the user and take feedback. Expect multiple iterations. Cut ru ### 6. Land it -Work in a worktree off main. Commit and open a PR. Don't push to main directly. +For a repository-owned skill, work in an isolated worktree, commit, and open a PR +within the user's authorized workflow. For a personal skill outside a repository, +report the validated local path. Keep private transcript evidence out of commits. ## Guardrails diff --git a/skills/meta-mode/playbooks/authoring-a-skill.md b/skills/meta-mode/playbooks/authoring-a-skill.md index 7ee312a..029fc40 100644 --- a/skills/meta-mode/playbooks/authoring-a-skill.md +++ b/skills/meta-mode/playbooks/authoring-a-skill.md @@ -2,10 +2,40 @@ **You own the skill's voice.** -1. Use the active harness's skill-authoring workflow when it exists. Otherwise follow the `SKILL.md` format and run the repository validator. -2. Validate the skill: frontmatter has `name` and `description`, referenced files exist, cross-skill links resolve. -3. Test cases if structural. Skip if subjective. -4. Run **Opening a PR**. +1. Locate the skill and its owner. Preserve an existing path. For a new skill, + use the active harness's discovered project skill root, or its user skill + root when the user requested a personal skill. Read applicable local + instructions. If a skill creator is listed in the harness catalog, read it; + otherwise continue with the steps here. A skills-only mstack installation + includes this playbook and does not require an extra authoring tool. +2. Draft `/SKILL.md`. Use a lowercase kebab-case directory name and matching + `name` frontmatter. Write `description` as one quoted YAML scalar with the + task that should trigger it. Put the ordered workflow and a checkable + completion condition in the body. Keep examples, scripts, and branch-specific + instructions beside the skill and link them relative to `SKILL.md`. Preserve + existing useful instructions when adapting a skill to another harness. +3. Review the description. Write two realistic requests that should select the + skill and two nearby requests that should not. Check the description against + each without reading the body. Name a missing trigger or remove an overly + broad one, then check the examples again. Keep personal mode skills explicitly + invoked unless the user requested automatic use. If the host can test actual + discovery, run one positive and one negative case and record what happened. +4. Validate the draft. Check `name` and `description`, YAML quoting, linked files, + and every referenced tool or command. Use a repository or harness validator + when one is present. In an mstack source checkout, run + `node scripts/validate.mjs` from its root and follow the repository's inventory + and integrity baseline instructions. In an installed skill directory, perform + these checks directly; do not assume the repository's `scripts/` exists. + For a separately installed subset, resolve each sibling skill through the + active catalog. Supply an inline fallback or report a missing dependency. +5. Exercise changed behavior. Run a bundled helper on disposable input, or walk + through one realistic request and check that every step has an available + action and an observable result. Test structural behavior when it has a cheap + reproducible check. For subjective prose, review the draft with the user. +6. For repository-owned skills, run [Opening a PR](./opening-a-pr.md) within the + user's authorized workflow. For a personal skill outside a repository, + validate the local edit and report its path; do not invent a repository or + publish personal conventions. When in doubt, delete. Keep only prose that changes a decision. Tell it to do the thing and skip the reason. Explain only when the rule is confusing without one. Match tone to scope. Point at structural sources (types, READMEs, config) per the **encode-lessons-in-structure** principle skill. Delegate to other skills by path. Don't restate. A workflow you keep hitting but isn't captured → propose a new skill. diff --git a/skills/recall/SKILL.md b/skills/recall/SKILL.md index 589c920..ed07ae2 100644 --- a/skills/recall/SKILL.md +++ b/skills/recall/SKILL.md @@ -16,11 +16,15 @@ history. Prefer live repository state over stale conversation claims. 2. **Lock scope.** Pin the topic, workspace, and time window. Default "recent" to seven days. Never expand one project's request into other workspaces. 3. **Discover history safely.** Use the active harness entry in - [history-sources.md](./references/history-sources.md). Prefer documented CLI - export commands over direct database reads. Exclude the current session and - obvious subagent, evaluation, and test noise. + [history-sources.md](./references/history-sources.md). The bundled + [history helper](./scripts/history.mjs) lists workspace-scoped metadata before + reading a selected session. It is available in a skills-only installation. + Prefer documented CLI export commands over direct database reads. Exclude + the current session and obvious subagent, evaluation, and test noise. 4. **Search narrowly.** Order candidates by actual modification time, search for - the topic first, then read only matching sessions and relevant regions. For a + the topic first, then read only matching sessions and relevant regions. Pass + `--query` and output limits to the helper, and preserve its warnings and + truncation status in your assessment. For a large authorized corpus, delegate non-overlapping time slices if the host supports parallel agents. Keep raw transcripts out of the final context. 5. **Sweep shared records.** For a named feature, file, subsystem, or incident, diff --git a/skills/recall/references/history-sources.md b/skills/recall/references/history-sources.md index 37c317c..f7490ba 100644 --- a/skills/recall/references/history-sources.md +++ b/skills/recall/references/history-sources.md @@ -3,39 +3,108 @@ Use only the active workspace and the scope authorized by the user. History formats change, so prefer a harness CLI or index before reading raw storage. +## Bundled reader + +Run the helper relative to the installed `recall` directory. It needs Node.js +18 or newer and no repository checkout or optional artifact installation: + +```bash +node /scripts/history.mjs list --harness codex --workspace --exclude +node /scripts/history.mjs read --harness codex --workspace --session --query parser --limit 8 --max-chars 6000 +``` + +Choose `codex`, `claude`, `opencode`, or `pi` for `--harness`. `list` emits IDs, +workspaces, and update times, without titles or message text. It defaults to the +last seven days and 20 results. `--since ` changes the time window; +`--limit` changes the result count. `read` requires a session ID from that +workspace, returns the last 20 user/assistant messages by default, and caps text +at 12,000 characters. Pi branch and compaction summaries use role `summary`. +The character budget is allocated to the newest messages first, then output is +returned in conversation order. +`--query` keeps messages containing that literal text, without case sensitivity. +Reading an explicit session has no default time cutoff. + +Repeat `--exclude ` for the current session and known test/evaluation runs. +Codex also excludes `CODEX_THREAD_ID` automatically. Known child-session metadata +and `subagents/` directories are excluded. Other harnesses do not reliably expose +the active session ID to a child process, so supply it explicitly. For `reflect`, +which intentionally reads the active session, use the transcript already in +context or a digest when its ID cannot be selected through this reader. + +The helper checks workspace metadata before extracting messages. Claude and +default pi discovery first restrict the filesystem search to the workspace +directory. Codex and explicit pi session directories require reading session +headers to find the workspace. Symlink entries are not followed. Missing stores +return an empty list; unreadable or unsupported metadata and malformed selected +records produce warnings without including raw record contents. A selected file +over 16 MiB is rejected. Use a native export or reader for larger sessions. + +Read output remains private source material. The helper omits tool payloads but +does not redact secrets that appear in user or assistant text. Review excerpts +before using them in a brief. `truncated: true` and warnings mean the result is +incomplete; do not treat it as proof that an event never happened. + ## Codex -Typical user-level sources: +Resolve the user root from non-empty `CODEX_HOME`, otherwise `~/.codex`. +`--root ` overrides that root for the helper. Typical sources below that +root are: -- `~/.codex/history.jsonl` for a lightweight prompt index. -- `~/.codex/session_index.jsonl` for session metadata when present. -- `~/.codex/sessions/` and `~/.codex/archived_sessions/` for session records. +- `history.jsonl` for a lightweight prompt index. +- `session_index.jsonl` for session metadata when present. +- `sessions/` and `archived_sessions/` for session records. Use metadata to narrow the candidate set before reading session contents. Do -not scan unrelated workspace sessions. +not scan unrelated workspace messages. The helper supports rollouts whose first +record is `session_meta` with `payload.id` and `payload.cwd`; it reads +`response_item` messages, or older user/agent `event_msg` records when response +messages are absent. It does not reconstruct an unsaved active branch or load +history index prompts across projects. ## Claude Code -Typical user-level sources: +Resolve the user root from non-empty `CLAUDE_CONFIG_DIR`, otherwise `~/.claude`. +`--root ` overrides that root for the helper. Typical sources are: -- `~/.claude/history.jsonl` for history metadata. -- `~/.claude/projects/` for project-scoped session records. +- `history.jsonl` for history metadata. +- `projects/` for project-scoped session records. Map the active workspace to its project directory, order records by modification -time, and read only matching conversations. +time, and read only matching conversations. The helper uses the standard project +slug, replacing each non-alphanumeric workspace character with `-`, and verifies +`cwd` and `sessionId` in a record before extracting text. It supports UUID and +`parentUuid` conversation trees, defaults to the last persisted entry, and accepts +`--leaf ` for a specific branch. Missing parents or duplicate IDs fail +explicitly. Nonstandard or shortened project slugs require native discovery; +the helper does not fall back to scanning all project directories. ## OpenCode Prefer the stable CLI surface: ```bash -opencode session list -opencode export --sanitize +opencode session list --format json --pure +opencode export --sanitize --pure ``` Use `--sanitize` whenever exported data will leave the local machine. Do not depend on OpenCode's internal database layout unless the CLI cannot provide the -required authorized record. +required authorized record. The helper runs these commands with the requested +workspace as its working directory. It filters list entries by exact `directory` +and excludes `parentID` sessions before exporting the selected ID, then verifies +the export's `info.id` and `info.directory`. Sanitized exports may replace the +directory with `[redacted:session-directory:]`; the helper accepts +only the marker for the already selected session. It inherits OpenCode's configured +environment, including `XDG_DATA_HOME`; `--root` is not supported. A missing CLI +or unsupported JSON schema is an error, not an empty-history result. + +OpenCode's sanitizer can replace all message text with `[redacted:text:...]`. +The helper reports that limitation; sanitized text cannot recover a conversation. +For private local recovery, explicitly add `--local-text` to `read` with a +workspace and session ID. Only that selected session is exported without +`--sanitize`. Output goes to stdout, no transcript file is written, and the +result contains `sanitized: false`. As with the other harness readers, inspect +unredacted text before quoting it in any brief or shared artifact. ## Pi @@ -44,12 +113,20 @@ Pi stores persistent sessions as JSONL trees. The default root is (`----`, with `/`, `\\`, and `:` replaced by `-`), and files are named `_.jsonl`. Set `PI_CODING_AGENT_SESSION_DIR` or pass `--session-dir ` when using a custom root; the CLI flag takes precedence. +For the helper, `--root ` names that exact custom session directory and +takes precedence over the environment variable. Otherwise the helper uses the +workspace slug under `$PI_CODING_AGENT_DIR/sessions/`, falling back to +`~/.pi/agent/sessions/` when the agent directory is unset or empty. Prefer `pi -r`/`/resume` for interactive discovery and `pi --export ` (or `/export`) when a readable HTML transcript is needed. For programmatic recall, parse JSONL entries by `type` and follow the `id`/`parentId` tree from the active leaf; do not treat the file as a linear transcript. Session versions 1 and 2 -are migrated to version 3 when loaded. `--no-session` and RPC clients started +are migrated to version 3 when loaded by pi. The read-only helper accepts version +3 and does not migrate files. It follows the last persisted entry by default; +pass `--leaf ` when a different branch matters. Missing parents, +duplicate IDs, and cycles fail explicitly. An in-memory branch change with no +persisted entry cannot be inferred from the file. `--no-session` and RPC clients started with `--no-session` do not create a persistent session. Limit scans to the active workspace's slug and use the newest matching session diff --git a/skills/recall/scripts/history.mjs b/skills/recall/scripts/history.mjs new file mode 100644 index 0000000..6252a7e --- /dev/null +++ b/skills/recall/scripts/history.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +import { createReadStream } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const HARNESSES = ["codex", "claude", "opencode", "pi"]; +const MAX_FILE_BYTES = 16 * 1024 * 1024; + +function pathKey(path) { + const normalized = resolve(path); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function sameWorkspace(left, right) { + return typeof left === "string" && pathKey(left) === pathKey(right); +} + +function expandHome(path) { + if (path === "~") return homedir(); + return /^[~][/\\]/.test(path) ? join(homedir(), path.slice(2)) : resolve(path); +} + +function storeRoot(options) { + if (options.root) return expandHome(options.root); + const { env, harness } = options; + if (harness === "codex") return expandHome(env.CODEX_HOME || join(homedir(), ".codex")); + if (harness === "claude") return expandHome(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")); + if (env.PI_CODING_AGENT_SESSION_DIR) return expandHome(env.PI_CODING_AGENT_SESSION_DIR); + const slug = `--${options.workspace.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + return join(expandHome(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent")), "sessions", slug); +} + +async function filesUnder(directory, warnings) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code !== "ENOENT") warnings.push(`A history directory could not be listed (${error.code ?? "I/O error"}).`); + return []; + } + const files = []; + for (const entry of entries) { + if (entry.name === "subagents") continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await filesUnder(path, warnings)); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path); + } + return files; +} + +async function metadata(path, harness) { + const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 4096, end: 1024 * 1024 - 1 }); + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + let count = 0; + try { + for await (const line of lines) { + if (++count > 20 || line.length > 1024 * 1024) break; + if (!line.trim()) continue; + const row = JSON.parse(line); + if (harness === "codex") { + if (row.type !== "session_meta") break; + return { id: row.payload?.id, workspace: row.payload?.cwd, child: Boolean(row.payload?.source?.subagent) }; + } + if (harness === "pi") { + if (row.type !== "session" || row.version !== 3) break; + return { id: row.id, workspace: row.cwd }; + } + if (typeof row.cwd === "string" && typeof row.sessionId === "string") { + return { id: row.sessionId, workspace: row.cwd, child: row.isSidechain === true }; + } + } + throw new Error("Unsupported session metadata"); + } catch { + throw new Error("Session metadata is unreadable or unsupported."); + } finally { + lines.close(); + stream.destroy(); + } +} + +function openCode(args, cwd, env) { + let command = "opencode"; + let commandArgs = args; + if (process.platform === "win32") { + const quoted = args.map((arg) => `'${arg.replaceAll("'", "''")}'`).join(", "); + const script = `$ErrorActionPreference = 'Stop'\n$recallArgs = @(${quoted})\n& opencode @recallArgs\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }`; + command = "powershell.exe"; + commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]; + } + const result = spawnSync(command, commandArgs, { cwd, env, encoding: "utf8", maxBuffer: MAX_FILE_BYTES, timeout: 30_000, windowsHide: true }); + if (result.error || result.status !== 0) throw new Error("OpenCode history command failed; check CLI availability and its configured data directory."); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error("OpenCode returned unsupported JSON output."); + } +} + +async function discover(options, warnings) { + const { harness, workspace } = options; + if (harness === "opencode") { + const rows = await options.runOpenCode(["session", "list", "--format", "json", "--pure"], workspace, options.env); + if (!Array.isArray(rows)) throw new Error("OpenCode session list must return a JSON array."); + return rows.filter((row) => row && sameWorkspace(row.directory, workspace)).map((row) => ({ + id: row.id, workspace: row.directory, updated: row.updated ?? row.time?.updated, child: Boolean(row.parentID), + })); + } + const root = storeRoot(options); + const roots = harness === "codex" ? [join(root, "sessions"), join(root, "archived_sessions")] + : harness === "claude" ? [join(root, "projects", workspace.replace(/[^a-zA-Z0-9]/g, "-"))] : [root]; + const sessions = []; + for (const directory of roots) { + for (const path of await filesUnder(directory, warnings)) { + try { + const file = await stat(path); + if (file.mtimeMs < options.since) continue; + const info = await metadata(path, harness); + if (sameWorkspace(info.workspace, workspace)) sessions.push({ ...info, path, updated: file.mtimeMs }); + } catch { + warnings.push("Skipped a session with unreadable or unsupported metadata."); + } + } + } + return sessions; +} + +function textContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.filter((part) => ["text", "input_text", "output_text"].includes(part?.type) && typeof part.text === "string") + .map((part) => part.text).join("\n"); +} + +function conversationBranch(rows, harness, leaf) { + const idKey = harness === "pi" ? "id" : "uuid"; + const parentKey = harness === "pi" ? "parentId" : "parentUuid"; + const entries = rows.filter((row) => typeof row[idKey] === "string" && row.type !== "session"); + if (!entries.length) return []; + const byId = new Map(entries.map((row) => [row[idKey], row])); + if (byId.size !== entries.length) throw new Error("Session contains duplicate branch entry IDs."); + let current = leaf ?? entries.at(-1)[idKey]; + if (!byId.has(current)) throw new Error("Unknown leaf in the selected session."); + const seen = new Set(); + const branch = []; + while (current !== null && current !== undefined) { + if (seen.has(current)) throw new Error("Session contains a branch cycle."); + seen.add(current); + const row = byId.get(current); + if (!row) throw new Error("Session branch is incomplete; a parent entry is missing."); + branch.push(row); + current = row[parentKey]; + } + return branch.reverse(); +} + +async function readMessages(session, options, warnings) { + if (options.harness === "opencode") { + const args = ["export", session.id, ...(options.localText ? [] : ["--sanitize"]), "--pure"]; + const data = await options.runOpenCode(args, options.workspace, options.env); + const directory = data?.info?.directory; + // Sanitized exports replace the directory after list metadata established the workspace. + const matchesDirectory = sameWorkspace(directory, options.workspace) || directory === `[redacted:session-directory:${session.id}]`; + if (data?.info?.id !== session.id || !matchesDirectory || !Array.isArray(data.messages)) { + throw new Error("OpenCode export does not match the selected workspace and session."); + } + const messages = data.messages.map((row) => ({ role: row.info?.role, text: textContent(row.parts) })); + if (!options.localText && messages.some((message) => /\[redacted:text:/.test(message.text))) { + warnings.push("OpenCode sanitized export redacted message text. Use --local-text for private local recovery of the selected session."); + } + return messages; + } + // Recheck metadata when opening the chosen file; discovery is not a permanent authorization token. + const info = await metadata(session.path, options.harness); + if (info.id !== session.id || !sameWorkspace(info.workspace, options.workspace) || info.child) { + throw new Error("Session metadata changed since discovery."); + } + if ((await stat(session.path)).size > MAX_FILE_BYTES) throw new Error("Selected session exceeds 16 MiB; use the harness export or a narrower native reader."); + const rows = []; + let malformed = 0; + for (const line of (await readFile(session.path, "utf8")).split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row)) rows.push(row); + else malformed++; + } catch { malformed++; } + } + if (malformed) warnings.push(`Skipped ${malformed} malformed record(s) in the selected session.`); + if (options.harness === "codex") { + const messages = rows.filter((row) => row.type === "response_item" && row.payload?.type === "message"); + if (messages.length) return messages.map((row) => ({ role: row.payload.role, text: textContent(row.payload.content) })); + return rows.filter((row) => row.type === "event_msg" && ["user_message", "agent_message"].includes(row.payload?.type)) + .map((row) => ({ role: row.payload.type === "user_message" ? "user" : "assistant", text: textContent(row.payload.message) })); + } + return conversationBranch(rows, options.harness, options.leaf).map((row) => + options.harness === "pi" && ["compaction", "branch_summary"].includes(row.type) + ? { role: "summary", text: textContent(row.summary) } + : { role: row.message?.role, text: textContent(row.message?.content) }); +} + +export async function history(input) { + const options = { env: process.env, runOpenCode: openCode, limit: 20, maxChars: 12_000, ...input }; + if (!["list", "read"].includes(options.command)) throw new Error("Command must be list or read."); + if (!HARNESSES.includes(options.harness)) throw new Error(`--harness must be one of ${HARNESSES.join(", ")}.`); + if (typeof options.workspace !== "string" || !options.workspace.trim()) throw new Error("--workspace is required."); + options.workspace = expandHome(options.workspace); + for (const name of ["limit", "maxChars"]) { + if (!Number.isSafeInteger(options[name]) || options[name] < 1 || options[name] > 1_000_000) throw new Error(`${name} must be an integer between 1 and 1000000.`); + } + options.since = options.since === undefined ? (options.command === "list" ? Date.now() - 7 * 86400_000 : 0) : Date.parse(options.since); + if (!Number.isFinite(options.since)) throw new Error("--since must be an ISO date or timestamp."); + if (options.command === "read" && (typeof options.session !== "string" || !options.session.trim())) throw new Error("read requires --session."); + if (options.localText && (options.harness !== "opencode" || options.command !== "read")) throw new Error("--local-text is supported only for OpenCode read with an explicit workspace and session."); + if (options.leaf && !["pi", "claude"].includes(options.harness)) throw new Error("--leaf is supported only for pi and Claude."); + if (options.root && options.harness === "opencode") throw new Error("OpenCode uses its own configured data directory; --root is not supported."); + const exclusions = new Set(options.exclude ?? []); + if (options.harness === "codex" && options.env.CODEX_THREAD_ID) exclusions.add(options.env.CODEX_THREAD_ID); + const warnings = []; + const discovered = await discover(options, warnings); + if (discovered.some((session) => typeof session.id !== "string" || !session.id || !Number.isFinite(session.updated))) { + warnings.push("Skipped session metadata without a supported ID or update timestamp."); + } + const candidates = discovered.filter((session) => + typeof session.id === "string" && session.id.length > 0 && !session.child && !exclusions.has(session.id) + && Number.isFinite(session.updated) && session.updated >= options.since) + .sort((left, right) => right.updated - left.updated); + const byId = new Map(); + for (const session of candidates) if (!byId.has(session.id)) byId.set(session.id, session); + const sessions = [...byId.values()]; + const publicSession = ({ id, workspace, updated }) => ({ id, workspace, updated: new Date(updated).toISOString() }); + if (options.command === "list") return { sessions: sessions.slice(0, options.limit).map(publicSession), truncated: sessions.length > options.limit, warnings }; + const session = sessions.find((candidate) => candidate.id === options.session); + if (!session) throw new Error("Session not found in the requested workspace, time window, or exclusions."); + const messages = (await readMessages(session, options, warnings)) + .filter((message) => ["user", "assistant", "summary"].includes(message.role) && message.text) + .filter((message) => !options.query || message.text.toLowerCase().includes(options.query.toLowerCase())); + let budget = options.maxChars; + let truncated = messages.length > options.limit; + const selected = []; + for (const message of messages.slice(-options.limit).reverse()) { + if (budget === 0) { truncated = true; break; } + const text = message.text.slice(0, budget); + if (text.length !== message.text.length) truncated = true; + budget -= text.length; + selected.push({ role: message.role, text }); + } + return { session: publicSession(session), messages: selected.reverse(), sanitized: options.harness === "opencode" && !options.localText, truncated, warnings }; +} + +function argumentsFor(argv) { + const options = { command: argv[0], exclude: [] }; + const flags = { "--harness": "harness", "--workspace": "workspace", "--root": "root", "--since": "since", "--session": "session", "--leaf": "leaf", "--query": "query", "--limit": "limit", "--max-chars": "maxChars", "--exclude": "exclude" }; + for (let index = 1; index < argv.length; index++) { + if (argv[index] === "--local-text") { options.localText = true; continue; } + const key = flags[argv[index]]; + if (!key) throw new Error(`Unknown option: ${argv[index]}`); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argv[index]} requires a value.`); + if (key === "exclude") options.exclude.push(value); + else options[key] = ["limit", "maxChars"].includes(key) ? Number(value) : value; + index++; + } + return options; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + if (process.argv.includes("--help")) { + console.log("Usage: node history.mjs --harness --workspace \nOptions: --root --since --exclude (repeatable) --limit \nRead: --session [--query ] [--max-chars ] [--leaf ]\nOpenCode read: --local-text returns private, unsanitized message text."); + } else console.log(JSON.stringify(await history(argumentsFor(process.argv.slice(2))), null, 2)); + } catch (error) { + console.error(`recall: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/skills/reflect/SKILL.md b/skills/reflect/SKILL.md index 0048aa4..37db227 100644 --- a/skills/reflect/SKILL.md +++ b/skills/reflect/SKILL.md @@ -15,15 +15,16 @@ Invoke when the user says "reflect" or "/reflect". Skip when the conversation is ### 1. Locate the active transcript -The parent finds its own transcript file before fanning out. Use the active Harness's history adapter to locate the workspace-scoped transcript directory. Do not glob across unrelated project stores. +The parent identifies the active session before fanning out. Prefer the +conversation already in context or a transcript path supplied by the host. When +lookup is necessary, use [recall's history sources](../recall/references/history-sources.md) +with the active workspace and known session ID. Match the opening user prompt +within the selected conversation, not at a fixed JSONL line or field shared +across harnesses. Keep warnings and truncation limits with the excerpts. -```bash -ls -t /*.jsonl /*/*.jsonl /*/subagents/*.jsonl 2>/dev/null | head -10 -``` - -Three transcript layouts: legacy flat (`.jsonl`), current nested (`/.jsonl`), and subagent (`/subagents/.jsonl`). - -For each candidate, read the first JSONL line and check that `message.content[0].text` contains the conversation's opening user prompt. Take the matching path. If no path resolves, write a tight digest of the session and pass that instead. +If recall is not installed or the active session has no readable persisted +record, write a tight digest of the current conversation and pass that instead. +Do not search other project stores to compensate for a missing active session. ### 2. Spawn three reviewers in parallel @@ -57,9 +58,15 @@ Backlog items file to whatever devex / backlog tracker your team uses automatica For each approved Accepted item, follow the Routing field exactly: - Trivial existing-skill edit (a one-line bullet, a tightened sentence, a stale fact corrected): parent does directly. -- Substantive existing-skill edit (a new section, a new pattern table, more than ~10 lines): hand to [the repository authoring playbook](../meta-mode/playbooks/authoring-a-skill.md) and run its draft, validation, and review steps. -- `tune description: ` (the skill exists but didn't trigger when it should have): use the repository authoring playbook's description review steps. -- `new skill via authoring workflow: `: hand creation to the skill-authoring workflow. Do not invent the shape ad hoc. +- Substantive existing-skill edit (a new section, a new pattern table, more than ~10 lines): follow [the bundled authoring playbook](../meta-mode/playbooks/authoring-a-skill.md) and run its draft, validation, and review steps. +- `tune description: ` (the skill exists but didn't trigger when it should have): use the authoring playbook's description review in step 3. +- `new skill via authoring workflow: `: follow that playbook's placement and draft steps. + +If the playbook is absent from a selected-skills installation, preserve the +existing skill format or create `/SKILL.md` with matching `name` and a +quoted `description`. Check links and available tools, and verify two triggering +and two non-triggering requests against the description. Exercise changed +structural behavior with a disposable example. Report the checks you could run. If your environment ships a SKILL.md validator, run it on every touched skill before declaring done. Skip this step if it doesn't. From 76e022ad50ce5b01f707559bbb2f82ca5e67c85f Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:53:29 +0800 Subject: [PATCH 2/5] fix(recall): resolve symlink aliases in CLI entry detection --- scripts/history.test.mjs | 23 ++++++++++++++++++++++- skills/recall/scripts/history.mjs | 6 +++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/history.test.mjs b/scripts/history.test.mjs index 7106fc7..3563ddd 100644 --- a/scripts/history.test.mjs +++ b/scripts/history.test.mjs @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { cpSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; import test from "node:test"; import { history } from "../skills/recall/scripts/history.mjs"; @@ -175,3 +176,23 @@ test("the copied recall skill runs without repository scripts and validates CLI assert.doesNotMatch(failed.stderr, /at file:/); } }); + +test("the recall CLI runs through a linked directory while imports stay silent", (t) => { + const { root, workspace } = fixture(t); + const installed = join(root, "installed-recall"); + const linked = join(root, "linked-recall"); + cpSync(resolve("skills/recall"), installed, { recursive: true }); + symlinkSync(installed, linked, process.platform === "win32" ? "junction" : "dir"); + const script = join(linked, "scripts", "history.mjs"); + const args = [script, "list", "--harness", "codex", "--workspace", workspace, "--root", join(root, "empty")]; + for (const flags of [[], ["--preserve-symlinks-main"]]) { + const result = spawnSync(process.execPath, [...flags, ...args], { encoding: "utf8", cwd: root }); + assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.stdout.trim(), "", "the CLI must run when its entry path has a symlink alias"); + assert.deepEqual(JSON.parse(result.stdout).sessions, []); + } + const importing = spawnSync(process.execPath, ["--input-type=module", "--eval", `await import(${JSON.stringify(pathToFileURL(script).href)})`], { encoding: "utf8", cwd: root }); + assert.equal(importing.status, 0, importing.stderr); + assert.equal(importing.stdout, ""); + assert.equal(importing.stderr, ""); +}); diff --git a/skills/recall/scripts/history.mjs b/skills/recall/scripts/history.mjs index 6252a7e..c3cac17 100644 --- a/skills/recall/scripts/history.mjs +++ b/skills/recall/scripts/history.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node -import { createReadStream } from "node:fs"; +import { createReadStream, realpathSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { spawnSync } from "node:child_process"; -import { pathToFileURL } from "node:url"; const HARNESSES = ["codex", "claude", "opencode", "pi"]; const MAX_FILE_BYTES = 16 * 1024 * 1024; @@ -268,7 +267,8 @@ function argumentsFor(argv) { return options; } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { +// Node can resolve module symlinks while argv retains the original launch path. +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url))) { try { if (process.argv.includes("--help")) { console.log("Usage: node history.mjs --harness --workspace \nOptions: --root --since --exclude (repeatable) --limit \nRead: --session [--query ] [--max-chars ] [--leaf ]\nOpenCode read: --local-text returns private, unsanitized message text."); From 97e26140e4ab9c27f76955f2c2597d3d4c4bc5e3 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:54:38 +0800 Subject: [PATCH 3/5] fix(recall): keep stdin imports out of CLI detection --- scripts/history.test.mjs | 6 ++++++ skills/recall/scripts/history.mjs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/history.test.mjs b/scripts/history.test.mjs index 3563ddd..02c94a8 100644 --- a/scripts/history.test.mjs +++ b/scripts/history.test.mjs @@ -195,4 +195,10 @@ test("the recall CLI runs through a linked directory while imports stay silent", assert.equal(importing.status, 0, importing.stderr); assert.equal(importing.stdout, ""); assert.equal(importing.stderr, ""); + const stdinImport = spawnSync(process.execPath, ["--input-type=module", "-"], { + input: `await import(${JSON.stringify(pathToFileURL(script).href)})`, encoding: "utf8", cwd: root, + }); + assert.equal(stdinImport.status, 0, stdinImport.stderr); + assert.equal(stdinImport.stdout, ""); + assert.equal(stdinImport.stderr, ""); }); diff --git a/skills/recall/scripts/history.mjs b/skills/recall/scripts/history.mjs index c3cac17..7b52038 100644 --- a/skills/recall/scripts/history.mjs +++ b/skills/recall/scripts/history.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createReadStream, realpathSync } from "node:fs"; +import { createReadStream, existsSync, realpathSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; @@ -268,7 +268,7 @@ function argumentsFor(argv) { } // Node can resolve module symlinks while argv retains the original launch path. -if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url))) { +if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url))) { try { if (process.argv.includes("--help")) { console.log("Usage: node history.mjs --harness --workspace \nOptions: --root --since --exclude (repeatable) --limit \nRead: --session [--query ] [--max-chars ] [--leaf ]\nOpenCode read: --local-text returns private, unsanitized message text."); From 9555e930aad65a96ccdc91fbe8972eb379bd3dbe Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:00:41 +0800 Subject: [PATCH 4/5] fix(recall): await history stream closure before returning --- scripts/history.test.mjs | 21 ++++++++++++++++++++- skills/recall/scripts/history.mjs | 3 +++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/history.test.mjs b/scripts/history.test.mjs index 02c94a8..5f3bc7e 100644 --- a/scripts/history.test.mjs +++ b/scripts/history.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -53,6 +53,25 @@ test("Claude scopes its project directory and follows the selected conversation assert.deepEqual((await history({ ...options, leaf: "discarded" })).messages.map((message) => message.text), ["Start", "Old approach"]); }); +test("history discovery releases file handles before callers move and delete the store", async (t) => { + const { root, workspace } = fixture(t); + const headers = [ + { type: "session_meta", payload: { id: "matching", cwd: workspace } }, + { type: "session_meta", payload: { id: "unrelated", cwd: join(root, "other") } }, + "MALFORMED HEADER", + ]; + for (let index = 0; index < headers.length; index++) { + const store = join(root, `store-${index}`); + const moved = join(root, `moved-${index}`); + jsonl(join(store, "archived_sessions", "session.jsonl"), [headers[index], "Unneeded body".repeat(1000)]); + const result = await history({ harness: "codex", workspace, root: store, env: {}, command: "list" }); + assert.equal(result.sessions.length, index === 0 ? 1 : 0); + renameSync(store, moved); + rmSync(moved, { recursive: true, force: true }); + assert.equal(existsSync(moved), false); + } +}); + test("discovery sorts by modification time, deduplicates newest records and honors the time window", async (t) => { const { root, workspace } = fixture(t); const meta = { type: "session_meta", payload: { id: "same", cwd: workspace } }; diff --git a/skills/recall/scripts/history.mjs b/skills/recall/scripts/history.mjs index 7b52038..7f00b88 100644 --- a/skills/recall/scripts/history.mjs +++ b/skills/recall/scripts/history.mjs @@ -54,6 +54,7 @@ async function filesUnder(directory, warnings) { async function metadata(path, harness) { const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 4096, end: 1024 * 1024 - 1 }); + const closed = new Promise((resolveClosed) => stream.once("close", resolveClosed)); const lines = createInterface({ input: stream, crlfDelay: Infinity }); let count = 0; try { @@ -79,6 +80,8 @@ async function metadata(path, harness) { } finally { lines.close(); stream.destroy(); + // On Windows, destroy() returns before its file descriptor is closed. + await closed; } } From c2fbad4cd48fd8d2f5239fa429b5ea22b96a524a Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:29:43 +0800 Subject: [PATCH 5/5] fix(recall): reject repeated options and deduplicate diagnostics --- scripts/history.test.mjs | 24 ++++++++++++++++++++++++ skills/recall/scripts/history.mjs | 7 +++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/history.test.mjs b/scripts/history.test.mjs index 5f3bc7e..4ac0d1a 100644 --- a/scripts/history.test.mjs +++ b/scripts/history.test.mjs @@ -153,12 +153,14 @@ test("missing stores and malformed records produce controlled diagnostics withou const options = { harness: "codex", workspace, root: join(root, "missing"), command: "list", env: {} }; assert.deepEqual((await history(options)).sessions, []); jsonl(join(options.root, "sessions", "broken.jsonl"), ["PRIVATE INVALID HEADER"]); + jsonl(join(options.root, "sessions", "also-broken.jsonl"), ["ANOTHER PRIVATE INVALID HEADER"]); const broken = await history(options); assert.equal(broken.warnings.length, 1); assert.doesNotMatch(JSON.stringify(broken), /PRIVATE/); jsonl(join(options.root, "sessions", "good.jsonl"), [{ type: "session_meta", payload: { id: "good", cwd: workspace } }, "PRIVATE INVALID BODY"]); const reading = await history({ ...options, command: "read", session: "good" }); assert.deepEqual(reading.messages, []); + assert.equal(reading.warnings.length, 2, "keep distinct diagnostics but emit each only once"); assert.ok(reading.warnings.some((warning) => /malformed record/.test(warning))); assert.doesNotMatch(JSON.stringify(reading), /PRIVATE/); }); @@ -221,3 +223,25 @@ test("the recall CLI runs through a linked directory while imports stay silent", assert.equal(stdinImport.stdout, ""); assert.equal(stdinImport.stderr, ""); }); + +test("the recall CLI rejects duplicate single-value options and switches but permits repeated exclusions", (t) => { + const { root, workspace } = fixture(t); + const store = join(root, "codex"); + for (const id of ["first", "second"]) { + jsonl(join(store, "sessions", `${id}.jsonl`), [{ type: "session_meta", payload: { id, cwd: workspace } }]); + } + const args = [resolve("skills/recall/scripts/history.mjs"), "list", "--harness", "codex", "--workspace", workspace, "--root", store]; + for (const [flag, suffix] of [ + ["--workspace", ["--workspace", join(root, "other-workspace")]], + ["--limit", ["--limit", "1", "--limit", "2"]], + ["--local-text", ["--local-text", "--local-text"]], + ]) { + const result = spawnSync(process.execPath, [...args, ...suffix], { encoding: "utf8", cwd: root }); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + assert.match(result.stderr, new RegExp(`Duplicate option: ${flag}`)); + } + const excluded = spawnSync(process.execPath, [...args, "--exclude", "first", "--exclude", "second"], { encoding: "utf8", cwd: root }); + assert.equal(excluded.status, 0, excluded.stderr); + assert.deepEqual(JSON.parse(excluded.stdout).sessions, []); +}); diff --git a/skills/recall/scripts/history.mjs b/skills/recall/scripts/history.mjs index 7f00b88..d6b5e86 100644 --- a/skills/recall/scripts/history.mjs +++ b/skills/recall/scripts/history.mjs @@ -235,7 +235,7 @@ export async function history(input) { for (const session of candidates) if (!byId.has(session.id)) byId.set(session.id, session); const sessions = [...byId.values()]; const publicSession = ({ id, workspace, updated }) => ({ id, workspace, updated: new Date(updated).toISOString() }); - if (options.command === "list") return { sessions: sessions.slice(0, options.limit).map(publicSession), truncated: sessions.length > options.limit, warnings }; + if (options.command === "list") return { sessions: sessions.slice(0, options.limit).map(publicSession), truncated: sessions.length > options.limit, warnings: [...new Set(warnings)] }; const session = sessions.find((candidate) => candidate.id === options.session); if (!session) throw new Error("Session not found in the requested workspace, time window, or exclusions."); const messages = (await readMessages(session, options, warnings)) @@ -251,13 +251,16 @@ export async function history(input) { budget -= text.length; selected.push({ role: message.role, text }); } - return { session: publicSession(session), messages: selected.reverse(), sanitized: options.harness === "opencode" && !options.localText, truncated, warnings }; + return { session: publicSession(session), messages: selected.reverse(), sanitized: options.harness === "opencode" && !options.localText, truncated, warnings: [...new Set(warnings)] }; } function argumentsFor(argv) { const options = { command: argv[0], exclude: [] }; const flags = { "--harness": "harness", "--workspace": "workspace", "--root": "root", "--since": "since", "--session": "session", "--leaf": "leaf", "--query": "query", "--limit": "limit", "--max-chars": "maxChars", "--exclude": "exclude" }; + const seen = new Set(); for (let index = 1; index < argv.length; index++) { + if (argv[index] !== "--exclude" && seen.has(argv[index])) throw new Error(`Duplicate option: ${argv[index]}`); + seen.add(argv[index]); if (argv[index] === "--local-text") { options.localText = true; continue; } const key = flags[argv[index]]; if (!key) throw new Error(`Unknown option: ${argv[index]}`);