@astrosheep/pi-context 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/src/budget.js +10 -8
  2. package/dist/src/dream/cli.js +9 -8
  3. package/dist/src/dream/gates.js +2 -1
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/runner.js +84 -25
  6. package/dist/src/history-tools.js +5 -5
  7. package/dist/src/history.js +11 -6
  8. package/dist/src/index.js +14 -15
  9. package/dist/src/notes/address.js +31 -0
  10. package/dist/src/{memory → notes}/frontmatter.js +5 -3
  11. package/dist/src/{notes.js → notes/model.js} +1 -1
  12. package/dist/src/{memory → notes}/paths.js +5 -1
  13. package/dist/src/{memory → notes}/store.js +45 -72
  14. package/dist/src/notes/tools.js +153 -0
  15. package/dist/src/prompts.js +31 -29
  16. package/dist/src/protocol.js +8 -4
  17. package/dist/src/thresholds.js +4 -1
  18. package/dist/src/tool-output.js +4 -1
  19. package/dist/src/warning.js +3 -3
  20. package/dist/test/agent-loop.test.js +6 -4
  21. package/dist/test/coherence.test.js +5 -1
  22. package/dist/test/dream.test.js +133 -34
  23. package/dist/test/history.test.js +6 -1
  24. package/dist/test/integration.test.js +84 -34
  25. package/dist/test/{memory.test.js → notes.test.js} +138 -34
  26. package/dist/test/pagination.property.test.js +1 -1
  27. package/package.json +5 -5
  28. package/playbook.md +30 -3
  29. package/src/budget.ts +11 -9
  30. package/src/dream/cli.ts +8 -8
  31. package/src/dream/gates.ts +2 -1
  32. package/src/dream/git.ts +27 -0
  33. package/src/dream/runner.ts +81 -23
  34. package/src/history-tools.ts +5 -5
  35. package/src/history.ts +12 -7
  36. package/src/index.ts +13 -14
  37. package/src/notes/address.ts +33 -0
  38. package/src/{memory → notes}/frontmatter.ts +5 -3
  39. package/src/{notes.ts → notes/model.ts} +2 -2
  40. package/src/{memory → notes}/paths.ts +6 -1
  41. package/src/{memory → notes}/store.ts +47 -77
  42. package/src/notes/tools.ts +132 -0
  43. package/src/prompts.ts +31 -29
  44. package/src/protocol.ts +8 -4
  45. package/src/thresholds.ts +4 -1
  46. package/src/tool-output.ts +4 -1
  47. package/src/warning.ts +3 -3
  48. package/dist/src/dream/apply.js +0 -87
  49. package/dist/src/dream/manifest.js +0 -16
  50. package/dist/src/memory/tools.js +0 -175
  51. package/src/dream/apply.ts +0 -47
  52. package/src/dream/manifest.ts +0 -21
  53. package/src/memory/tools.ts +0 -175
@@ -1,43 +1,142 @@
1
1
  import test from "node:test";
2
- import nodeAssert from "node:assert/strict";
3
- import { mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, utimesSync, writeFileSync, existsSync } from "node:fs";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, linkSync, mkdirSync, readFileSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
4
+ import { mkdtempSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
- import { spawnSync } from "node:child_process";
7
- import { defaultDreamerSessionFactory, runDreamer, READ_ONLY_TOOLS } from "../src/dream/runner.js";
8
- const cli = join(process.cwd(), "dist/src/dream/cli.js");
9
- const assert = nodeAssert;
10
- assert.match = (value, expected) => nodeAssert.ok(typeof expected === "string" ? value.includes(expected) : expected.test(value));
11
- function fixture() { const home = mkdtempSync(join(tmpdir(), "dream-")); mkdirSync(join(home, "pi/session"), { recursive: true }); return home; }
12
- function note(home, session, name, body, window) { const dir = join(home, "pi/session", session); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, name), `---\nscope: session\norigin: self\nstatus: active\nsource_window: ${window}\ncreated_at: 2020-01-01T00:00:00.000Z\nupdated_at: 2020-01-01T00:00:00.000Z\nlast_accessed: 2020-01-01T00:00:00.000Z\naccess_count: 0\n---\n\n${body}`); }
13
- function dreamer(home, manifest, sentinel) { const file = join(home, "dreamer.mjs"); writeFileSync(file, `import {writeFileSync} from 'node:fs'; ${sentinel ? `writeFileSync(${JSON.stringify(sentinel)}, 'spawned');` : ""} process.stdin.resume(); process.stdin.on('end',()=>process.stdout.write(${JSON.stringify(JSON.stringify(manifest))}));`); return `node ${file}`; }
14
- function run(home, extra = []) { return spawnSync(process.execPath, [cli, "--notes-home", home, ...extra], { encoding: "utf8" }); }
7
+ import { acquireLock, failLock } from "../src/dream/lock.js";
8
+ import { materialGate, timeGate } from "../src/dream/gates.js";
9
+ import { defaultDreamerSessionFactory, dreamerWriteToolDefinitions, runDreamer, DREAMER_TOOLS } from "../src/dream/runner.js";
10
+ import { gitCommit } from "../src/dream/git.js";
11
+ import { execFileSync } from "node:child_process";
12
+ import { contentText } from "../src/history.js";
13
+ function fixture() { return mkdtempSync(join(tmpdir(), "dream-")); }
15
14
  function old(path) { const d = new Date(Date.now() - 48 * 3600_000); utimesSync(path, d, d); }
16
- test("dream time gate skips first, names reason, and does not spawn", () => { const h = fixture(); const sentinel = join(h, "sentinel"); const lock = join(h, ".dream.lock"); writeFileSync(lock, "999999"); const fresh = new Date(); utimesSync(lock, fresh, fresh); const r = run(h, ["--dreamer", dreamer(h, { report: "x" }, sentinel)]); assert.equal(r.status, 0); assert.match(r.stdout, "time gate"); assert.equal(existsSync(sentinel), false); });
17
- test("material gate skips when too few session directories changed", () => { const h = fixture(); const lock = join(h, ".dream.lock"); writeFileSync(lock, "999999"); old(lock); note(h, "only", "a.md", "a", "w"); const r = run(h, ["--min-sessions", "3", "--dreamer", dreamer(h, { report: "x" })]); assert.equal(r.status, 0); assert.match(r.stdout, "material gate"); });
18
- test("force bypasses time and material gates and external dreamer runs", () => { const h = fixture(); const r = run(h, ["--force", "--dreamer", dreamer(h, { report: "forced" })]); assert.equal(r.status, 0); assert.match(readFileSync(readdirSync(join(h, "dreams")).map(x => join(h, "dreams", x))[0], "utf8"), "forced"); });
19
- test("live PID lock excludes and dead PID is reclaimed", () => { const h = fixture(); const lock = join(h, ".dream.lock"); writeFileSync(lock, String(process.pid)); const r = run(h, ["--force", "--dreamer", dreamer(h, { report: "no" })]); assert.equal(r.status, 0); assert.match(r.stdout, "lock gate"); writeFileSync(lock, "999999"); old(lock); const ok = run(h, ["--force", "--dreamer", dreamer(h, { report: "reclaimed" })]); assert.equal(ok.status, 0); });
20
- test("external dreamer manifest merges recurrence, proposes global promotion, trashes, and reports", () => { const h = fixture(); note(h, "dream", "a.md", "one", "window-a"); note(h, "dream", "b.md", "two", "window-b"); note(h, "dream", "trash.md", "gone", "window-c"); const m = { merge: [{ into: "a.md", from: ["b.md"] }], promote: [{ path: "a.md", to: "global", reason: "shared" }], trash: [{ path: "trash.md", reason: "obsolete" }], report: "I dreamed on 2026-09-19." }; const r = run(h, ["--force", "--dreamer", dreamer(h, m)]); assert.equal(r.status, 0, r.stderr); const merged = readFileSync(join(h, "pi/session/dream/a.md"), "utf8"); assert.match(merged, "one"); assert.match(merged, "two"); assert.match(merged, "recurrence_count: 1"); assert.match(readFileSync(join(h, "pi/session/dream/b.md"), "utf8"), "status: superseded"); assert.equal(existsSync(join(h, "pi/session/dream/trash.md")), false); const trash = readdirSync(join(h, "trash"))[0]; assert.equal(readFileSync(join(h, "trash", trash, "session", "trash.md"), "utf8").includes("gone"), true); const reports = readdirSync(join(h, "dreams")); const report = readFileSync(join(h, "dreams", reports[0]), "utf8"); assert.match(report, "I dreamed"); assert.match(report, "proposal: promote"); assert.match(report, "trashed"); });
21
- test("invalid manifest path makes zero writes and exits nonzero", () => { const h = fixture(); note(h, "s1", "a.md", "one", "w"); const before = readFileSync(join(h, "pi/session/s1/a.md"), "utf8"); const r = run(h, ["--force", "--dreamer", dreamer(h, { merge: [{ into: "missing.md", from: ["a.md"] }], report: "bad" })]); assert.notEqual(r.status, 0); assert.match(r.stderr, "missing.md"); assert.equal(readFileSync(join(h, "pi/session/s1/a.md"), "utf8"), before); assert.equal(existsSync(join(h, "dreams")), false); });
22
- test("failed run restores prior lock mtime", () => { const h = fixture(); const lock = join(h, ".dream.lock"); writeFileSync(lock, "999999"); const prior = Date.now() - 48 * 3600_000; utimesSync(lock, new Date(prior), new Date(prior)); const r = run(h, ["--force", "--dreamer", dreamer(h, { merge: [{ into: "missing", from: [] }], report: "x" })]); assert.notEqual(r.status, 0); assert.ok(Math.abs(statSync(lock).mtimeMs - prior) < 2000); });
23
- test("SDK dreamer captures assistant event and receives read-only allowlist", async () => {
15
+ test("time and material gates preserve skip decisions and reasons", () => {
16
+ const home = fixture();
17
+ const lock = join(home, ".dream.lock");
18
+ writeFileSync(lock, "999999");
19
+ const fresh = timeGate(lock, 24);
20
+ assert.equal(fresh.ok, false);
21
+ assert.equal(fresh.reason, "time gate: lock is too fresh");
22
+ old(lock);
23
+ assert.deepEqual(timeGate(lock, 24).ok, true);
24
+ mkdirSync(join(home, "pi/session/one"), { recursive: true });
25
+ writeFileSync(join(home, "pi/session/one/a.md"), "a");
26
+ const material = materialGate(home, statSync(lock).mtimeMs, 1);
27
+ assert.equal(material.ok, true);
28
+ assert.match(material.reason, /material gate: 1 changed sessions/);
29
+ assert.equal(materialGate(home, Date.now(), 2).ok, false);
30
+ });
31
+ test("live lock is excluded, dead lock is reclaimed, and failures restore mtime", () => {
32
+ const home = fixture();
33
+ const lock = join(home, ".dream.lock");
34
+ writeFileSync(lock, String(process.pid));
35
+ const live = acquireLock(lock);
36
+ assert.equal(live.held, false);
37
+ assert.equal(live.reason, "lock gate: live process holds the lock");
38
+ writeFileSync(lock, "999999");
39
+ old(lock);
40
+ const prior = statSync(lock).mtimeMs;
41
+ const reclaimed = acquireLock(lock);
42
+ assert.equal(reclaimed.held, true);
43
+ utimesSync(lock, new Date(), new Date());
44
+ failLock(reclaimed);
45
+ assert.ok(Math.abs(statSync(lock).mtimeMs - prior) < 2000);
46
+ });
47
+ test("dreamer write jail accepts home files and refuses escapes", async () => {
48
+ const home = fixture();
49
+ const tools = new Map(dreamerWriteToolDefinitions(home).map((tool) => [tool.name, tool]));
50
+ const ctx = { cwd: home };
51
+ await tools.get("write").execute("write", { path: "global/x.md", content: "one" }, undefined, undefined, ctx);
52
+ assert.equal(readFileSync(join(home, "global/x.md"), "utf8"), "one");
53
+ const rejectsOutsideHome = async (tool, path) => {
54
+ const params = tool === "write" ? { path, content: "outside" } : { path, edits: [{ oldText: "one", newText: "outside" }] };
55
+ await assert.rejects(() => tools.get(tool).execute("escape", params, undefined, undefined, ctx), (error) => error.message.includes(home));
56
+ };
57
+ for (const tool of ["write", "edit"]) {
58
+ await rejectsOutsideHome(tool, "/tmp/dream-jail-outside.md");
59
+ await rejectsOutsideHome(tool, "../dream-jail-outside.md");
60
+ }
61
+ const outside = fixture();
62
+ symlinkSync(outside, join(home, "escape"));
63
+ await rejectsOutsideHome("write", "escape/outside.md");
64
+ await rejectsOutsideHome("edit", "escape/outside.md");
65
+ assert.equal(existsSync(join(outside, "outside.md")), false, "the jail does not write through an in-home symlink");
66
+ const outsideFile = join(outside, "outside.md");
67
+ writeFileSync(outsideFile, "outside");
68
+ symlinkSync(outsideFile, join(home, "global/outside-link.md"));
69
+ await rejectsOutsideHome("write", "global/outside-link.md");
70
+ assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a symlinked file outside home");
71
+ linkSync(outsideFile, join(home, "global/hardlink.md"));
72
+ await rejectsOutsideHome("write", "global/hardlink.md");
73
+ assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a hardlinked file outside home");
74
+ const insideFile = join(home, "global/inside.md");
75
+ writeFileSync(insideFile, "inside");
76
+ symlinkSync(insideFile, join(home, "global/inside-link.md"));
77
+ await tools.get("write").execute("write", { path: "global/inside-link.md", content: "updated" }, undefined, undefined, ctx);
78
+ assert.equal(readFileSync(insideFile, "utf8"), "updated", "the jail permits a symlinked file that resolves inside home");
79
+ });
80
+ test("dreamer allowlist contains only the file tools and reports their writes", async () => {
24
81
  let configured = [];
25
- const session = { subscribe(handler) { this.handler = handler; return () => { }; }, handler: (_event) => { }, async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: '{"report":"stub"}' }] } }); }, dispose() { } };
26
- const manifest = await runDreamer("playbook", "/tmp/notes", { sessionFactory: async (options) => { configured = options.tools; return session; } });
27
- assert.deepEqual(manifest, { report: "stub" });
28
- assert.deepEqual(configured, READ_ONLY_TOOLS);
29
- assert.equal(configured.includes("notes_write"), false);
30
- assert.equal(configured.includes("notes_edit"), false);
31
- });
32
- test("SDK dreamer surfaces provider stopReason error instead of manifest-parse lie", async () => {
33
- const session = { subscribe(handler) { this.handler = handler; return () => { }; }, handler: (_event) => { }, async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "402: {\"error\":{\"message\":\"Insufficient Balance\"}}" } }); }, dispose() { } };
34
- await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /Insufficient Balance/);
35
- await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /402/);
82
+ const session = {
83
+ subscribe(handler) { this.handler = handler; return () => { }; },
84
+ handler: (_event) => { },
85
+ async prompt(_text) { this.handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/a.md", content: "a" } }); this.handler({ type: "tool_execution_start", toolName: "edit", args: { path: "project/p.md", edits: [] } }); this.handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }, { type: "text", text: "again" }] } }); },
86
+ dispose() { },
87
+ };
88
+ const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: async (options) => { configured = options.tools; return session; } });
89
+ assert.deepEqual(configured, DREAMER_TOOLS);
90
+ assert.deepEqual(configured, ["read", "grep", "find", "ls", "write", "edit"]);
91
+ assert.equal(configured.some((tool) => tool.startsWith("notes_")), false);
92
+ assert.deepEqual(result.writes, [{ tool: "write", path: "global/a.md" }, { tool: "edit", path: "project/p.md" }]);
93
+ assert.equal(result.report, "done\nagain");
94
+ assert.equal(result.report, contentText([{ type: "text", text: "done" }, { type: "text", text: "again" }]), "dream and history share the text projection");
95
+ });
96
+ test("dreamer session has exactly the jailed file-tool allowlist", async () => {
97
+ const session = await defaultDreamerSessionFactory({ cwd: fixture(), tools: DREAMER_TOOLS });
98
+ try {
99
+ assert.deepEqual(session.agent.state.tools.map((tool) => tool.name).sort(), [...DREAMER_TOOLS].sort());
100
+ }
101
+ finally {
102
+ session.dispose();
103
+ }
36
104
  });
37
- test("SDK dreamer parse failure keeps existing manifest message without provider error", async () => {
38
- const session = { subscribe(handler) { this.handler = handler; return () => { }; }, handler: (_event) => { }, async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "sorry, I could not do that" }] } }); }, dispose() { } };
39
- await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /did not return a valid JSON manifest/);
105
+ test("playbook describes plain files and the retained frontmatter", () => {
106
+ const playbook = readFileSync(join(process.cwd(), "playbook.md"), "utf8");
107
+ assert.equal(playbook.includes("notes_"), false);
108
+ for (const field of ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count"])
109
+ assert.match(playbook, new RegExp(`^${field}:`, "m"));
110
+ assert.equal(/^scope:/m.test(playbook), false, "scope is derived from the address rather than persisted");
111
+ assert.match(playbook, /Nothing is physically deleted/);
112
+ });
113
+ test("provider errors propagate without parsing a response", async () => {
114
+ const session = {
115
+ subscribe(handler) { this.handler = handler; return () => { }; },
116
+ handler: (_event) => { },
117
+ async prompt(_text) { this.handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } }); },
118
+ dispose() { },
119
+ };
120
+ await assert.rejects(() => runDreamer("playbook", "/tmp/notes", { sessionFactory: async () => session }), /Insufficient Balance/);
40
121
  });
41
122
  test("default dreamer rejects an unresolvable model pattern", async () => {
42
- await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: READ_ONLY_TOOLS }), /definitely-not-a-real-model/);
123
+ await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: DREAMER_TOOLS }), /definitely-not-a-real-model/);
124
+ });
125
+ test("git audit layer commits baseline and dream, stays silent when clean, keeps file content", () => {
126
+ const home = fixture();
127
+ writeFileSync(join(home, "a.md"), "one");
128
+ gitCommit(home, "baseline t");
129
+ gitCommit(home, "dream t"); // clean tree — no empty commit
130
+ const log1 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
131
+ assert.equal(log1, "baseline t");
132
+ writeFileSync(join(home, "a.md"), "two");
133
+ gitCommit(home, "dream t2");
134
+ const log2 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
135
+ assert.equal(log2, "dream t2\nbaseline t");
136
+ assert.equal(readFileSync(join(home, "a.md"), "utf8"), "two"); // notes themselves untouched by the layer
137
+ const before = execFileSync("git", ["show", "HEAD~1:a.md"], { cwd: home, encoding: "utf8" }).trim();
138
+ assert.equal(before, "one"); // rollback information actually recorded
139
+ });
140
+ test("git audit layer never breaks the run when git itself fails", () => {
141
+ gitCommit(join(fixture(), "missing", "home"), "x"); // init on a missing cwd throws inside — swallowed
43
142
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { resetV2WindowId, visibleItem } from "../src/history.js";
3
+ import { contentText, resetV2WindowId, rootWindowId, visibleItem } from "../src/history.js";
4
4
  test("visibleItem reports a plain fitting prefix and names the full length", () => {
5
5
  const item = visibleItem({ windowId: "w", itemId: "i", role: "user", content: "abcdef", createdAt: undefined }, 4);
6
6
  assert.equal(Array.from(item.truncated_content).length, 4);
@@ -19,3 +19,8 @@ test("persisted reset IDs are opaque within the supported protocol version", ()
19
19
  assert.equal(resetV2WindowId({ piContext: "reset-v2", windowId: 123 }), undefined);
20
20
  assert.equal(resetV2WindowId(null), undefined);
21
21
  });
22
+ test("text content projection and root window IDs have stable shared forms", () => {
23
+ const content = [{ type: "text", text: "first" }, { type: "toolCall", name: "ignored" }, { type: "text", text: "second" }];
24
+ assert.equal(contentText(content), "first\nsecond");
25
+ assert.equal(rootWindowId("12345678-abcd"), "pcw:12345678:root");
26
+ });
@@ -5,9 +5,10 @@ import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
6
  import { AgentSession, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
7
7
  import piContext, { historyFromSession, internal } from "../src/index.js";
8
- import { localIso } from "../src/notes.js";
9
- import { physicalPath } from "../src/memory/paths.js";
10
- import { listNotes } from "../src/memory/store.js";
8
+ import { bootBlock } from "../src/prompts.js";
9
+ import { localIso } from "../src/notes/model.js";
10
+ import { physicalPath } from "../src/notes/paths.js";
11
+ import { listNotes } from "../src/notes/store.js";
11
12
  import { middleTruncate, page, TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
12
13
  // Settings fixtures live in temp directories. PI_CODING_AGENT_DIR is redirected for the
13
14
  // whole test process so the extension's SettingsManager.create(ctx.cwd, undefined, ...)
@@ -109,12 +110,41 @@ function noticesOf(ctx) {
109
110
  export async function call(captured, name, params, ctx) {
110
111
  const tool = captured.tools.get(name);
111
112
  assert.ok(tool, `registered ${name}`);
113
+ // Most pre-redesign coverage names session notes by their bare address. Keep these old
114
+ // fixture call sites readable while routing the direct tool invocation through its new
115
+ // address-shaped input; contract-specific tests below pass address themselves.
116
+ const noteCall = name === "notes_write" || name === "notes_edit" || name === "notes_read";
117
+ if (noteCall && "path" in params && !("address" in params)) {
118
+ const { path, scope, ...rest } = params;
119
+ assert.equal(typeof path, "string", "legacy note fixture path is a string");
120
+ const address = scope === "project" ? `@project/${path}` : scope === "global" ? `@global/${path}` : path;
121
+ return tool.execute("call-1", { ...rest, address }, new AbortController().signal, () => { }, ctx);
122
+ }
123
+ if ((name === "notes_list" || name === "notes_search") && params.scope === "global") {
124
+ const { scope: _scope, pattern, ...rest } = params;
125
+ return tool.execute("call-1", { ...rest, pattern: `@global/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
126
+ }
127
+ if ((name === "notes_list" || name === "notes_search") && params.scope === "session") {
128
+ const { scope: _scope, pattern, ...rest } = params;
129
+ return tool.execute("call-1", { ...rest, pattern: typeof pattern === "string" ? pattern : "*.md" }, new AbortController().signal, () => { }, ctx);
130
+ }
112
131
  return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
113
132
  }
114
133
  export function resultJson(result) {
115
134
  const text = result.content[0];
116
135
  assert.ok(text && text.type === "text", "tool result carries text");
117
- return JSON.parse(text.text);
136
+ const value = JSON.parse(text.text);
137
+ const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@global/") ? address.slice("@global/".length) : address;
138
+ const legacyPath = (row) => {
139
+ if (typeof row.address === "string" && row.path === undefined)
140
+ Object.defineProperty(row, "path", { value: suffix(row.address), enumerable: false });
141
+ };
142
+ legacyPath(value);
143
+ if (Array.isArray(value.files))
144
+ for (const file of value.files)
145
+ if (file && typeof file === "object")
146
+ legacyPath(file);
147
+ return value;
118
148
  }
119
149
  /** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
120
150
  export function assertWithinBudget(result, message) {
@@ -247,11 +277,13 @@ test("schemas cover the History/Notes actions plus reset controls", () => {
247
277
  // The write surface requires its body; the edit surface requires its anchors.
248
278
  const writeSchema = captured.tools.get("notes_write")?.parameters;
249
279
  assert.ok(writeSchema?.properties?.content, "notes_write exposes content");
250
- assert.ok(writeSchema?.properties?.path, "notes_write exposes path");
251
- assert.deepEqual([...(writeSchema?.required ?? [])].sort(), ["content", "path"], "notes_write requires path and content");
280
+ assert.ok(writeSchema?.properties?.address, "notes_write exposes address");
281
+ assert.equal(writeSchema?.properties?.scope, undefined, "notes_write has no scope parameter");
282
+ assert.deepEqual([...(writeSchema?.required ?? [])].sort(), ["address", "content"], "notes_write requires address and content");
252
283
  const editSchema = captured.tools.get("notes_edit")?.parameters;
253
284
  assert.ok(editSchema?.properties?.edits, "notes_edit exposes edits");
254
- assert.deepEqual([...(editSchema?.required ?? [])].sort(), ["path"], "notes_edit requires only path; edits are optional for metadata-only updates");
285
+ assert.equal(editSchema?.properties?.scope, undefined, "notes_edit has no scope parameter");
286
+ assert.deepEqual([...(editSchema?.required ?? [])].sort(), ["address"], "notes_edit requires only address; edits are optional for metadata-only updates");
255
287
  // The history ordering switch is documented as newest-first by default.
256
288
  for (const name of ["history_windows", "history_list", "history_search"]) {
257
289
  const schema = captured.tools.get(name)?.parameters;
@@ -270,7 +302,12 @@ test("schemas cover the History/Notes actions plus reset controls", () => {
270
302
  assert.equal(schema?.properties?.limit_chars?.maximum, 50000, `${name} caps limit_chars at 50000`);
271
303
  }
272
304
  const noteReadSchema = captured.tools.get("notes_read")?.parameters;
273
- assert.deepEqual(Object.keys(noteReadSchema?.properties ?? {}).sort(), ["limit_chars", "offset_chars", "path", "scope"], "notes_read exposes exactly the character-window params plus scope");
305
+ assert.deepEqual(Object.keys(noteReadSchema?.properties ?? {}).sort(), ["address", "limit_chars", "offset_chars"], "notes_read exposes exactly address and character-window params");
306
+ for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
307
+ const schema = captured.tools.get(name)?.parameters;
308
+ assert.equal(schema?.properties?.scope, undefined, `${name} has no scope property`);
309
+ assert.equal(schema?.additionalProperties, false, `${name} rejects scope as an additional property`);
310
+ }
274
311
  assert.equal(/start_|stop_line|total_lines/.test(captured.tools.get("notes_read")?.description ?? ""), false, "notes_read prose carries no line surface");
275
312
  });
276
313
  test("notes_list is most-recently-updated first across merged scopes", async () => {
@@ -288,11 +325,11 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
288
325
  put("project", "c.md", base + 5);
289
326
  put("global", "e.md", base + 20);
290
327
  const files = async (params) => resultJson(await call(captured, "notes_list", params, ctx)).files;
291
- assert.deepEqual((await files({})).map((file) => file.path), ["e.md", "a.md", "b.md", "c.md"], "updated_at descending with path ascending as the tiebreak");
328
+ assert.deepEqual((await files({})).map((file) => file.address), ["@global/e.md", "a.md", "b.md", "@project/c.md"], "updated_at descending with address ascending as the tiebreak");
292
329
  // A same-path pair in two scopes keeps both rows; equal timestamps tie-break by scope name.
293
330
  put("global", "a.md", base + 10);
294
- assert.deepEqual((await files({})).filter((file) => file.path === "a.md").map((file) => file.scope), ["global", "session"], "equal timestamps tie-break by scope name");
295
- assert.deepEqual((await files({ scope: "session" })).map((file) => file.path), ["a.md", "b.md"], "a scope filter narrows the set");
331
+ assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.scope), ["global", "session"], "equal timestamps tie-break by full address");
332
+ assert.deepEqual((await files({ pattern: "*.md" })).map((file) => file.address), ["a.md", "b.md"], "a bare pattern narrows to the session home");
296
333
  });
297
334
  test("notes are real files that persist across sessions and round-trip Unicode", async () => {
298
335
  const original = manager();
@@ -306,7 +343,7 @@ test("notes are real files that persist across sessions and round-trip Unicode",
306
343
  const restoredCtx = context(restored);
307
344
  const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "global", offset_chars: -4 }, restoredCtx);
308
345
  const read = resultRead(rawRead);
309
- assert.equal(read.details.path, "checkpoint/进度.md");
346
+ assert.equal(read.details.address, "@global/checkpoint/进度.md");
310
347
  assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
311
348
  assert.equal(read.details.scope, "global");
312
349
  const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "global" }, restoredCtx));
@@ -354,7 +391,7 @@ test("the boot notes index excludes stale notes while list, read, and search sti
354
391
  const text = typeof boot?.message.content === "string" ? boot.message.content : "";
355
392
  assert.ok(text.includes("fresh.md"), "the fresh note is indexed");
356
393
  assert.equal(text.includes("old.md"), false, "the stale note leaves the boot index");
357
- assert.equal(text.includes("stale content"), false, "the stale preview is not rendered");
394
+ assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
358
395
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
359
396
  assert.equal(listed.files.find((file) => file.path === "old.md")?.stale, true, "list carries the stale flag");
360
397
  assert.equal(listed.files.find((file) => file.path === "fresh.md")?.stale, false);
@@ -372,9 +409,30 @@ test("the boot notes index omits itself when every note is stale", async () => {
372
409
  runHandlers(captured, "session_start", {}, ctx);
373
410
  const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
374
411
  assert.equal(text.includes("done.md"), false, "no stale note is indexed");
375
- assert.equal(text.includes("finished"), false, "no stale preview is rendered");
412
+ assert.equal(text.includes("finished"), false, "the stale note's body is absent from boot");
376
413
  assert.ok(text.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG), "the rest of the boot block still renders");
377
414
  });
415
+ test("the boot block gives awake agents the notes-home file layout", () => {
416
+ const session = manager();
417
+ const rendered = bootBlock(context(session), "pcw:test:root", undefined, false);
418
+ assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
419
+ assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@global\/<vpath>/);
420
+ assert.match(rendered, /there is no cross-home fallback/);
421
+ assert.match(rendered, /Any other note is a plain file — use the file tools/);
422
+ });
423
+ test("the boot block keeps fresh global and project maps resident, never a session map", async () => {
424
+ const session = manager();
425
+ const captured = makeExtension(session);
426
+ const ctx = context(session);
427
+ await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
428
+ await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
429
+ await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global" }, ctx);
430
+ const rendered = bootBlock(ctx, "pcw:test:root", undefined, false);
431
+ assert.ok(rendered.includes("MAP: global"));
432
+ assert.ok(rendered.includes("MAP: project"));
433
+ assert.equal(rendered.includes("MAP: session"), false);
434
+ assert.ok(rendered.indexOf("MAP: global") < rendered.indexOf("MAP: project"), "global map precedes project map");
435
+ });
378
436
  test("paged tool outputs stay bounded and cursors reconstruct history and notes", async () => {
379
437
  const session = manager();
380
438
  const captured = makeExtension(session);
@@ -636,8 +694,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
636
694
  assert.ok(first.content.length > 0, "the page is not empty");
637
695
  assert.equal(first.content.includes("…"), false, "the payload is a plain prefix with no marker");
638
696
  assert.ok(first.content.startsWith("---\n"), "the frontmatter is delivered first");
639
- assert.equal(first.header, `[huge.md · chars 0-${first.next_offset_chars} of ${first.total_chars} · continue at offset_chars=${first.next_offset_chars} · session · created ${String(first.details.created_at)} · updated ${String(first.details.updated_at)}]`, "the raw header names the file, the delivered range, the resume cursor, the scope and the timestamps");
640
- assert.deepEqual(Object.keys(first.details).sort(), ["created_at", "limit_chars", "next_offset_chars", "offset_chars", "path", "scope", "total_chars", "updated_at"], "notes_read details carries exactly the slim window metadata plus scope");
697
+ assert.equal(first.header, `[huge.md · chars 0-${first.next_offset_chars} of ${first.total_chars} · continue at offset_chars=${first.next_offset_chars} · session · created ${String(first.details.created_at)} · updated ${String(first.details.updated_at)}]`, "the raw header names the address, delivered range, resume cursor, scope and timestamps");
698
+ assert.deepEqual(Object.keys(first.details).sort(), ["address", "created_at", "limit_chars", "next_offset_chars", "offset_chars", "scope", "total_chars", "updated_at"], "notes_read details carries exactly the slim window metadata plus scope");
641
699
  assert.equal("content" in first.details, false, "details never duplicates the payload");
642
700
  assert.equal(first.offset_chars, 0, "the default window starts at the resolved offset 0");
643
701
  // Following the cursor reconstructs frontmatter + body by plain concatenation.
@@ -655,7 +713,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
655
713
  // A success carries structured details; an error stays a JSON envelope with no details.
656
714
  const missingResult = await call(captured, "notes_read", { path: "no-such.md" }, ctx);
657
715
  const missing = resultJson(missingResult);
658
- assert.deepEqual(Object.keys(missing).sort(), ["error", "path"], "the read error carries exactly error and path");
716
+ assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
659
717
  assert.equal(missing.error, "note not found");
660
718
  assert.equal(missingResult.details, undefined, "a JSON error carries no details metadata");
661
719
  });
@@ -937,11 +995,10 @@ test("custom reset boundary removes old provider context but history remains sea
937
995
  assert.equal(found.items[0]?.item_id, oldUserId);
938
996
  assert.ok(sessionManager.getEntry(compactionId));
939
997
  });
940
- test("the boot notes preview keeps short notes whole and long notes head-to-tail", async () => {
998
+ test("the boot notes index shows one metadata line per note and never a body", async () => {
941
999
  const sessionManager = manager();
942
1000
  const captured = makeExtension(sessionManager);
943
1001
  const ctx = context(sessionManager);
944
- // Unique Unicode code points so an overlap introduced by a naive head+tail concat is detectable.
945
1002
  const longText = Array.from({ length: 400 }, (_, index) => String.fromCharCode(0x4e00 + index)).join("");
946
1003
  const shortText = "short-first\nshort-second";
947
1004
  await call(captured, "notes_write", { path: "long.md", content: longText }, ctx);
@@ -950,19 +1007,11 @@ test("the boot notes preview keeps short notes whole and long notes head-to-tail
950
1007
  const boot = captured.sent[0];
951
1008
  const text = typeof boot?.message.content === "string" ? boot.message.content : "";
952
1009
  assert.ok(text.includes("long.md") && text.includes("short.md"), "both notes are indexed");
953
- // Short note: complete, with its newline preserved and each line indented 2 spaces.
954
- assert.ok(text.includes(" short-first\n short-second"), "short note text is shown whole and indented");
955
- // Long note preview: exactly first 80 + separator + last 240 Unicode characters.
956
- const chars = Array.from(longText);
957
- const head = chars.slice(0, 80).join("");
958
- const tail = chars.slice(chars.length - 240).join("");
959
- const previewLine = text.split("\n").find((line) => line.startsWith(" ") && line.includes("…"));
960
- assert.ok(previewLine, "long note carries an ellipsis preview line");
961
- const preview = Array.from(previewLine.slice(2));
962
- assert.ok(previewLine.includes(head), "long preview keeps the head");
963
- assert.ok(previewLine.includes(tail), "long preview keeps the tail");
964
- assert.equal(preview.length, 321, "head 80 + one separator + tail 240, nothing duplicated");
965
- assert.equal(previewLine.includes(longText), false, "long note is truncated, not shown whole");
1010
+ // Each note is exactly one metadata line: address, line count, byte count, timestamp.
1011
+ assert.match(text, /^- long\.md \(1 lines, \d+ UTF-8 bytes, updated [^)]+\)$/m, "the long note is a single metadata line");
1012
+ assert.match(text, /^- short\.md \(2 lines, \d+ UTF-8 bytes, updated [^)]+\)$/m, "the short note is a single metadata line");
1013
+ assert.equal(text.includes(longText), false, "the long note's body never reaches boot");
1014
+ assert.equal(text.includes(shortText), false, "the short note's body never reaches boot");
966
1015
  });
967
1016
  test("the boot block is persisted at the root and baked into every reset summary", async () => {
968
1017
  const sessionManager = manager();
@@ -1584,7 +1633,8 @@ test("malformed frontmatter timestamps degrade to a finite fallback without pois
1584
1633
  assert.ok(Number.isFinite(rows[0].meta.updated_at), "a malformed timestamp degrades to a finite fallback");
1585
1634
  runHandlers(extension, "session_start", {}, ctx);
1586
1635
  const rendered = JSON.stringify(extension.sent);
1587
- assert.ok(rendered.includes("keep me"), "the valid body still renders");
1636
+ assert.ok(rendered.includes("good.md"), "the valid note still renders as a metadata line");
1637
+ assert.equal(rendered.includes("keep me"), false, "note bodies stay out of the boot block");
1588
1638
  assert.equal(rendered.includes("NaN"), false, "no malformed timestamp leaks into the boot block");
1589
1639
  });
1590
1640
  test("JSONL reload retains once-per-window boot and reminder without runtime memory", () => {
@@ -1663,7 +1713,7 @@ test("argument footguns die loudly and tool-run metadata surfaces (A1/A2/A3/B4/B
1663
1713
  assert.match(notePastEnd.error ?? "", /past the end/, "notes: past-end offset is a named error");
1664
1714
  assert.equal(notePastEnd.offset_chars, noteTotal + 1, "notes: the error echoes the offending offset");
1665
1715
  assert.equal(notePastEnd.total_chars, noteTotal, "notes: the error names the real length");
1666
- assert.equal(notePastEnd.path, "a.md", "notes: the error echoes the path");
1716
+ assert.equal(notePastEnd.address, "a.md", "notes: the error echoes the address");
1667
1717
  const noteEnd = resultRead(await call(captured, "notes_read", { path: "a.md", offset_chars: noteTotal }, ctx));
1668
1718
  assert.equal(noteEnd.content, "", "notes: offset == total is the legal empty end-read");
1669
1719
  assert.equal(noteEnd.next_offset_chars, null, "notes: the end-read terminates");