@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.
- package/dist/src/budget.js +10 -8
- package/dist/src/dream/cli.js +9 -8
- package/dist/src/dream/gates.js +2 -1
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/runner.js +84 -25
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +5 -3
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +5 -1
- package/dist/src/{memory → notes}/store.js +45 -72
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +31 -29
- package/dist/src/protocol.js +8 -4
- package/dist/src/thresholds.js +4 -1
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +133 -34
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +84 -34
- package/dist/test/{memory.test.js → notes.test.js} +138 -34
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +8 -8
- package/src/dream/gates.ts +2 -1
- package/src/dream/git.ts +27 -0
- package/src/dream/runner.ts +81 -23
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +47 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +8 -4
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- package/src/memory/tools.ts +0 -175
package/dist/test/dream.test.js
CHANGED
|
@@ -1,43 +1,142 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function
|
|
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("
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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("
|
|
38
|
-
const
|
|
39
|
-
|
|
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:
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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?.
|
|
251
|
-
assert.
|
|
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.
|
|
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(), ["
|
|
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.
|
|
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.
|
|
295
|
-
assert.deepEqual((await files({
|
|
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.
|
|
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
|
|
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, "
|
|
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
|
|
640
|
-
assert.deepEqual(Object.keys(first.details).sort(), ["created_at", "limit_chars", "next_offset_chars", "offset_chars", "
|
|
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(), ["
|
|
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
|
|
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
|
-
//
|
|
954
|
-
assert.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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("
|
|
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.
|
|
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");
|