@astrosheep/pi-context 0.23.1 → 0.25.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/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1861 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +97 -0
- package/dist/src/context/prompts.js +94 -0
- package/dist/src/context/reset-lifecycle.js +134 -0
- package/dist/src/context/runtime.js +236 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +11 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +813 -213
- package/dist/test/boot.integration.test.js +167 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +393 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +273 -0
- package/dist/test/notes.test.js +40 -370
- package/dist/test/reset-lifecycle.test.js +248 -180
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +16 -14
- package/package.json +11 -10
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +103 -0
- package/src/context/prompts.ts +111 -0
- package/src/context/reset-lifecycle.ts +145 -0
- package/src/context/runtime.ts +246 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +11 -9
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1775
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/dist/test/notes.test.js
CHANGED
|
@@ -7,19 +7,17 @@
|
|
|
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 { existsSync,
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
11
|
import { join } from "node:path";
|
|
13
12
|
import test from "node:test";
|
|
14
|
-
import { physicalPath
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
import { physicalPath } from "../src/notes/paths.js";
|
|
14
|
+
import { call, context, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
|
|
15
|
+
const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
|
|
16
|
+
test.beforeEach(() => testEnvironment.beforeEach());
|
|
17
|
+
test.afterEach(() => testEnvironment.afterEach());
|
|
18
|
+
test.after(() => testEnvironment.dispose());
|
|
19
19
|
function freshRoot() {
|
|
20
|
-
|
|
21
|
-
process.env.PI_NOTES_HOME = root;
|
|
22
|
-
return root;
|
|
20
|
+
return testEnvironment.newNotesRoot();
|
|
23
21
|
}
|
|
24
22
|
function setUpdatedAt(scope, path, ctx, timestamp) {
|
|
25
23
|
const file = physicalPath(scope, path, ctx);
|
|
@@ -48,10 +46,6 @@ test("exactly the five notes tools are registered; the legacy five are gone", ()
|
|
|
48
46
|
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
49
47
|
assert.equal(captured.tools.get(legacy), undefined, `${legacy} is unregistered`);
|
|
50
48
|
}
|
|
51
|
-
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
|
|
52
|
-
const description = captured.tools.get(name)?.description ?? "";
|
|
53
|
-
assert.equal(/resolved_scope|\bscope\b/.test(description), false, `${name} describes home identity through address only`);
|
|
54
|
-
}
|
|
55
49
|
assert.equal(captured.tools.get("notes_write")?.executionMode, "sequential");
|
|
56
50
|
assert.equal(captured.tools.get("notes_edit")?.executionMode, "sequential");
|
|
57
51
|
assert.equal(captured.tools.get("notes_read")?.executionMode, undefined);
|
|
@@ -80,42 +74,11 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
80
74
|
assert.equal(result.address, "a/b.md");
|
|
81
75
|
assert.equal(result.written, true);
|
|
82
76
|
// A leading YAML block in user content is stripped from the body.
|
|
83
|
-
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope:
|
|
77
|
+
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
|
|
84
78
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
85
79
|
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
86
80
|
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
87
81
|
});
|
|
88
|
-
test("overwrite preserves created_at and unknown keys, bumps updated_at, and clears stale", async () => {
|
|
89
|
-
freshRoot();
|
|
90
|
-
const session = manager();
|
|
91
|
-
const captured = makeExtension(session);
|
|
92
|
-
const ctx = context(session);
|
|
93
|
-
const file = physicalPath("session", "keep.md", ctx);
|
|
94
|
-
await call(captured, "notes_write", { path: "keep.md", content: "first", stale: true }, ctx);
|
|
95
|
-
const created = listNotes(ctx, { scope: "session" })[0].meta.created_at;
|
|
96
|
-
// Inject an unknown frontmatter key the way the sleep-shift layer will.
|
|
97
|
-
const raw = readFileSync(file, "utf8");
|
|
98
|
-
writeFileSync(file, raw.replace(/\n---\n\n/, "\nsleep_shift_key: \"keep-me\"\nrecurrence_count: 4\n---\n\n"));
|
|
99
|
-
const rewrite = resultJson(await call(captured, "notes_write", { path: "keep.md", content: "second" }, ctx));
|
|
100
|
-
const after = readFileSync(file, "utf8");
|
|
101
|
-
assert.equal(after.includes("sleep_shift_key: keep-me"), true, "an unknown key survives a rewrite");
|
|
102
|
-
assert.equal(after.includes("recurrence_count: 4"), true, "a known sleep-shift key survives a rewrite");
|
|
103
|
-
assert.match(after, /\n---\n\nsecond$/, "the body is replaced");
|
|
104
|
-
assert.deepEqual(Object.keys(rewrite).sort(), ["address", "written"]);
|
|
105
|
-
const listed = listNotes(ctx, { scope: "session" })[0];
|
|
106
|
-
assert.equal(listed.meta.created_at, created, "created_at is preserved across an overwrite");
|
|
107
|
-
assert.ok(listed.meta.updated_at >= created, "updated_at is bumped");
|
|
108
|
-
assert.equal(listed.meta.stale, false, "a plain rewrite clears stale");
|
|
109
|
-
assert.equal(listed.meta.status, "active");
|
|
110
|
-
});
|
|
111
|
-
test("listNotes retains each parsed body for MAP injection", async () => {
|
|
112
|
-
freshRoot();
|
|
113
|
-
const session = manager();
|
|
114
|
-
const captured = makeExtension(session);
|
|
115
|
-
const ctx = context(session);
|
|
116
|
-
await call(captured, "notes_write", { path: "retained.md", content: "parsed once" }, ctx);
|
|
117
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.body, "parsed once");
|
|
118
|
-
});
|
|
119
82
|
test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
|
|
120
83
|
freshRoot();
|
|
121
84
|
const session = manager();
|
|
@@ -136,70 +99,6 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
|
|
|
136
99
|
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
137
100
|
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
138
101
|
});
|
|
139
|
-
test("a single edit inserts newText byte-for-byte: no $-pattern substitution", async () => {
|
|
140
|
-
freshRoot();
|
|
141
|
-
const session = manager();
|
|
142
|
-
const captured = makeExtension(session);
|
|
143
|
-
const ctx = context(session);
|
|
144
|
-
// Each pattern is a JS String.replace replacement token. With positional splicing the whole
|
|
145
|
-
// two-character (or two-dollar) sequence lands literally; with String.replace it would expand,
|
|
146
|
-
// and the prefix token ($`) would splice in the entire document prefix.
|
|
147
|
-
const cases = ["$&", "$`", "$'", "$1", "$$"];
|
|
148
|
-
for (const token of cases) {
|
|
149
|
-
const newText = `pre${token}post`;
|
|
150
|
-
await call(captured, "notes_write", { path: "literal.md", content: "alpha\nbeta\ngamma" }, ctx);
|
|
151
|
-
const edited = resultJson(await call(captured, "notes_edit", { path: "literal.md", edits: [{ oldText: "beta", newText }] }, ctx));
|
|
152
|
-
assert.equal(edited.applied, 1, `the single edit for ${JSON.stringify(token)} applied`);
|
|
153
|
-
const body = resultRead(await call(captured, "notes_read", { path: "literal.md" }, ctx)).content;
|
|
154
|
-
assert.equal(body.endsWith(`alpha\n${newText}\ngamma`), true, `${JSON.stringify(token)} is inserted literally`);
|
|
155
|
-
assert.equal(body.endsWith(`alpha\nalpha\npre${token}post\ngamma`), false, `${JSON.stringify(token)} does not splice in the document prefix`);
|
|
156
|
-
}
|
|
157
|
-
// The replace_all branch (split/join) is likewise literal, so both branches agree.
|
|
158
|
-
await call(captured, "notes_write", { path: "literal-all.md", content: "one X two X three" }, ctx);
|
|
159
|
-
await call(captured, "notes_edit", { path: "literal-all.md", edits: [{ oldText: "X", newText: "$`$&$1$$" }], replace_all: true }, ctx);
|
|
160
|
-
const allBody = resultRead(await call(captured, "notes_read", { path: "literal-all.md" }, ctx)).content;
|
|
161
|
-
assert.equal(allBody.endsWith("one $`$&$1$$ two $`$&$1$$ three"), true, "replace_all inserts $-patterns literally too");
|
|
162
|
-
});
|
|
163
|
-
test("metadata-only edit updates setters without touching the body", async () => {
|
|
164
|
-
freshRoot();
|
|
165
|
-
const session = manager();
|
|
166
|
-
const captured = makeExtension(session);
|
|
167
|
-
const ctx = context(session);
|
|
168
|
-
await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
|
|
169
|
-
const bare = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
170
|
-
assert.equal(bare.applied, 0, "a metadata-only update applies no edits");
|
|
171
|
-
assert.equal(bare.address, "journal.md");
|
|
172
|
-
assert.deepEqual(Object.keys(bare).sort(), ["address", "applied", "diff"]);
|
|
173
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, true, "stale is set without a body edit");
|
|
174
|
-
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "the body is untouched");
|
|
175
|
-
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
176
|
-
assert.deepEqual(Object.keys(revived).sort(), ["address", "applied", "diff"]);
|
|
177
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.stale, false, "a later metadata-only update revives the note");
|
|
178
|
-
});
|
|
179
|
-
test("notes_edit returns a pi-edit-style diff of what changed", async () => {
|
|
180
|
-
freshRoot();
|
|
181
|
-
const session = manager();
|
|
182
|
-
const captured = makeExtension(session);
|
|
183
|
-
const ctx = context(session);
|
|
184
|
-
await call(captured, "notes_write", { path: "d.md", content: "alpha\nbeta" }, ctx);
|
|
185
|
-
// Content edit → body diff.
|
|
186
|
-
const bodyEdit = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
187
|
-
assert.match(bodyEdit.diff, /- *\d+ beta/);
|
|
188
|
-
assert.match(bodyEdit.diff, /\+ *\d+ B/);
|
|
189
|
-
assert.equal(bodyEdit.diff.includes("scope:"), false, "a content-only diff does not drag frontmatter in");
|
|
190
|
-
// Metadata-only → frontmatter diff.
|
|
191
|
-
const metaOnly = resultJson(await call(captured, "notes_edit", { path: "d.md", stale: true }, ctx));
|
|
192
|
-
assert.equal(metaOnly.applied, 0);
|
|
193
|
-
assert.match(metaOnly.diff, /- *\d+ stale: false/);
|
|
194
|
-
assert.match(metaOnly.diff, /\+ *\d+ stale: true/);
|
|
195
|
-
assert.equal(metaOnly.diff.includes("alpha"), false, "a metadata-only diff does not drag the body in");
|
|
196
|
-
// Both → one combined diff naming body and frontmatter changes, without moving homes.
|
|
197
|
-
const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], stale: false }, ctx));
|
|
198
|
-
assert.match(combined.diff, /- *\d+ alpha/);
|
|
199
|
-
assert.match(combined.diff, /\+ *\d+ ALPHA/);
|
|
200
|
-
assert.match(combined.diff, /- *\d+ stale: true/);
|
|
201
|
-
assert.match(combined.diff, /\+ *\d+ stale: false/);
|
|
202
|
-
});
|
|
203
102
|
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
204
103
|
freshRoot();
|
|
205
104
|
const session = manager();
|
|
@@ -226,35 +125,6 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
|
|
|
226
125
|
const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
227
126
|
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
228
127
|
});
|
|
229
|
-
test.skip("scope resolution and movement are superseded by explicit address tests", async () => {
|
|
230
|
-
freshRoot();
|
|
231
|
-
const session = manager();
|
|
232
|
-
const captured = makeExtension(session);
|
|
233
|
-
const ctx = context(session);
|
|
234
|
-
await call(captured, "notes_write", { path: "shared.md", content: "personal body", scope: "personal" }, ctx);
|
|
235
|
-
await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
|
|
236
|
-
const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
|
|
237
|
-
assert.equal(first.details.scope, "session", "session wins the precedence over personal");
|
|
238
|
-
const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
|
|
239
|
-
assert.ok(readAgain.content.includes("session body"));
|
|
240
|
-
const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
|
|
241
|
-
assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
|
|
242
|
-
const personalMeta = listNotes(ctx, { scope: "personal" })[0].meta;
|
|
243
|
-
assert.equal(personalMeta.access_count, 0, "the personal copy is untouched");
|
|
244
|
-
// Move the session copy to project; the personal copy is untouched.
|
|
245
|
-
const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
|
|
246
|
-
assert.equal(moved.meta.scope, "project");
|
|
247
|
-
assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
|
|
248
|
-
assert.equal(existsSync(physicalPath("session", "shared.md", ctx)), false, "the source file moved away");
|
|
249
|
-
assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
|
|
250
|
-
// A move onto an existing target is refused and both files survive unchanged.
|
|
251
|
-
await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
|
|
252
|
-
await call(captured, "notes_write", { path: "clash.md", content: "personal stay", scope: "personal" }, ctx);
|
|
253
|
-
const beforePersonal = readFileSync(physicalPath("personal", "clash.md", ctx), "utf8");
|
|
254
|
-
const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "personal" }, ctx));
|
|
255
|
-
assert.match(refusal.error, /already exists/);
|
|
256
|
-
assert.equal(readFileSync(physicalPath("personal", "clash.md", ctx), "utf8"), beforePersonal, "the target survives a refused move");
|
|
257
|
-
});
|
|
258
128
|
test("all notes tool results use address as the only home identity", async () => {
|
|
259
129
|
freshRoot();
|
|
260
130
|
const session = manager();
|
|
@@ -263,7 +133,7 @@ test("all notes tool results use address as the only home identity", async () =>
|
|
|
263
133
|
const notes = [
|
|
264
134
|
{ address: "session.md", body: "session needle" },
|
|
265
135
|
{ address: "@project/project.md", body: "project needle" },
|
|
266
|
-
{ address: "@
|
|
136
|
+
{ address: "@human/human.md", body: "human needle" },
|
|
267
137
|
];
|
|
268
138
|
for (const note of notes) {
|
|
269
139
|
const written = resultJson(await call(captured, "notes_write", { address: note.address, content: note.body }, ctx));
|
|
@@ -294,18 +164,18 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
294
164
|
const ctx = context(session);
|
|
295
165
|
await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
|
|
296
166
|
await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
|
|
297
|
-
await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "
|
|
167
|
+
await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "human" }, ctx);
|
|
298
168
|
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
299
|
-
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@
|
|
169
|
+
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
|
|
300
170
|
for (const row of listed.files) {
|
|
301
171
|
assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
|
|
302
172
|
assert.equal(row.stale, false);
|
|
303
173
|
}
|
|
304
|
-
const scoped = resultJson(await call(captured, "notes_list", { scope: "
|
|
305
|
-
assert.deepEqual(scoped.files.map((file) => file.address), ["@
|
|
174
|
+
const scoped = resultJson(await call(captured, "notes_list", { scope: "human" }, ctx));
|
|
175
|
+
assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
|
|
306
176
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
307
177
|
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
308
|
-
assert.deepEqual([...searched.files].map((file) => file.address).sort(), ["@
|
|
178
|
+
assert.deepEqual([...searched.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"]);
|
|
309
179
|
for (const row of [...listed.files, ...searched.files])
|
|
310
180
|
assertNoPublicScope(row, "notes_list/search");
|
|
311
181
|
assert.equal(searched.files.every((file) => file.matches_total === 1), true);
|
|
@@ -323,182 +193,6 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
323
193
|
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
|
324
194
|
await assert.rejects(() => call(captured, "notes_search", { query: "needle", pattern: "bad\\glob" }, ctx), /backslash/);
|
|
325
195
|
});
|
|
326
|
-
test("the boot index reads the physical store across scopes and excludes stale notes", async () => {
|
|
327
|
-
freshRoot();
|
|
328
|
-
const session = manager();
|
|
329
|
-
const captured = makeExtension(session);
|
|
330
|
-
const ctx = context(session);
|
|
331
|
-
await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
|
|
332
|
-
await call(captured, "notes_write", { path: "personal.md", content: "personal content", scope: "personal" }, ctx);
|
|
333
|
-
await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
|
|
334
|
-
runHandlers(captured, "session_start", {}, ctx);
|
|
335
|
-
const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
|
|
336
|
-
assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
|
|
337
|
-
assert.ok(text.includes("personal.md"), "a fresh personal note is indexed");
|
|
338
|
-
assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
|
|
339
|
-
assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
|
|
340
|
-
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
|
|
341
|
-
assert.ok(PROTOCOL_BLOCK.includes(name), `the protocol block names ${name}`);
|
|
342
|
-
}
|
|
343
|
-
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
344
|
-
assert.equal(PROTOCOL_BLOCK.includes(legacy), false, `the protocol block no longer names ${legacy}`);
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
test("search offsets start reads at Unicode matches across homes without mutating search results", async () => {
|
|
348
|
-
freshRoot();
|
|
349
|
-
const session = manager();
|
|
350
|
-
const captured = makeExtension(session);
|
|
351
|
-
const ctx = context(session);
|
|
352
|
-
const notes = [
|
|
353
|
-
{ address: "session.md", scope: "session", body: "first line\n前置 🐉 needle-session\nend", needle: "needle-session" },
|
|
354
|
-
{ address: "@project/crossing.md", scope: "project", body: "prefix\nneedle-project 😀", needle: "needle-project" },
|
|
355
|
-
{ address: "@personal/legacy.md", scope: "personal", body: "legacy 😺 needle-personal", needle: "needle-personal" },
|
|
356
|
-
];
|
|
357
|
-
for (const note of notes)
|
|
358
|
-
await call(captured, "notes_write", { address: note.address, content: note.body }, ctx);
|
|
359
|
-
const crossing = physicalPath("project", "crossing.md", ctx);
|
|
360
|
-
writeFileSync(crossing, readFileSync(crossing, "utf8").replace(/^access_count: 0$/m, "access_count: 9"));
|
|
361
|
-
const legacy = physicalPath("personal", "legacy.md", ctx);
|
|
362
|
-
writeFileSync(legacy, notes[2].body);
|
|
363
|
-
const before = new Map(notes.map((note) => [note.address, readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8")]));
|
|
364
|
-
const searched = resultJson(await call(captured, "notes_search", { query: notes.map((note) => note.needle), pattern: "**" }, ctx));
|
|
365
|
-
for (const note of notes) {
|
|
366
|
-
assert.equal(readFileSync(physicalPath(note.scope, note.address.replace(/^@(?:project|personal)\//, ""), ctx), "utf8"), before.get(note.address), `search leaves ${note.address} byte-identical`);
|
|
367
|
-
const file = searched.files.find((candidate) => candidate.address === note.address);
|
|
368
|
-
assert.ok(file, `search returns ${note.address}`);
|
|
369
|
-
assert.deepEqual(Object.keys(file).sort(), ["address", "matches", "matches_total", "stale", "updated_at"]);
|
|
370
|
-
const hit = file.matches[0];
|
|
371
|
-
assert.deepEqual(Object.keys(hit).sort(), ["line", "offset_chars", "text", "truncated"]);
|
|
372
|
-
const read = resultRead(await call(captured, "notes_read", { address: note.address, offset_chars: hit.offset_chars }, ctx));
|
|
373
|
-
assert.ok(read.content.startsWith(note.needle), `search offset starts notes_read at ${note.needle}`);
|
|
374
|
-
assert.deepEqual(Object.keys(read.details).sort(), ["address", "next_offset_chars", "offset_chars", "total_chars"]);
|
|
375
|
-
}
|
|
376
|
-
assert.match(readFileSync(crossing, "utf8"), /^access_count: 10$/m, "the predicted read crosses access_count from 9 to 10");
|
|
377
|
-
assert.match(resultRead(await call(captured, "notes_read", { address: "@personal/legacy.md" }, ctx)).content, /^---\n/, "a missing-frontmatter note is normalized only by read");
|
|
378
|
-
});
|
|
379
|
-
test("notes_read surfaces frontmatter and search reports body lines", async () => {
|
|
380
|
-
freshRoot();
|
|
381
|
-
const session = manager();
|
|
382
|
-
const captured = makeExtension(session);
|
|
383
|
-
const ctx = context(session);
|
|
384
|
-
await call(captured, "notes_write", { path: "compose.md", content: "line one\nneedle address\nline three" }, ctx);
|
|
385
|
-
const read = resultRead(await call(captured, "notes_read", { path: "compose.md" }, ctx));
|
|
386
|
-
assert.match(read.content, /^---\n/, "the model sees the frontmatter first");
|
|
387
|
-
assert.ok(read.content.includes("needle address"));
|
|
388
|
-
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
389
|
-
const match = searched.files[0].matches[0];
|
|
390
|
-
assert.equal(match.line, 2, "search reports the body line number");
|
|
391
|
-
assert.equal(match.offset_chars, read.content.indexOf("needle"), "offset_chars addresses the query in the serialized read stream");
|
|
392
|
-
});
|
|
393
|
-
test("mutations are atomic, leave no temp files, and a read bumps only the access keys", async () => {
|
|
394
|
-
freshRoot();
|
|
395
|
-
const session = manager();
|
|
396
|
-
const captured = makeExtension(session);
|
|
397
|
-
const ctx = context(session);
|
|
398
|
-
await call(captured, "notes_write", { path: "atomic.md", content: "alpha\nbeta" }, ctx);
|
|
399
|
-
await call(captured, "notes_edit", { path: "atomic.md", edits: [{ oldText: "alpha", newText: "A" }] }, ctx);
|
|
400
|
-
await call(captured, "notes_read", { path: "atomic.md" }, ctx);
|
|
401
|
-
const first = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
402
|
-
assert.equal(first.includes("alpha"), false, "the edit landed");
|
|
403
|
-
const secondRead = resultRead(await call(captured, "notes_read", { path: "atomic.md" }, ctx));
|
|
404
|
-
assert.ok(secondRead.content.endsWith("A\nbeta"), "a second read still delivers the body");
|
|
405
|
-
const afterRead = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
406
|
-
const lineOf = (text, key) => text.split("\n").find((line) => line.startsWith(`${key}:`));
|
|
407
|
-
for (const key of ["created_at", "updated_at", "origin", "status", "scope"]) {
|
|
408
|
-
assert.equal(lineOf(afterRead, key), lineOf(first, key), `${key} stays byte-stable across a read`);
|
|
409
|
-
}
|
|
410
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.access_count, 2, "two reads bump access_count twice");
|
|
411
|
-
// No torn-write temp files survive any mutation.
|
|
412
|
-
const temps = [];
|
|
413
|
-
const walk = (dir) => {
|
|
414
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
415
|
-
if (entry.isDirectory())
|
|
416
|
-
walk(join(dir, entry.name));
|
|
417
|
-
else if (entry.name.endsWith(".tmp"))
|
|
418
|
-
temps.push(join(dir, entry.name));
|
|
419
|
-
}
|
|
420
|
-
};
|
|
421
|
-
walk(process.env.PI_NOTES_HOME);
|
|
422
|
-
assert.deepEqual(temps, [], "tmp files are renamed away, never left behind");
|
|
423
|
-
});
|
|
424
|
-
test("write-time caps refuse an oversized vpath or serialized file, and edit refuses an oversized result", async () => {
|
|
425
|
-
freshRoot();
|
|
426
|
-
const session = manager();
|
|
427
|
-
const captured = makeExtension(session);
|
|
428
|
-
const ctx = context(session);
|
|
429
|
-
const seg = "b".repeat(120);
|
|
430
|
-
const okPath = [seg, seg, seg, seg, "x"].join("/");
|
|
431
|
-
const overPath = `${okPath}${seg}`;
|
|
432
|
-
assert.ok(Buffer.byteLength(okPath, "utf8") <= MAX_NOTE_PATH_BYTES);
|
|
433
|
-
assert.ok(Buffer.byteLength(overPath, "utf8") > MAX_NOTE_PATH_BYTES);
|
|
434
|
-
const refusedPath = resultJson(await call(captured, "notes_write", { path: overPath, content: "x" }, ctx));
|
|
435
|
-
assert.match(refusedPath.error, new RegExp(String(MAX_NOTE_PATH_BYTES)), "an over-cap vpath names the cap");
|
|
436
|
-
const accepted = resultJson(await call(captured, "notes_write", { path: okPath, content: "x" }, ctx));
|
|
437
|
-
assert.equal(accepted.path, okPath, "a path at the cap is accepted");
|
|
438
|
-
const refusedBody = resultJson(await call(captured, "notes_write", { path: "big.md", content: "x".repeat(MAX_NOTE_BYTES) }, ctx));
|
|
439
|
-
assert.match(refusedBody.error, new RegExp(String(MAX_NOTE_BYTES)), "an over-cap body names the size cap");
|
|
440
|
-
await call(captured, "notes_write", { path: "small.md", content: "small" }, ctx);
|
|
441
|
-
const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
|
|
442
|
-
assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
|
|
443
|
-
});
|
|
444
|
-
test("fresh personal and project MAP.md bodies are both resident before the pocket", async () => {
|
|
445
|
-
freshRoot();
|
|
446
|
-
const session = manager();
|
|
447
|
-
const captured = makeExtension(session);
|
|
448
|
-
const ctx = context(session);
|
|
449
|
-
await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
|
|
450
|
-
await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
|
|
451
|
-
await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal\nMAP: second" }, ctx);
|
|
452
|
-
await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
|
|
453
|
-
runHandlers(captured, "session_start", {}, ctx);
|
|
454
|
-
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
455
|
-
assert.ok(boot.includes("MAP: personal"), "the personal map is injected");
|
|
456
|
-
assert.ok(boot.includes("MAP: project"), "the project map is injected");
|
|
457
|
-
assert.equal(boot.includes("MAP: session"), false, "the session map is never injected");
|
|
458
|
-
assert.ok(boot.indexOf("MAP: personal") < boot.indexOf("MAP: project"), "the personal map precedes the project map");
|
|
459
|
-
assert.ok(boot.indexOf("MAP: project") < boot.indexOf("crumpled note"), "both map bodies precede the pocket");
|
|
460
|
-
assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
|
|
461
|
-
});
|
|
462
|
-
test("the boot pocket applies per-home quotas in session, project, personal order", async () => {
|
|
463
|
-
freshRoot();
|
|
464
|
-
const session = manager();
|
|
465
|
-
const captured = makeExtension(session);
|
|
466
|
-
const ctx = context(session);
|
|
467
|
-
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
|
468
|
-
for (const [scope, count] of [["session", 6], ["project", 3], ["personal", 3]]) {
|
|
469
|
-
for (let index = 0; index < count; index++) {
|
|
470
|
-
const path = `${scope}-${index}.md`;
|
|
471
|
-
await call(captured, "notes_write", { path, content: `${scope} body`, scope }, ctx);
|
|
472
|
-
setUpdatedAt(scope, path, ctx, base + index * 1_000);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
|
|
476
|
-
await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
|
|
477
|
-
await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal" }, ctx);
|
|
478
|
-
runHandlers(captured, "session_start", {}, ctx);
|
|
479
|
-
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
480
|
-
assert.ok(boot.includes("You find 9 crumpled notes in your pocket (by home, most recent first within each: up to 5 from this session, 2 from this project, 2 from personal). A note's content never appears here, so its name has to say what the note is about:"), "the pocket line matches the dictated copy");
|
|
481
|
-
for (const name of ["session-5.md", "session-4.md", "session-3.md", "session-2.md", "session-1.md", "@project/project-2.md", "@project/project-1.md", "@personal/personal-2.md", "@personal/personal-1.md"]) {
|
|
482
|
-
assert.ok(boot.includes(name), `${name} stays in the pocket`);
|
|
483
|
-
}
|
|
484
|
-
for (const name of ["session-0.md", "@project/project-0.md", "@personal/personal-0.md", "MAP.md", "MAP: session"]) {
|
|
485
|
-
assert.equal(boot.includes(name), false, `${name} is not a pocket entry`);
|
|
486
|
-
}
|
|
487
|
-
assert.ok(boot.indexOf("session-5.md") < boot.indexOf("session-4.md"), "session notes are most-recent-first");
|
|
488
|
-
assert.ok(boot.indexOf("@project/project-2.md") < boot.indexOf("@project/project-1.md"), "project notes are most-recent-first");
|
|
489
|
-
assert.ok(boot.indexOf("@personal/personal-2.md") < boot.indexOf("@personal/personal-1.md"), "personal notes are most-recent-first");
|
|
490
|
-
assert.ok(boot.indexOf("session-1.md") < boot.indexOf("@project/project-2.md"), "session notes precede project notes");
|
|
491
|
-
assert.ok(boot.indexOf("@project/project-1.md") < boot.indexOf("@personal/personal-2.md"), "project notes precede personal notes");
|
|
492
|
-
});
|
|
493
|
-
test("project scope keys off the git root basename and sha1 prefix", () => {
|
|
494
|
-
const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
|
|
495
|
-
process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
496
|
-
const session = manager();
|
|
497
|
-
const ctx = context(session, undefined, undefined, true, root);
|
|
498
|
-
const key = projectKey(root);
|
|
499
|
-
assert.match(key, /^pi-context-proj-[^-]+-[0-9a-f]{8}$/, "the project key is basename plus an 8-hex sha1 prefix");
|
|
500
|
-
assert.equal(scopeDir("project", ctx), join(process.env.PI_NOTES_HOME, "project", key));
|
|
501
|
-
});
|
|
502
196
|
test("@ addresses select one home, reject illegal sigils, and never fall back", async () => {
|
|
503
197
|
const root = freshRoot();
|
|
504
198
|
const session = manager();
|
|
@@ -506,55 +200,31 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
|
|
|
506
200
|
const ctx = context(session);
|
|
507
201
|
await call(captured, "notes_write", { address: "same.md", content: "session" }, ctx);
|
|
508
202
|
await call(captured, "notes_write", { address: "@project/same.md", content: "project" }, ctx);
|
|
509
|
-
await call(captured, "notes_write", { address: "@
|
|
203
|
+
await call(captured, "notes_write", { address: "@human/same.md", content: "human" }, ctx);
|
|
510
204
|
assert.ok(existsSync(physicalPath("project", "same.md", ctx)), "@project writes to the current project home");
|
|
511
|
-
assert.ok(existsSync(physicalPath("
|
|
205
|
+
assert.ok(existsSync(physicalPath("human", "same.md", ctx)), "@human writes to the human home");
|
|
512
206
|
assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
|
|
513
207
|
assert.equal(resultJson(await call(captured, "notes_read", { address: "@project/missing.md" }, ctx)).error, "note not found");
|
|
514
|
-
await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@
|
|
515
|
-
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@
|
|
516
|
-
assert.equal(existsSync(join(root, "
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
|
|
538
|
-
assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
|
|
539
|
-
});
|
|
540
|
-
test("stale project and personal maps are skipped independently", async () => {
|
|
541
|
-
freshRoot();
|
|
542
|
-
const session = manager();
|
|
543
|
-
const captured = makeExtension(session);
|
|
544
|
-
const ctx = context(session);
|
|
545
|
-
await call(captured, "notes_write", { address: "@project/MAP.md", content: "project fresh" }, ctx);
|
|
546
|
-
await call(captured, "notes_write", { address: "@personal/MAP.md", content: "personal stale", stale: true }, ctx);
|
|
547
|
-
runHandlers(captured, "session_start", {}, ctx);
|
|
548
|
-
let boot = String(captured.sent.at(-1)?.message.content ?? "");
|
|
549
|
-
assert.ok(boot.includes("project fresh"), "a fresh project map survives a stale personal map");
|
|
550
|
-
assert.equal(boot.includes("personal stale"), false, "the stale personal map is skipped");
|
|
551
|
-
const second = manager();
|
|
552
|
-
const secondCaptured = makeExtension(second);
|
|
553
|
-
const secondCtx = context(second);
|
|
554
|
-
await call(secondCaptured, "notes_edit", { address: "@project/MAP.md", stale: true }, secondCtx);
|
|
555
|
-
await call(secondCaptured, "notes_edit", { address: "@personal/MAP.md", stale: false }, secondCtx);
|
|
556
|
-
runHandlers(secondCaptured, "session_start", {}, secondCtx);
|
|
557
|
-
boot = String(secondCaptured.sent.at(-1)?.message.content ?? "");
|
|
558
|
-
assert.equal(boot.includes("project fresh"), false, "the stale project map is skipped");
|
|
559
|
-
assert.ok(boot.includes("personal stale"), "a fresh personal map survives a stale project map");
|
|
560
|
-
});
|
|
208
|
+
await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@human\/.*bare names are the session home/);
|
|
209
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are the session home/);
|
|
210
|
+
assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
|
|
211
|
+
});
|
|
212
|
+
const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00.000+00:00\nupdated_at: 2026-01-01T00:00:00.000+00:00\nlast_accessed: 2026-01-01T00:00:00.000+00:00\naccess_count: 0\n---\n\n${body}`;
|
|
213
|
+
async function withAgent(name, run) {
|
|
214
|
+
const previous = process.env.PI_NOTES_AGENT;
|
|
215
|
+
if (name === undefined)
|
|
216
|
+
delete process.env.PI_NOTES_AGENT;
|
|
217
|
+
else
|
|
218
|
+
process.env.PI_NOTES_AGENT = name;
|
|
219
|
+
try {
|
|
220
|
+
// A plain `return run()` would run the finally block the moment the promise pends,
|
|
221
|
+
// restoring the env while the callback still has awaits ahead of it.
|
|
222
|
+
await run();
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
if (previous === undefined)
|
|
226
|
+
delete process.env.PI_NOTES_AGENT;
|
|
227
|
+
else
|
|
228
|
+
process.env.PI_NOTES_AGENT = previous;
|
|
229
|
+
}
|
|
230
|
+
}
|