@astrosheep/pi-context 0.25.2 → 0.26.1
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/README.md +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +635 -381
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +51 -28
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +276 -236
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/text-match.d.ts +5 -0
- package/dist/src/text-match.js +15 -0
- package/dist/src/tool-output.d.ts +97 -0
- package/dist/src/tool-output.js +1 -15
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +387 -20
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +59 -35
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +28 -12
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.js +9 -0
- package/dist/test/helpers/extension.d.ts +104 -0
- package/dist/test/helpers/extension.js +7 -81
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/history.integration.test.js +17 -19
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +128 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +51 -55
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +162 -34
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +52 -27
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +289 -238
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- package/src/protocol.ts +5 -6
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
|
@@ -2,17 +2,14 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
-
import { loadNotesSnapshot
|
|
5
|
+
import { loadNotesSnapshot } from "../src/pi/notes/snapshot.js";
|
|
6
6
|
import { renderBootBlock } from "../src/context/prompts.js";
|
|
7
7
|
import { localIso } from "../src/notes/frontmatter.js";
|
|
8
|
-
import { physicalPath, scopeDir } from "
|
|
9
|
-
import { listNotes } from "../src/notes/store.js";
|
|
8
|
+
import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
|
|
10
9
|
import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
11
|
-
import { assertWithinBudget, call, context, explicitBoot,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
15
|
-
test.after(() => testEnvironment.dispose());
|
|
10
|
+
import { assertWithinBudget, call, context, explicitBoot, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
|
|
11
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
12
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
16
13
|
test("notes_list is most-recently-updated first across merged scopes", async () => {
|
|
17
14
|
const session = manager();
|
|
18
15
|
const captured = makeExtension(session);
|
|
@@ -20,7 +17,7 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
|
|
|
20
17
|
const put = (scope, path, updated) => {
|
|
21
18
|
const file = physicalPath(scope, path, ctx);
|
|
22
19
|
mkdirSync(dirname(file), { recursive: true });
|
|
23
|
-
writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\
|
|
20
|
+
writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreatedAt: ${localIso(updated - 1000)}\nupdatedAt: ${localIso(updated)}\nlastAccessed: ${localIso(updated)}\naccessCount: 0\n---\n\nbody`);
|
|
24
21
|
};
|
|
25
22
|
const base = 1_700_000_000_000;
|
|
26
23
|
put("session", "b.md", base + 10);
|
|
@@ -38,59 +35,58 @@ test("notes are real files that persist across sessions and round-trip Unicode",
|
|
|
38
35
|
const original = manager();
|
|
39
36
|
const captured = makeExtension(original);
|
|
40
37
|
const ctx = context(original);
|
|
41
|
-
await call(captured, "notes_write", {
|
|
38
|
+
await call(captured, "notes_write", { address: "@human/checkpoint/进度.md", content: "第一行\nneedle Café" }, ctx);
|
|
42
39
|
// A brand-new session over the same physical root sees the human note: nothing is replayed
|
|
43
40
|
// from session entries, the file itself is the durable artifact.
|
|
44
41
|
const restored = manager();
|
|
45
42
|
const restoredCaptured = makeExtension(restored);
|
|
46
43
|
const restoredCtx = context(restored);
|
|
47
|
-
const rawRead = await call(restoredCaptured, "notes_read", {
|
|
44
|
+
const rawRead = await call(restoredCaptured, "notes_read", { address: "@human/checkpoint/进度.md", offset_chars: -4 }, restoredCtx);
|
|
48
45
|
const read = resultRead(rawRead);
|
|
49
46
|
assert.equal(read.details.address, "@human/checkpoint/进度.md");
|
|
50
47
|
assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
|
|
51
|
-
const searched = resultJson(await call(restoredCaptured, "notes_search", {
|
|
48
|
+
const searched = resultJson(await call(restoredCaptured, "notes_search", { pattern: "@human/**", query: "Café" }, restoredCtx));
|
|
52
49
|
assert.equal(searched.files[0]?.matches[0]?.line, 2);
|
|
53
|
-
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**"
|
|
50
|
+
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/checkpoint/**" }, restoredCtx));
|
|
54
51
|
assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
|
|
55
|
-
assert.equal(listedFiles.files[0]?.
|
|
52
|
+
assert.equal(listedFiles.files[0]?.address, "@human/checkpoint/进度.md");
|
|
56
53
|
// A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
|
|
57
|
-
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "
|
|
54
|
+
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/*" }, restoredCtx));
|
|
58
55
|
assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
|
|
59
|
-
assert.equal(searched.files[0]?.created_at, listedFiles.files[0]?.created_at, "note tools agree on the timestamp format");
|
|
60
56
|
assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
|
|
61
|
-
await assert.rejects(() => call(captured, "notes_write", {
|
|
57
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "../escape", content: "x" }, ctx), /unsupported component/);
|
|
62
58
|
});
|
|
63
59
|
test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
|
|
64
60
|
const sm = manager();
|
|
65
61
|
const captured = makeExtension(sm);
|
|
66
62
|
const ctx = context(sm);
|
|
67
|
-
await call(captured, "notes_write", {
|
|
63
|
+
await call(captured, "notes_write", { address: "journal.md", content: "log line" }, ctx);
|
|
68
64
|
// metadata-only: content unchanged, flag set, applied 0
|
|
69
|
-
const markOnly = resultJson(await call(captured, "notes_edit", {
|
|
65
|
+
const markOnly = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: true }, ctx));
|
|
70
66
|
assert.equal(markOnly.applied, 0);
|
|
71
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
72
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
67
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
68
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
|
|
73
69
|
// explicit revive
|
|
74
|
-
const revived = resultJson(await call(captured, "notes_edit", {
|
|
75
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "stale:false revives");
|
|
70
|
+
const revived = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: false }, ctx));
|
|
71
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
|
|
76
72
|
// write+stale closure then plain write revival
|
|
77
|
-
await call(captured, "notes_write", {
|
|
78
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
79
|
-
await call(captured, "notes_write", {
|
|
80
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "writing without stale revives");
|
|
73
|
+
await call(captured, "notes_write", { address: "journal.md", content: "final", stale: true }, ctx);
|
|
74
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
75
|
+
await call(captured, "notes_write", { address: "journal.md", content: "reopened" }, ctx);
|
|
76
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
|
|
81
77
|
// metadata-only on a missing path is the typed not-found arm
|
|
82
|
-
const missing = resultJson(await call(captured, "notes_edit", {
|
|
78
|
+
const missing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
|
|
83
79
|
assert.equal(missing.error, "note not found");
|
|
84
80
|
});
|
|
85
|
-
test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", () => {
|
|
81
|
+
test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
|
|
86
82
|
const sm = manager();
|
|
87
83
|
const ctx = context(sm);
|
|
88
|
-
assert.deepEqual(listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
|
|
84
|
+
assert.deepEqual(await listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
|
|
89
85
|
const blockedHome = scopeDir("human", ctx);
|
|
90
86
|
writeFileSync(blockedHome, "not a directory");
|
|
91
|
-
assert.
|
|
87
|
+
await assert.rejects(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
|
|
92
88
|
});
|
|
93
|
-
test("boot note acquisition is one closed snapshot and isolates one or all failed homes", () => {
|
|
89
|
+
test("boot note acquisition is one closed snapshot and isolates one or all failed homes", async () => {
|
|
94
90
|
const sm = manager();
|
|
95
91
|
const ctx = context(sm);
|
|
96
92
|
const updated = Date.now();
|
|
@@ -105,10 +101,10 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
105
101
|
origin: "self",
|
|
106
102
|
status: "active",
|
|
107
103
|
stale: false,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
104
|
+
createdAt: updated,
|
|
105
|
+
updatedAt: updated,
|
|
106
|
+
lastAccessed: updated,
|
|
107
|
+
accessCount: 0,
|
|
112
108
|
},
|
|
113
109
|
});
|
|
114
110
|
const rows = new Map([
|
|
@@ -119,7 +115,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
119
115
|
["model", [note("model", "model.md", "@models/default/model.md", "MODEL_POCKET_BODY")]],
|
|
120
116
|
]);
|
|
121
117
|
const calls = new Map();
|
|
122
|
-
const snapshot = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
118
|
+
const snapshot = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
123
119
|
calls.set(scope, (calls.get(scope) ?? 0) + 1);
|
|
124
120
|
return rows.get(scope) ?? [];
|
|
125
121
|
});
|
|
@@ -138,7 +134,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
138
134
|
assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
|
|
139
135
|
assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
|
|
140
136
|
const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
|
|
141
|
-
const oneFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
137
|
+
const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
142
138
|
if (scope === "human")
|
|
143
139
|
throw readFailure("EIO");
|
|
144
140
|
return rows.get(scope) ?? [];
|
|
@@ -153,7 +149,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
153
149
|
});
|
|
154
150
|
assert.ok(oneFailedText.includes("PROJECT_MAP_BODY") && oneFailedText.includes("notes_list can retry after recovery"), "healthy homes and the recovery notice survive one failure");
|
|
155
151
|
assert.equal(oneFailedText.includes("HUMAN_POCKET_BODY"), false, "the failed home's index is omitted");
|
|
156
|
-
const allFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
152
|
+
const allFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
157
153
|
throw readFailure(scope === "session" ? "EACCES" : "EIO");
|
|
158
154
|
});
|
|
159
155
|
assert.equal(allFailed.unavailable.length, 5);
|
|
@@ -168,12 +164,12 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
168
164
|
assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
|
|
169
165
|
assert.ok(allFailedText.includes("Your memory resets whenever the context window fills"), "protocol survives an all-home failure");
|
|
170
166
|
assert.equal(allFailedText.includes("scripted read failure"), false, "the model-facing notice does not expose OS/error details");
|
|
171
|
-
assert.
|
|
172
|
-
assert.
|
|
167
|
+
await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
|
|
168
|
+
await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw Object.assign(new Error("invalid argument"), { code: "ERR_INVALID_ARG_TYPE" }); }), (error) => error.code === "ERR_INVALID_ARG_TYPE", "Node ERR_* failures are not treated as filesystem errno failures");
|
|
173
169
|
});
|
|
174
|
-
test("the boot block gives awake agents the notes-home file layout", () => {
|
|
170
|
+
test("the boot block gives awake agents the notes-home file layout", async () => {
|
|
175
171
|
const session = manager();
|
|
176
|
-
const rendered = explicitBoot(context(session), "pcw:test:root", undefined);
|
|
172
|
+
const rendered = await explicitBoot(context(session), "pcw:test:root", undefined);
|
|
177
173
|
assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
|
|
178
174
|
assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
|
|
179
175
|
});
|
|
@@ -183,8 +179,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
183
179
|
const ctx = context(session);
|
|
184
180
|
const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
185
181
|
const text = `${huge}\ntail line`;
|
|
186
|
-
await call(captured, "notes_write", {
|
|
187
|
-
const rawFirst = await call(captured, "notes_read", {
|
|
182
|
+
await call(captured, "notes_write", { address: "huge.md", content: text }, ctx);
|
|
183
|
+
const rawFirst = await call(captured, "notes_read", { address: "huge.md" }, ctx);
|
|
188
184
|
assertWithinBudget(rawFirst, "single oversized note");
|
|
189
185
|
const first = resultRead(rawFirst);
|
|
190
186
|
assert.ok(first.content.length > 0, "the page is not empty");
|
|
@@ -198,7 +194,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
198
194
|
const parts = [first.content];
|
|
199
195
|
let offset = first.next_offset_chars;
|
|
200
196
|
while (offset !== null) {
|
|
201
|
-
const rawChunk = await call(captured, "notes_read", {
|
|
197
|
+
const rawChunk = await call(captured, "notes_read", { address: "huge.md", offset_chars: offset }, ctx);
|
|
202
198
|
assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
|
|
203
199
|
const chunk = resultRead(rawChunk);
|
|
204
200
|
assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
|
|
@@ -207,7 +203,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
207
203
|
}
|
|
208
204
|
assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
|
|
209
205
|
// A success carries structured details; an error stays a JSON envelope with no details.
|
|
210
|
-
const missingResult = await call(captured, "notes_read", {
|
|
206
|
+
const missingResult = await call(captured, "notes_read", { address: "no-such.md" }, ctx);
|
|
211
207
|
const missing = resultJson(missingResult);
|
|
212
208
|
assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
|
|
213
209
|
assert.equal(missing.error, "note not found");
|
|
@@ -219,8 +215,8 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
219
215
|
const ctx = context(session);
|
|
220
216
|
// The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
|
|
221
217
|
const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
222
|
-
await call(captured, "notes_write", {
|
|
223
|
-
await call(captured, "notes_write", {
|
|
218
|
+
await call(captured, "notes_write", { address: "a.md", content: "needle small" }, ctx);
|
|
219
|
+
await call(captured, "notes_write", { address: "search.md", content: hugeLine }, ctx);
|
|
224
220
|
const pages = [];
|
|
225
221
|
let cursor = 0;
|
|
226
222
|
let next = 0;
|
|
@@ -232,7 +228,7 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
232
228
|
if (next !== null)
|
|
233
229
|
cursor = next;
|
|
234
230
|
}
|
|
235
|
-
assert.deepEqual(pages.map((file) => file.
|
|
231
|
+
assert.deepEqual(pages.map((file) => file.address), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
|
|
236
232
|
const oversized = pages[1];
|
|
237
233
|
assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
|
|
238
234
|
assert.equal(oversized.matches.length, 1);
|
|
@@ -241,13 +237,13 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
241
237
|
assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
|
|
242
238
|
assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
|
|
243
239
|
assert.equal(match.line, 1, "the informational line number survives");
|
|
244
|
-
const atMatch = resultRead(await call(captured, "notes_read", {
|
|
240
|
+
const atMatch = resultRead(await call(captured, "notes_read", { address: "search.md", offset_chars: match.offset_chars }, ctx));
|
|
245
241
|
assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
|
|
246
242
|
// The body is reconstructible by following notes_read's cursor from the start of the file.
|
|
247
243
|
const parts = [];
|
|
248
244
|
let offset = 0;
|
|
249
245
|
while (offset !== null) {
|
|
250
|
-
const rawChunk = await call(captured, "notes_read", {
|
|
246
|
+
const rawChunk = await call(captured, "notes_read", { address: "search.md", offset_chars: offset }, ctx);
|
|
251
247
|
assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
|
|
252
248
|
const chunk = resultRead(rawChunk);
|
|
253
249
|
assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
|
|
@@ -260,10 +256,10 @@ test("notes_search scopes by glob pattern; a non-matching pattern is an empty pa
|
|
|
260
256
|
const session = manager();
|
|
261
257
|
const captured = makeExtension(session);
|
|
262
258
|
const ctx = context(session);
|
|
263
|
-
await call(captured, "notes_write", {
|
|
264
|
-
await call(captured, "notes_write", {
|
|
259
|
+
await call(captured, "notes_write", { address: "deep/nested/a.md", content: "needle here" }, ctx);
|
|
260
|
+
await call(captured, "notes_write", { address: "top.md", content: "needle there" }, ctx);
|
|
265
261
|
const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
|
|
266
|
-
assert.deepEqual(scoped.files.map((file) => file.
|
|
262
|
+
assert.deepEqual(scoped.files.map((file) => file.address), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
|
|
267
263
|
const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
|
|
268
264
|
assert.equal(none.error, undefined, "a non-matching pattern is not an error");
|
|
269
265
|
assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test/notes.test.js
CHANGED
|
@@ -7,22 +7,23 @@
|
|
|
7
7
|
* HERMETIC: every test points PI_NOTES_HOME at its own temp root; no real ~/.agents is touched.
|
|
8
8
|
*/
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import test from "node:test";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
import { parseNote } from "../src/notes/frontmatter.js";
|
|
15
|
+
import { projectKey } from "../src/notes/paths.js";
|
|
16
|
+
import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
|
|
17
|
+
import { call, context, explicitBoot, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
|
|
18
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
19
|
+
const testEnvironment = installExtensionTestHooks("pi-context-notes");
|
|
19
20
|
function freshRoot() {
|
|
20
21
|
return testEnvironment.newNotesRoot();
|
|
21
22
|
}
|
|
22
23
|
function setUpdatedAt(scope, path, ctx, timestamp) {
|
|
23
24
|
const file = physicalPath(scope, path, ctx);
|
|
24
25
|
const raw = readFileSync(file, "utf8");
|
|
25
|
-
writeFileSync(file, raw.replace(/^
|
|
26
|
+
writeFileSync(file, raw.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date(timestamp).toISOString()}`));
|
|
26
27
|
}
|
|
27
28
|
function assertNoPublicScope(value, label) {
|
|
28
29
|
if (Array.isArray(value)) {
|
|
@@ -56,7 +57,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
56
57
|
const captured = makeExtension(session);
|
|
57
58
|
const ctx = context(session);
|
|
58
59
|
const sessionId = session.getSessionId();
|
|
59
|
-
const result = resultJson(await call(captured, "notes_write", {
|
|
60
|
+
const result = resultJson(await call(captured, "notes_write", { address: "a/b.md", content: "hello" }, ctx));
|
|
60
61
|
assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
|
|
61
62
|
const file = physicalPath("session", "a/b.md", ctx);
|
|
62
63
|
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
@@ -64,39 +65,144 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
64
65
|
const raw = readFileSync(file, "utf8");
|
|
65
66
|
assert.match(raw, /^---\n/, "the file opens with frontmatter");
|
|
66
67
|
assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
|
|
67
|
-
for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["
|
|
68
|
+
for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["accessCount", "0"]]) {
|
|
68
69
|
assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
|
|
69
70
|
}
|
|
70
71
|
assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
|
|
71
|
-
for (const key of ["
|
|
72
|
+
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
72
73
|
assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
|
|
73
74
|
}
|
|
75
|
+
assert.equal(parseNote(raw).meta.project, projectKey(ctx.cwd), "a newly-created session note records its existing project key");
|
|
74
76
|
assert.equal(result.address, "a/b.md");
|
|
75
77
|
assert.equal(result.written, true);
|
|
78
|
+
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
|
|
76
79
|
// A leading YAML block in user content is stripped from the body.
|
|
77
|
-
await call(captured, "notes_write", {
|
|
80
|
+
await call(captured, "notes_write", { address: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
|
|
78
81
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
79
82
|
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
80
83
|
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
81
84
|
});
|
|
85
|
+
test("session-note project ownership is per note, persistent across sessions, and not reassigned by cwd", async () => {
|
|
86
|
+
freshRoot();
|
|
87
|
+
const cwdA = join(testEnvironment.cwd, "project-a");
|
|
88
|
+
const cwdB = join(testEnvironment.cwd, "project-b");
|
|
89
|
+
mkdirSync(cwdA, { recursive: true });
|
|
90
|
+
mkdirSync(cwdB, { recursive: true });
|
|
91
|
+
const projectA = projectKey(cwdA);
|
|
92
|
+
const projectB = projectKey(cwdB);
|
|
93
|
+
assert.notEqual(projectA, projectB);
|
|
94
|
+
const firstSession = manager();
|
|
95
|
+
const firstCaptured = makeExtension(firstSession);
|
|
96
|
+
const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
|
|
97
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "first project session" }, firstCtx);
|
|
98
|
+
const firstFile = physicalPath("session", "first.md", firstCtx);
|
|
99
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
|
|
100
|
+
const secondSession = manager();
|
|
101
|
+
const secondCaptured = makeExtension(secondSession);
|
|
102
|
+
const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
|
|
103
|
+
await call(secondCaptured, "notes_write", { address: "second.md", content: "same project, another session" }, secondCtx);
|
|
104
|
+
const secondFile = physicalPath("session", "second.md", secondCtx);
|
|
105
|
+
assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
|
|
106
|
+
const thirdSession = manager();
|
|
107
|
+
const thirdCaptured = makeExtension(thirdSession);
|
|
108
|
+
const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
|
|
109
|
+
await call(thirdCaptured, "notes_write", { address: "third.md", content: "different project" }, thirdCtx);
|
|
110
|
+
const thirdFile = physicalPath("session", "third.md", thirdCtx);
|
|
111
|
+
assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
|
|
112
|
+
const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
|
|
113
|
+
assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
|
|
114
|
+
const movedContext = context(firstSession, undefined, undefined, true, cwdB);
|
|
115
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "overwritten from another cwd" }, movedContext);
|
|
116
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
|
|
117
|
+
await call(firstCaptured, "notes_edit", { address: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
|
|
118
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
|
|
119
|
+
await call(firstCaptured, "notes_read", { address: "first.md" }, movedContext);
|
|
120
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
|
|
121
|
+
await call(firstCaptured, "notes_write", { address: "new-from-project-b.md", content: "new note" }, movedContext);
|
|
122
|
+
assert.equal(parseNote(readFileSync(physicalPath("session", "new-from-project-b.md", movedContext), "utf8")).meta.project, projectB, "only a newly-created session note uses the current project key");
|
|
123
|
+
await call(firstCaptured, "notes_write", { address: "@project/project-note.md", content: "project home note" }, movedContext);
|
|
124
|
+
assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
|
|
125
|
+
assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
|
|
126
|
+
});
|
|
127
|
+
test("linked git worktrees share the main checkout's project key", () => {
|
|
128
|
+
freshRoot();
|
|
129
|
+
// realpath keeps the fixture's gitdir pointer and the asserted paths on one spelling
|
|
130
|
+
// (macOS temp roots live under the /var -> /private/var symlink).
|
|
131
|
+
const root = realpathSync(testEnvironment.cwd);
|
|
132
|
+
const main = join(root, "repo");
|
|
133
|
+
mkdirSync(main, { recursive: true });
|
|
134
|
+
const git = (args) => {
|
|
135
|
+
execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", ...args], { cwd: main, stdio: "ignore" });
|
|
136
|
+
};
|
|
137
|
+
git(["init", "-q"]);
|
|
138
|
+
git(["commit", "-q", "--allow-empty", "-m", "init"]);
|
|
139
|
+
const worktree = join(root, "wt");
|
|
140
|
+
git(["worktree", "add", "-q", "--detach", worktree]);
|
|
141
|
+
assert.equal(projectKey(worktree), projectKey(main), "a linked worktree resolves to the main checkout's key");
|
|
142
|
+
assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
|
|
143
|
+
assert.equal(scopeDir("project", context(manager(), undefined, undefined, true, worktree)), scopeDir("project", context(manager(), undefined, undefined, true, main)), "@project uses the same physical home from both checkouts");
|
|
144
|
+
});
|
|
145
|
+
test("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
|
|
146
|
+
freshRoot();
|
|
147
|
+
const session = manager();
|
|
148
|
+
const captured = makeExtension(session);
|
|
149
|
+
const ctx = context(session);
|
|
150
|
+
const legacyFile = physicalPath("session", "legacy.md", ctx);
|
|
151
|
+
mkdirSync(scopeDir("session", ctx), { recursive: true });
|
|
152
|
+
writeFileSync(legacyFile, `---
|
|
153
|
+
origin: self
|
|
154
|
+
status: active
|
|
155
|
+
stale: false
|
|
156
|
+
created_at: 2026-01-01T00:00:00.000+00:00
|
|
157
|
+
updated_at: 2026-01-01T00:00:00.000+00:00
|
|
158
|
+
last_accessed: 2026-01-01T00:00:00.000+00:00
|
|
159
|
+
access_count: 0
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
legacy body`);
|
|
163
|
+
assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
|
|
164
|
+
const legacyBytes = readFileSync(legacyFile, "utf8");
|
|
165
|
+
await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
|
|
166
|
+
await assert.rejects(() => call(captured, "notes_read", { address: "legacy.md" }, ctx), /requires manual migration/);
|
|
167
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
|
|
168
|
+
assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
|
|
169
|
+
const invalidFile = physicalPath("session", "invalid.md", ctx);
|
|
170
|
+
writeFileSync(invalidFile, `---
|
|
171
|
+
origin: self
|
|
172
|
+
status: active
|
|
173
|
+
stale: false
|
|
174
|
+
createdAt: 2026-01-01T00:00:00.000+00:00
|
|
175
|
+
updatedAt: 2026-01-01T00:00:00.000+00:00
|
|
176
|
+
lastAccessed: 2026-01-01T00:00:00.000+00:00
|
|
177
|
+
accessCount: 0
|
|
178
|
+
project: 17
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
invalid owner`);
|
|
182
|
+
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
|
|
183
|
+
assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
|
|
184
|
+
await call(captured, "notes_write", { address: "invalid.md", content: "still invalid" }, ctx);
|
|
185
|
+
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
|
|
186
|
+
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
|
|
187
|
+
});
|
|
82
188
|
test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
|
|
83
189
|
freshRoot();
|
|
84
190
|
const session = manager();
|
|
85
191
|
const captured = makeExtension(session);
|
|
86
192
|
const ctx = context(session);
|
|
87
|
-
await call(captured, "notes_write", {
|
|
88
|
-
const ambiguous = resultJson(await call(captured, "notes_edit", {
|
|
193
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
|
|
194
|
+
const ambiguous = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
89
195
|
assert.match(ambiguous.error, /occurs 2 times/);
|
|
90
196
|
assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
|
|
91
|
-
const missing = resultJson(await call(captured, "notes_edit", {
|
|
197
|
+
const missing = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
|
|
92
198
|
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
93
|
-
const all = resultJson(await call(captured, "notes_edit", {
|
|
199
|
+
const all = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
94
200
|
assert.equal(all.applied, 1);
|
|
95
201
|
assert.equal(all.address, "edit.md");
|
|
96
202
|
assertNoPublicScope(all, "notes_edit");
|
|
97
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
203
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
98
204
|
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
99
|
-
const frontmatterOnly = resultJson(await call(captured, "notes_edit", {
|
|
205
|
+
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
100
206
|
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
101
207
|
});
|
|
102
208
|
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
@@ -104,25 +210,25 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
|
|
|
104
210
|
const session = manager();
|
|
105
211
|
const captured = makeExtension(session);
|
|
106
212
|
const ctx = context(session);
|
|
107
|
-
const nameOnly = resultJson(await call(captured, "notes_edit", {
|
|
213
|
+
const nameOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md" }, ctx));
|
|
108
214
|
assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
|
|
109
|
-
await call(captured, "notes_write", {
|
|
110
|
-
const empty = resultJson(await call(captured, "notes_edit", {
|
|
215
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta" }, ctx);
|
|
216
|
+
const empty = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [] }, ctx));
|
|
111
217
|
assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
|
|
112
|
-
const editMissing = resultJson(await call(captured, "notes_edit", {
|
|
218
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
|
|
113
219
|
assert.equal(editMissing.error, "note not found");
|
|
114
|
-
const readMissing = resultJson(await call(captured, "notes_read", {
|
|
220
|
+
const readMissing = resultJson(await call(captured, "notes_read", { address: "missing.md" }, ctx));
|
|
115
221
|
assert.equal(readMissing.error, "note not found");
|
|
116
|
-
assert.equal(readMissing.
|
|
222
|
+
assert.equal(readMissing.address, "missing.md");
|
|
117
223
|
const file = physicalPath("session", "edit.md", ctx);
|
|
118
224
|
const before = readFileSync(file, "utf8");
|
|
119
|
-
const failed = resultJson(await call(captured, "notes_edit", {
|
|
225
|
+
const failed = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
|
|
120
226
|
assert.equal(failed.edit_index, 1, "the failing edit is named");
|
|
121
227
|
assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
|
|
122
|
-
const applied = resultJson(await call(captured, "notes_edit", {
|
|
228
|
+
const applied = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
|
|
123
229
|
assert.equal(applied.applied, 2);
|
|
124
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
125
|
-
const zero = resultJson(await call(captured, "notes_edit", {
|
|
230
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
|
|
231
|
+
const zero = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
126
232
|
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
127
233
|
});
|
|
128
234
|
test("all notes tool results use address as the only home identity", async () => {
|
|
@@ -162,16 +268,16 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
162
268
|
const session = manager();
|
|
163
269
|
const captured = makeExtension(session);
|
|
164
270
|
const ctx = context(session);
|
|
165
|
-
await call(captured, "notes_write", {
|
|
166
|
-
await call(captured, "notes_write", {
|
|
167
|
-
await call(captured, "notes_write", {
|
|
271
|
+
await call(captured, "notes_write", { address: "one.md", content: "needle one" }, ctx);
|
|
272
|
+
await call(captured, "notes_write", { address: "@project/two.md", content: "needle two" }, ctx);
|
|
273
|
+
await call(captured, "notes_write", { address: "@human/three.md", content: "needle three" }, ctx);
|
|
168
274
|
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
169
275
|
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
|
|
170
276
|
for (const row of listed.files) {
|
|
171
277
|
assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
|
|
172
278
|
assert.equal(row.stale, false);
|
|
173
279
|
}
|
|
174
|
-
const scoped = resultJson(await call(captured, "notes_list", {
|
|
280
|
+
const scoped = resultJson(await call(captured, "notes_list", { pattern: "@human/**" }, ctx));
|
|
175
281
|
assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
|
|
176
282
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
177
283
|
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
@@ -187,7 +293,7 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
187
293
|
const escaped = ["../evil", "/abs", "a\\b"];
|
|
188
294
|
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
189
295
|
for (const path of escaped) {
|
|
190
|
-
await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
296
|
+
await assert.rejects(() => call(captured, tool, { address: path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
191
297
|
}
|
|
192
298
|
}
|
|
193
299
|
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
|
@@ -209,7 +315,29 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
|
|
|
209
315
|
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are this session/);
|
|
210
316
|
assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
|
|
211
317
|
});
|
|
212
|
-
|
|
318
|
+
test("Pi adapter resolves agent and switched model identity on each notes call and boot", async () => {
|
|
319
|
+
const root = freshRoot();
|
|
320
|
+
const session = manager();
|
|
321
|
+
const captured = makeExtension(session);
|
|
322
|
+
const ctx = context(session, undefined, undefined, true, testEnvironment.cwd, true, "provider/First.Model");
|
|
323
|
+
await withAgent("Test Agent", async () => {
|
|
324
|
+
await call(captured, "notes_write", { address: "@self/private.md", content: "agent note" }, ctx);
|
|
325
|
+
await call(captured, "notes_write", { address: "@model/private.md", content: "first model note" }, ctx);
|
|
326
|
+
ctx.model = context(session, undefined, undefined, true, testEnvironment.cwd, true, "other/Second.Model").model;
|
|
327
|
+
assert.equal(resultJson(await call(captured, "notes_read", { address: "@model/private.md" }, ctx)).error, "note not found", "switched model does not fall back to previous home");
|
|
328
|
+
await call(captured, "notes_write", { address: "@model/private.md", content: "second model note" }, ctx);
|
|
329
|
+
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
330
|
+
assert.deepEqual(listed.files.map((row) => row.address).sort(), ["@agents/test-agent/private.md", "@models/second-model/private.md"]);
|
|
331
|
+
const boot = await explicitBoot(ctx, "test-window", undefined);
|
|
332
|
+
assert.ok(boot.includes("@models/second-model/private.md"));
|
|
333
|
+
assert.equal(boot.includes("@models/first-model/private.md"), false);
|
|
334
|
+
assert.match(resultRead(await call(captured, "notes_read", { address: "@models/first-model/private.md" }, ctx)).content, /first model note$/);
|
|
335
|
+
const refused = resultJson(await call(captured, "notes_edit", { address: "@models/first-model/private.md", stale: true }, ctx));
|
|
336
|
+
assert.match(refused.error, /not your home/);
|
|
337
|
+
assert.match(readFileSync(join(root, "models/first-model/private.md"), "utf8"), /first model note$/);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00.000+00:00\nupdatedAt: 2026-01-01T00:00:00.000+00:00\nlastAccessed: 2026-01-01T00:00:00.000+00:00\naccessCount: 0\n---\n\n${body}`;
|
|
213
341
|
async function withAgent(name, run) {
|
|
214
342
|
const previous = process.env.PI_NOTES_AGENT;
|
|
215
343
|
if (name === undefined)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|