@astrosheep/pi-context 0.25.1 → 0.26.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 +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +616 -370
- 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 +19 -15
- 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 +298 -242
- 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/tool-output.d.ts +101 -0
- 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 +318 -19
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +55 -29
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +8 -7
- 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.d.ts +115 -0
- package/dist/test/helpers/extension.js +6 -6
- 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/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +111 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +22 -24
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +137 -7
- 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 +25 -17
- 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 +308 -244
- 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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createNotesStore, NoteError } from "../src/notes/index.js";
|
|
7
|
+
function fixture(t) {
|
|
8
|
+
const home = mkdtempSync(join(tmpdir(), "notes-library-"));
|
|
9
|
+
t.after(() => rmSync(home, { recursive: true, force: true }));
|
|
10
|
+
const context = { home, sessionId: "session-a", projectKey: "project-12345678", agent: "root", model: "test-model" };
|
|
11
|
+
return { home, context, notes: createNotesStore(context) };
|
|
12
|
+
}
|
|
13
|
+
test("standalone notes API persists camelCase metadata, edits, lists and searches full results", async (t) => {
|
|
14
|
+
const { home, notes } = fixture(t);
|
|
15
|
+
const first = await notes.write("checkpoint", "alpha\nneedle 😀", { origin: "user" });
|
|
16
|
+
assert.equal(first.meta.project, "project-12345678");
|
|
17
|
+
const read = (await notes.read("checkpoint.md"));
|
|
18
|
+
assert.equal(read.body, "alpha\nneedle 😀");
|
|
19
|
+
assert.equal(read.meta.origin, "user");
|
|
20
|
+
assert.equal(read.meta.accessCount, 1);
|
|
21
|
+
assert.equal(await notes.read("missing.md"), undefined);
|
|
22
|
+
const edited = await notes.edit("checkpoint", [{ oldText: "alpha", newText: "beta" }]);
|
|
23
|
+
assert.deepEqual(edited.change, { kind: "body", before: "alpha\nneedle 😀", after: "beta\nneedle 😀" });
|
|
24
|
+
assert.equal(edited.resolvedScope, read.resolvedScope);
|
|
25
|
+
assert.equal(edited.applied, 1);
|
|
26
|
+
const metadataEdit = await notes.edit("checkpoint", undefined, { stale: true });
|
|
27
|
+
assert.equal(metadataEdit.change.kind, "metadata");
|
|
28
|
+
assert.match(metadataEdit.change.before, /\nstale: false\n/);
|
|
29
|
+
assert.match(metadataEdit.change.after, /\nstale: true\n/);
|
|
30
|
+
assert.equal(metadataEdit.change.after.includes("needle"), false, "metadata-only diff excludes body");
|
|
31
|
+
assert.equal((await notes.list())[0]?.meta.stale, true);
|
|
32
|
+
const matches = await notes.search(["needle"]);
|
|
33
|
+
assert.equal(matches[0]?.matches[0]?.line, 2);
|
|
34
|
+
const text = (await notes.read("checkpoint")).text;
|
|
35
|
+
assert.equal(Array.from(text).slice(matches[0].matches[0].offsetChars).join(""), "needle 😀");
|
|
36
|
+
// An existing hand-written extra field and original ownership survive a new caller.
|
|
37
|
+
const path = join(home, "pi/session/session-a/checkpoint.md");
|
|
38
|
+
writeFileSync(path, readFileSync(path, "utf8").replace("\n---\n\n", "\nsourceWindow: pcw:test\nrecurrenceCount: 2\nrecurrenceWindows:\n - pcw:one\n - pcw:two\n__proto__: {\"sentinel\":true}\ncustom: preserved\n---\n\n"));
|
|
39
|
+
const moved = createNotesStore({ home, sessionId: "session-a", projectKey: "other-12345678", agent: "root", model: "test-model" });
|
|
40
|
+
const rewritten = await moved.write("checkpoint", "new body");
|
|
41
|
+
assert.equal(rewritten.meta.createdAt, first.meta.createdAt);
|
|
42
|
+
assert.equal(rewritten.meta.project, "project-12345678");
|
|
43
|
+
assert.equal(rewritten.meta.custom, "preserved");
|
|
44
|
+
assert.deepEqual(rewritten.meta["__proto__"], { sentinel: true });
|
|
45
|
+
assert.deepEqual((await moved.read("checkpoint"))?.meta["__proto__"], { sentinel: true }, "unknown __proto__ metadata survives persistence and reread");
|
|
46
|
+
assert.equal(rewritten.meta.sourceWindow, "pcw:test");
|
|
47
|
+
assert.equal(rewritten.meta.recurrenceCount, 2);
|
|
48
|
+
assert.deepEqual(rewritten.meta.recurrenceWindows, ["pcw:one", "pcw:two"]);
|
|
49
|
+
assert.equal(rewritten.meta.stale, false);
|
|
50
|
+
const combined = await moved.edit("checkpoint", [{ oldText: "new", newText: "final" }], { stale: true });
|
|
51
|
+
assert.equal(combined.change.kind, "file");
|
|
52
|
+
assert.match(combined.change.after, /\nstale: true\n/);
|
|
53
|
+
assert.match(combined.change.after, /final body$/);
|
|
54
|
+
assert.deepEqual((await moved.edit("checkpoint", undefined, { stale: true })).change, { kind: "none", before: "", after: "" });
|
|
55
|
+
});
|
|
56
|
+
test("same-file read/modify/write operations serialize across stores and markdown aliases", async (t) => {
|
|
57
|
+
const { home, context, notes } = fixture(t);
|
|
58
|
+
const other = createNotesStore(context);
|
|
59
|
+
await notes.write("shared", "alpha\nbeta");
|
|
60
|
+
const firstEdit = notes.edit("shared", [{ oldText: "alpha", newText: "A" }]);
|
|
61
|
+
const secondEdit = other.edit("shared.md", [{ oldText: "beta", newText: "B" }]);
|
|
62
|
+
await Promise.all([firstEdit, secondEdit]);
|
|
63
|
+
assert.equal((await notes.read("shared"))?.body, "A\nB", "edits through address aliases retain both changes");
|
|
64
|
+
const before = (await notes.read("shared")).meta.accessCount;
|
|
65
|
+
const [readA, readB] = await Promise.all([notes.read("shared.md"), other.read("shared")]);
|
|
66
|
+
assert.ok(readA && readB);
|
|
67
|
+
assert.equal((await notes.list({ scope: "session" }))[0]?.meta.accessCount, before + 2, "parallel reads do not lose access-counter updates");
|
|
68
|
+
const file = join(home, "pi/session/session-a/shared.md");
|
|
69
|
+
const stableEdits = [{ oldText: "A", newText: "first" }];
|
|
70
|
+
const operation = notes.edit("shared", stableEdits);
|
|
71
|
+
stableEdits[0].newText = "mutated after call";
|
|
72
|
+
await operation;
|
|
73
|
+
assert.equal((await notes.read("shared"))?.body, "first\nB", "edit arguments are snapshotted at method entry");
|
|
74
|
+
assert.equal(readFileSync(file, "utf8").includes("mutated after call"), false);
|
|
75
|
+
});
|
|
76
|
+
test("stores snapshot explicit identity and do not leak homes across instances", async (t) => {
|
|
77
|
+
const { home, context } = fixture(t);
|
|
78
|
+
const mutable = { ...context };
|
|
79
|
+
const notes = createNotesStore(mutable);
|
|
80
|
+
const other = createNotesStore({ ...context, home: join(home, "other"), agent: "other-agent", model: "other-model" });
|
|
81
|
+
assert.equal(existsSync(join(home, "other")), false, "construction does not create the supplied home");
|
|
82
|
+
mutable.agent = "changed-after-construction";
|
|
83
|
+
await notes.write("@self/private", "first");
|
|
84
|
+
await other.write("@self/private", "second");
|
|
85
|
+
assert.equal((await notes.read("@self/private"))?.body, "first");
|
|
86
|
+
assert.equal((await other.read("@self/private"))?.body, "second");
|
|
87
|
+
assert.equal((await notes.list())[0]?.address, "@agents/root/private.md");
|
|
88
|
+
assert.equal((await other.list())[0]?.address, "@agents/other-agent/private.md");
|
|
89
|
+
mkdirSync(join(home, "agents/visitor"), { recursive: true });
|
|
90
|
+
writeFileSync(join(home, "agents/visitor/hello.md"), "visiting");
|
|
91
|
+
assert.equal((await notes.read("@agents/visitor/hello"))?.body, "visiting");
|
|
92
|
+
await assert.rejects(() => notes.write("@agents/visitor/hello", "overwrite"), (error) => error instanceof NoteError && error.code === "invalid_scope");
|
|
93
|
+
});
|
|
94
|
+
test("invalid addressing and failed edits leave stored bytes untouched without poisoning the queue", async (t) => {
|
|
95
|
+
const { home, context, notes } = fixture(t);
|
|
96
|
+
assert.throws(() => createNotesStore({ ...context, sessionId: "../escape" }));
|
|
97
|
+
await assert.rejects(() => notes.write("@project/../escape", "bad"));
|
|
98
|
+
await assert.rejects(() => notes.list({ scope: "agent", who: "../escape" }));
|
|
99
|
+
// The typed API disallows this; JavaScript callers must still receive a refusal.
|
|
100
|
+
// @ts-expect-error who cannot accompany project scope
|
|
101
|
+
await assert.rejects(() => notes.list({ scope: "project", who: "root" }), (error) => error instanceof NoteError && error.code === "invalid_scope");
|
|
102
|
+
await notes.write("edit", "alpha\nbeta\nbeta");
|
|
103
|
+
const path = join(home, "pi/session/session-a/edit.md");
|
|
104
|
+
const before = readFileSync(path, "utf8");
|
|
105
|
+
await assert.rejects(() => notes.edit("edit", [{ oldText: "beta", newText: "B" }]), (error) => error instanceof NoteError && error.code === "ambiguous_edit" && error.lineNumbers?.join(",") === "2,3");
|
|
106
|
+
await assert.rejects(() => notes.edit("edit", [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "X" }]), (error) => error instanceof NoteError && error.code === "no_match" && error.editIndex === 1);
|
|
107
|
+
assert.equal(readFileSync(path, "utf8"), before);
|
|
108
|
+
await assert.rejects(() => notes.edit("absent", undefined, { stale: true }), (error) => error instanceof NoteError && error.code === "not_found");
|
|
109
|
+
await notes.edit("edit", [{ oldText: "alpha", newText: "A" }]);
|
|
110
|
+
assert.equal((await notes.read("edit"))?.body, "A\nbeta\nbeta", "failed operations do not poison later queue work");
|
|
111
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -2,11 +2,10 @@ 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
10
|
import { assertWithinBudget, call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
|
|
12
11
|
const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
|
|
@@ -20,7 +19,7 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
|
|
|
20
19
|
const put = (scope, path, updated) => {
|
|
21
20
|
const file = physicalPath(scope, path, ctx);
|
|
22
21
|
mkdirSync(dirname(file), { recursive: true });
|
|
23
|
-
writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\
|
|
22
|
+
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
23
|
};
|
|
25
24
|
const base = 1_700_000_000_000;
|
|
26
25
|
put("session", "b.md", base + 10);
|
|
@@ -56,7 +55,6 @@ test("notes are real files that persist across sessions and round-trip Unicode",
|
|
|
56
55
|
// A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
|
|
57
56
|
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "human" }, restoredCtx));
|
|
58
57
|
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
58
|
assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
|
|
61
59
|
await assert.rejects(() => call(captured, "notes_write", { path: "../escape", content: "x" }, ctx), /unsupported component/);
|
|
62
60
|
});
|
|
@@ -68,29 +66,29 @@ test("stale lifecycle: writes and metadata-only edits close and revive a note",
|
|
|
68
66
|
// metadata-only: content unchanged, flag set, applied 0
|
|
69
67
|
const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
70
68
|
assert.equal(markOnly.applied, 0);
|
|
71
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
69
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
72
70
|
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
|
|
73
71
|
// explicit revive
|
|
74
72
|
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
75
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "stale:false revives");
|
|
73
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
|
|
76
74
|
// write+stale closure then plain write revival
|
|
77
75
|
await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
|
|
78
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
76
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
79
77
|
await call(captured, "notes_write", { path: "journal.md", content: "reopened" }, ctx);
|
|
80
|
-
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "writing without stale revives");
|
|
78
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
|
|
81
79
|
// metadata-only on a missing path is the typed not-found arm
|
|
82
80
|
const missing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
83
81
|
assert.equal(missing.error, "note not found");
|
|
84
82
|
});
|
|
85
|
-
test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", () => {
|
|
83
|
+
test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
|
|
86
84
|
const sm = manager();
|
|
87
85
|
const ctx = context(sm);
|
|
88
|
-
assert.deepEqual(listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
|
|
86
|
+
assert.deepEqual(await listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
|
|
89
87
|
const blockedHome = scopeDir("human", ctx);
|
|
90
88
|
writeFileSync(blockedHome, "not a directory");
|
|
91
|
-
assert.
|
|
89
|
+
await assert.rejects(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
|
|
92
90
|
});
|
|
93
|
-
test("boot note acquisition is one closed snapshot and isolates one or all failed homes", () => {
|
|
91
|
+
test("boot note acquisition is one closed snapshot and isolates one or all failed homes", async () => {
|
|
94
92
|
const sm = manager();
|
|
95
93
|
const ctx = context(sm);
|
|
96
94
|
const updated = Date.now();
|
|
@@ -105,10 +103,10 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
105
103
|
origin: "self",
|
|
106
104
|
status: "active",
|
|
107
105
|
stale: false,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
createdAt: updated,
|
|
107
|
+
updatedAt: updated,
|
|
108
|
+
lastAccessed: updated,
|
|
109
|
+
accessCount: 0,
|
|
112
110
|
},
|
|
113
111
|
});
|
|
114
112
|
const rows = new Map([
|
|
@@ -119,7 +117,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
119
117
|
["model", [note("model", "model.md", "@models/default/model.md", "MODEL_POCKET_BODY")]],
|
|
120
118
|
]);
|
|
121
119
|
const calls = new Map();
|
|
122
|
-
const snapshot = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
120
|
+
const snapshot = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
123
121
|
calls.set(scope, (calls.get(scope) ?? 0) + 1);
|
|
124
122
|
return rows.get(scope) ?? [];
|
|
125
123
|
});
|
|
@@ -138,7 +136,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
138
136
|
assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
|
|
139
137
|
assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
|
|
140
138
|
const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
|
|
141
|
-
const oneFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
139
|
+
const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
142
140
|
if (scope === "human")
|
|
143
141
|
throw readFailure("EIO");
|
|
144
142
|
return rows.get(scope) ?? [];
|
|
@@ -153,7 +151,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
153
151
|
});
|
|
154
152
|
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
153
|
assert.equal(oneFailedText.includes("HUMAN_POCKET_BODY"), false, "the failed home's index is omitted");
|
|
156
|
-
const allFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
154
|
+
const allFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
157
155
|
throw readFailure(scope === "session" ? "EACCES" : "EIO");
|
|
158
156
|
});
|
|
159
157
|
assert.equal(allFailed.unavailable.length, 5);
|
|
@@ -168,12 +166,12 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
168
166
|
assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
|
|
169
167
|
assert.ok(allFailedText.includes("Your memory resets whenever the context window fills"), "protocol survives an all-home failure");
|
|
170
168
|
assert.equal(allFailedText.includes("scripted read failure"), false, "the model-facing notice does not expose OS/error details");
|
|
171
|
-
assert.
|
|
172
|
-
assert.
|
|
169
|
+
await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
|
|
170
|
+
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
171
|
});
|
|
174
|
-
test("the boot block gives awake agents the notes-home file layout", () => {
|
|
172
|
+
test("the boot block gives awake agents the notes-home file layout", async () => {
|
|
175
173
|
const session = manager();
|
|
176
|
-
const rendered = explicitBoot(context(session), "pcw:test:root", undefined);
|
|
174
|
+
const rendered = await explicitBoot(context(session), "pcw:test:root", undefined);
|
|
177
175
|
assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
|
|
178
176
|
assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
|
|
179
177
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test/notes.test.js
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
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 {
|
|
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, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
|
|
15
18
|
const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
|
|
16
19
|
test.beforeEach(() => testEnvironment.beforeEach());
|
|
17
20
|
test.afterEach(() => testEnvironment.afterEach());
|
|
@@ -22,7 +25,7 @@ function freshRoot() {
|
|
|
22
25
|
function setUpdatedAt(scope, path, ctx, timestamp) {
|
|
23
26
|
const file = physicalPath(scope, path, ctx);
|
|
24
27
|
const raw = readFileSync(file, "utf8");
|
|
25
|
-
writeFileSync(file, raw.replace(/^
|
|
28
|
+
writeFileSync(file, raw.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date(timestamp).toISOString()}`));
|
|
26
29
|
}
|
|
27
30
|
function assertNoPublicScope(value, label) {
|
|
28
31
|
if (Array.isArray(value)) {
|
|
@@ -64,21 +67,126 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
64
67
|
const raw = readFileSync(file, "utf8");
|
|
65
68
|
assert.match(raw, /^---\n/, "the file opens with frontmatter");
|
|
66
69
|
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"], ["
|
|
70
|
+
for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["accessCount", "0"]]) {
|
|
68
71
|
assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
|
|
69
72
|
}
|
|
70
73
|
assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
|
|
71
|
-
for (const key of ["
|
|
74
|
+
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
72
75
|
assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
|
|
73
76
|
}
|
|
77
|
+
assert.equal(parseNote(raw).meta.project, projectKey(ctx.cwd), "a newly-created session note records its existing project key");
|
|
74
78
|
assert.equal(result.address, "a/b.md");
|
|
75
79
|
assert.equal(result.written, true);
|
|
80
|
+
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
|
|
76
81
|
// A leading YAML block in user content is stripped from the body.
|
|
77
82
|
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
|
|
78
83
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
79
84
|
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
80
85
|
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
81
86
|
});
|
|
87
|
+
test("session-note project ownership is per note, persistent across sessions, and not reassigned by cwd", async () => {
|
|
88
|
+
freshRoot();
|
|
89
|
+
const cwdA = join(testEnvironment.cwd, "project-a");
|
|
90
|
+
const cwdB = join(testEnvironment.cwd, "project-b");
|
|
91
|
+
mkdirSync(cwdA, { recursive: true });
|
|
92
|
+
mkdirSync(cwdB, { recursive: true });
|
|
93
|
+
const projectA = projectKey(cwdA);
|
|
94
|
+
const projectB = projectKey(cwdB);
|
|
95
|
+
assert.notEqual(projectA, projectB);
|
|
96
|
+
const firstSession = manager();
|
|
97
|
+
const firstCaptured = makeExtension(firstSession);
|
|
98
|
+
const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
|
|
99
|
+
await call(firstCaptured, "notes_write", { path: "first.md", content: "first project session" }, firstCtx);
|
|
100
|
+
const firstFile = physicalPath("session", "first.md", firstCtx);
|
|
101
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
|
|
102
|
+
const secondSession = manager();
|
|
103
|
+
const secondCaptured = makeExtension(secondSession);
|
|
104
|
+
const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
|
|
105
|
+
await call(secondCaptured, "notes_write", { path: "second.md", content: "same project, another session" }, secondCtx);
|
|
106
|
+
const secondFile = physicalPath("session", "second.md", secondCtx);
|
|
107
|
+
assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
|
|
108
|
+
const thirdSession = manager();
|
|
109
|
+
const thirdCaptured = makeExtension(thirdSession);
|
|
110
|
+
const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
|
|
111
|
+
await call(thirdCaptured, "notes_write", { path: "third.md", content: "different project" }, thirdCtx);
|
|
112
|
+
const thirdFile = physicalPath("session", "third.md", thirdCtx);
|
|
113
|
+
assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
|
|
114
|
+
const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
|
|
115
|
+
assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
|
|
116
|
+
const movedContext = context(firstSession, undefined, undefined, true, cwdB);
|
|
117
|
+
await call(firstCaptured, "notes_write", { path: "first.md", content: "overwritten from another cwd" }, movedContext);
|
|
118
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
|
|
119
|
+
await call(firstCaptured, "notes_edit", { path: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
|
|
120
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
|
|
121
|
+
await call(firstCaptured, "notes_read", { path: "first.md" }, movedContext);
|
|
122
|
+
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
|
|
123
|
+
await call(firstCaptured, "notes_write", { path: "new-from-project-b.md", content: "new note", scope: "session" }, movedContext);
|
|
124
|
+
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");
|
|
125
|
+
await call(firstCaptured, "notes_write", { path: "project-note.md", content: "project home note", scope: "project" }, movedContext);
|
|
126
|
+
assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
|
|
127
|
+
assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
|
|
128
|
+
});
|
|
129
|
+
test("linked git worktrees share the main checkout's project key", () => {
|
|
130
|
+
freshRoot();
|
|
131
|
+
// realpath keeps the fixture's gitdir pointer and the asserted paths on one spelling
|
|
132
|
+
// (macOS temp roots live under the /var -> /private/var symlink).
|
|
133
|
+
const root = realpathSync(testEnvironment.cwd);
|
|
134
|
+
const main = join(root, "repo");
|
|
135
|
+
mkdirSync(main, { recursive: true });
|
|
136
|
+
const git = (args) => {
|
|
137
|
+
execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", ...args], { cwd: main, stdio: "ignore" });
|
|
138
|
+
};
|
|
139
|
+
git(["init", "-q"]);
|
|
140
|
+
git(["commit", "-q", "--allow-empty", "-m", "init"]);
|
|
141
|
+
const worktree = join(root, "wt");
|
|
142
|
+
git(["worktree", "add", "-q", "--detach", worktree]);
|
|
143
|
+
assert.equal(projectKey(worktree), projectKey(main), "a linked worktree resolves to the main checkout's key");
|
|
144
|
+
assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
|
|
145
|
+
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");
|
|
146
|
+
});
|
|
147
|
+
test("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
|
|
148
|
+
freshRoot();
|
|
149
|
+
const session = manager();
|
|
150
|
+
const captured = makeExtension(session);
|
|
151
|
+
const ctx = context(session);
|
|
152
|
+
const legacyFile = physicalPath("session", "legacy.md", ctx);
|
|
153
|
+
mkdirSync(scopeDir("session", ctx), { recursive: true });
|
|
154
|
+
writeFileSync(legacyFile, `---
|
|
155
|
+
origin: self
|
|
156
|
+
status: active
|
|
157
|
+
stale: false
|
|
158
|
+
created_at: 2026-01-01T00:00:00.000+00:00
|
|
159
|
+
updated_at: 2026-01-01T00:00:00.000+00:00
|
|
160
|
+
last_accessed: 2026-01-01T00:00:00.000+00:00
|
|
161
|
+
access_count: 0
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
legacy body`);
|
|
165
|
+
assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
|
|
166
|
+
const legacyBytes = readFileSync(legacyFile, "utf8");
|
|
167
|
+
await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
|
|
168
|
+
await assert.rejects(() => call(captured, "notes_read", { path: "legacy.md" }, ctx), /requires manual migration/);
|
|
169
|
+
await assert.rejects(() => call(captured, "notes_write", { path: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
|
|
170
|
+
assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
|
|
171
|
+
const invalidFile = physicalPath("session", "invalid.md", ctx);
|
|
172
|
+
writeFileSync(invalidFile, `---
|
|
173
|
+
origin: self
|
|
174
|
+
status: active
|
|
175
|
+
stale: false
|
|
176
|
+
createdAt: 2026-01-01T00:00:00.000+00:00
|
|
177
|
+
updatedAt: 2026-01-01T00:00:00.000+00:00
|
|
178
|
+
lastAccessed: 2026-01-01T00:00:00.000+00:00
|
|
179
|
+
accessCount: 0
|
|
180
|
+
project: 17
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
invalid owner`);
|
|
184
|
+
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
|
|
185
|
+
assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
|
|
186
|
+
await call(captured, "notes_write", { path: "invalid.md", content: "still invalid" }, ctx);
|
|
187
|
+
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
|
|
188
|
+
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
|
|
189
|
+
});
|
|
82
190
|
test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
|
|
83
191
|
freshRoot();
|
|
84
192
|
const session = manager();
|
|
@@ -209,7 +317,29 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
|
|
|
209
317
|
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are this session/);
|
|
210
318
|
assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
|
|
211
319
|
});
|
|
212
|
-
|
|
320
|
+
test("Pi adapter resolves agent and switched model identity on each notes call and boot", async () => {
|
|
321
|
+
const root = freshRoot();
|
|
322
|
+
const session = manager();
|
|
323
|
+
const captured = makeExtension(session);
|
|
324
|
+
const ctx = context(session, undefined, undefined, true, testEnvironment.cwd, true, "provider/First.Model");
|
|
325
|
+
await withAgent("Test Agent", async () => {
|
|
326
|
+
await call(captured, "notes_write", { address: "@self/private.md", content: "agent note" }, ctx);
|
|
327
|
+
await call(captured, "notes_write", { address: "@model/private.md", content: "first model note" }, ctx);
|
|
328
|
+
ctx.model = context(session, undefined, undefined, true, testEnvironment.cwd, true, "other/Second.Model").model;
|
|
329
|
+
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");
|
|
330
|
+
await call(captured, "notes_write", { address: "@model/private.md", content: "second model note" }, ctx);
|
|
331
|
+
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
332
|
+
assert.deepEqual(listed.files.map((row) => row.address).sort(), ["@agents/test-agent/private.md", "@models/second-model/private.md"]);
|
|
333
|
+
const boot = await explicitBoot(ctx, "test-window", undefined);
|
|
334
|
+
assert.ok(boot.includes("@models/second-model/private.md"));
|
|
335
|
+
assert.equal(boot.includes("@models/first-model/private.md"), false);
|
|
336
|
+
assert.match(resultRead(await call(captured, "notes_read", { address: "@models/first-model/private.md" }, ctx)).content, /first model note$/);
|
|
337
|
+
const refused = resultJson(await call(captured, "notes_edit", { address: "@models/first-model/private.md", stale: true }, ctx));
|
|
338
|
+
assert.match(refused.error, /not your home/);
|
|
339
|
+
assert.match(readFileSync(join(root, "models/first-model/private.md"), "utf8"), /first model note$/);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
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
343
|
async function withAgent(name, run) {
|
|
214
344
|
const previous = process.env.PI_NOTES_AGENT;
|
|
215
345
|
if (name === undefined)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|