@astrosheep/pi-context 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/budget.js +65 -0
- package/dist/src/dream/cli.js +83 -0
- package/dist/src/dream/gates.js +22 -0
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/runner.js +115 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +215 -0
- package/dist/src/index.js +98 -0
- package/dist/src/notes/address.js +31 -0
- package/dist/src/notes/frontmatter.js +136 -0
- package/dist/src/notes/model.js +101 -0
- package/dist/src/notes/paths.js +58 -0
- package/dist/src/notes/store.js +270 -0
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +81 -0
- package/dist/src/protocol.js +56 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +75 -0
- package/dist/src/tool-output.js +175 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +214 -0
- package/dist/test/coherence.test.js +375 -0
- package/dist/test/dream.test.js +142 -0
- package/dist/test/history.test.js +26 -0
- package/dist/test/integration.test.js +1766 -0
- package/dist/test/notes.test.js +474 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +13 -7
- package/playbook.md +32 -0
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +20 -0
- package/src/dream/git.ts +27 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/runner.ts +111 -0
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +62 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +9 -5
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/src/memory/tools.ts +0 -166
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OWNER: pi-context (adopted).
|
|
3
|
+
* STATUS: tracked acceptance spec for the real-file notes store and its five tools.
|
|
4
|
+
* CLAIM: notes live as markdown files under $PI_NOTES_HOME with harness-owned frontmatter;
|
|
5
|
+
* the five tools (notes_write/edit/read/list/search) are the only note surface, and the
|
|
6
|
+
* boot index reads the physical store across scopes.
|
|
7
|
+
* HERMETIC: every test points PI_NOTES_HOME at its own temp root; no real ~/.agents is touched.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import { physicalPath, projectKey, scopeDir } from "../src/notes/paths.js";
|
|
15
|
+
import { listNotes } from "../src/notes/store.js";
|
|
16
|
+
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES, PROTOCOL_BLOCK } from "../src/protocol.js";
|
|
17
|
+
import { call, context, makeExtension, manager, resultJson, resultRead, runHandlers } from "./integration.test.js";
|
|
18
|
+
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-context-notes-agent-"));
|
|
19
|
+
function freshRoot() {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
21
|
+
process.env.PI_NOTES_HOME = root;
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
function setUpdatedAt(scope, path, ctx, timestamp) {
|
|
25
|
+
const file = physicalPath(scope, path, ctx);
|
|
26
|
+
const raw = readFileSync(file, "utf8");
|
|
27
|
+
writeFileSync(file, raw.replace(/^updated_at: .*$/m, `updated_at: ${new Date(timestamp).toISOString()}`));
|
|
28
|
+
}
|
|
29
|
+
test("exactly the five notes tools are registered; the legacy five are gone", () => {
|
|
30
|
+
const captured = makeExtension(manager());
|
|
31
|
+
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
|
|
32
|
+
assert.ok(captured.tools.get(name), `${name} is registered`);
|
|
33
|
+
}
|
|
34
|
+
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
35
|
+
assert.equal(captured.tools.get(legacy), undefined, `${legacy} is unregistered`);
|
|
36
|
+
}
|
|
37
|
+
assert.equal(captured.tools.get("notes_write")?.executionMode, "sequential");
|
|
38
|
+
assert.equal(captured.tools.get("notes_edit")?.executionMode, "sequential");
|
|
39
|
+
assert.equal(captured.tools.get("notes_read")?.executionMode, undefined);
|
|
40
|
+
});
|
|
41
|
+
test("write lands a real markdown file with harness frontmatter and a pure body", async () => {
|
|
42
|
+
const root = freshRoot();
|
|
43
|
+
const session = manager();
|
|
44
|
+
const captured = makeExtension(session);
|
|
45
|
+
const ctx = context(session);
|
|
46
|
+
const sessionId = session.getSessionId();
|
|
47
|
+
const result = resultJson(await call(captured, "notes_write", { path: "a/b.md", content: "hello" }, ctx));
|
|
48
|
+
assert.equal(result.scope, "session");
|
|
49
|
+
assert.equal(result.size_bytes, 5);
|
|
50
|
+
const file = physicalPath("session", "a/b.md", ctx);
|
|
51
|
+
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
52
|
+
assert.ok(existsSync(file), "the note is a real file under the session scope dir");
|
|
53
|
+
const raw = readFileSync(file, "utf8");
|
|
54
|
+
assert.match(raw, /^---\n/, "the file opens with frontmatter");
|
|
55
|
+
assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
|
|
56
|
+
for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
|
|
57
|
+
assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
|
|
58
|
+
}
|
|
59
|
+
assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
|
|
60
|
+
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
61
|
+
assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
|
|
62
|
+
}
|
|
63
|
+
assert.equal(typeof result.meta.created_at, "string", "wire meta renders timestamps as ISO strings");
|
|
64
|
+
// A leading YAML block in user content is stripped from the body.
|
|
65
|
+
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: global\nnonsense: true\n---\nreal body" }, ctx);
|
|
66
|
+
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
67
|
+
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
68
|
+
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
69
|
+
});
|
|
70
|
+
test("overwrite preserves created_at and unknown keys, bumps updated_at, and clears stale", async () => {
|
|
71
|
+
freshRoot();
|
|
72
|
+
const session = manager();
|
|
73
|
+
const captured = makeExtension(session);
|
|
74
|
+
const ctx = context(session);
|
|
75
|
+
const file = physicalPath("session", "keep.md", ctx);
|
|
76
|
+
await call(captured, "notes_write", { path: "keep.md", content: "first", stale: true }, ctx);
|
|
77
|
+
const created = listNotes(ctx, { scope: "session" })[0].meta.created_at;
|
|
78
|
+
// Inject an unknown frontmatter key the way the sleep-shift layer will.
|
|
79
|
+
const raw = readFileSync(file, "utf8");
|
|
80
|
+
writeFileSync(file, raw.replace(/\n---\n\n/, "\nsleep_shift_key: \"keep-me\"\nrecurrence_count: 4\n---\n\n"));
|
|
81
|
+
const rewrite = resultJson(await call(captured, "notes_write", { path: "keep.md", content: "second" }, ctx));
|
|
82
|
+
const after = readFileSync(file, "utf8");
|
|
83
|
+
assert.equal(after.includes("sleep_shift_key: keep-me"), true, "an unknown key survives a rewrite");
|
|
84
|
+
assert.equal(after.includes("recurrence_count: 4"), true, "a known sleep-shift key survives a rewrite");
|
|
85
|
+
assert.match(after, /\n---\n\nsecond$/, "the body is replaced");
|
|
86
|
+
assert.equal(rewrite.meta.origin, "self");
|
|
87
|
+
const listed = listNotes(ctx, { scope: "session" })[0];
|
|
88
|
+
assert.equal(listed.meta.created_at, created, "created_at is preserved across an overwrite");
|
|
89
|
+
assert.ok(listed.meta.updated_at >= created, "updated_at is bumped");
|
|
90
|
+
assert.equal(listed.meta.stale, false, "a plain rewrite clears stale");
|
|
91
|
+
assert.equal(listed.meta.status, "active");
|
|
92
|
+
});
|
|
93
|
+
test("listNotes retains each parsed body for MAP injection", async () => {
|
|
94
|
+
freshRoot();
|
|
95
|
+
const session = manager();
|
|
96
|
+
const captured = makeExtension(session);
|
|
97
|
+
const ctx = context(session);
|
|
98
|
+
await call(captured, "notes_write", { path: "retained.md", content: "parsed once" }, ctx);
|
|
99
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0]?.body, "parsed once");
|
|
100
|
+
});
|
|
101
|
+
test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
|
|
102
|
+
freshRoot();
|
|
103
|
+
const session = manager();
|
|
104
|
+
const captured = makeExtension(session);
|
|
105
|
+
const ctx = context(session);
|
|
106
|
+
await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
|
|
107
|
+
const ambiguous = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
108
|
+
assert.match(ambiguous.error, /occurs 2 times/);
|
|
109
|
+
assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
|
|
110
|
+
const missing = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
|
|
111
|
+
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
112
|
+
const all = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
113
|
+
assert.equal(all.applied, 1);
|
|
114
|
+
assert.equal(all.resolved_scope, "session", "the success return names the layer the file was resolved from");
|
|
115
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
116
|
+
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
117
|
+
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
118
|
+
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
119
|
+
});
|
|
120
|
+
test("a single edit inserts newText byte-for-byte: no $-pattern substitution", async () => {
|
|
121
|
+
freshRoot();
|
|
122
|
+
const session = manager();
|
|
123
|
+
const captured = makeExtension(session);
|
|
124
|
+
const ctx = context(session);
|
|
125
|
+
// Each pattern is a JS String.replace replacement token. With positional splicing the whole
|
|
126
|
+
// two-character (or two-dollar) sequence lands literally; with String.replace it would expand,
|
|
127
|
+
// and the prefix token ($`) would splice in the entire document prefix.
|
|
128
|
+
const cases = ["$&", "$`", "$'", "$1", "$$"];
|
|
129
|
+
for (const token of cases) {
|
|
130
|
+
const newText = `pre${token}post`;
|
|
131
|
+
await call(captured, "notes_write", { path: "literal.md", content: "alpha\nbeta\ngamma" }, ctx);
|
|
132
|
+
const edited = resultJson(await call(captured, "notes_edit", { path: "literal.md", edits: [{ oldText: "beta", newText }] }, ctx));
|
|
133
|
+
assert.equal(edited.applied, 1, `the single edit for ${JSON.stringify(token)} applied`);
|
|
134
|
+
const body = resultRead(await call(captured, "notes_read", { path: "literal.md" }, ctx)).content;
|
|
135
|
+
assert.equal(body.endsWith(`alpha\n${newText}\ngamma`), true, `${JSON.stringify(token)} is inserted literally`);
|
|
136
|
+
assert.equal(body.endsWith(`alpha\nalpha\npre${token}post\ngamma`), false, `${JSON.stringify(token)} does not splice in the document prefix`);
|
|
137
|
+
}
|
|
138
|
+
// The replace_all branch (split/join) is likewise literal, so both branches agree.
|
|
139
|
+
await call(captured, "notes_write", { path: "literal-all.md", content: "one X two X three" }, ctx);
|
|
140
|
+
await call(captured, "notes_edit", { path: "literal-all.md", edits: [{ oldText: "X", newText: "$`$&$1$$" }], replace_all: true }, ctx);
|
|
141
|
+
const allBody = resultRead(await call(captured, "notes_read", { path: "literal-all.md" }, ctx)).content;
|
|
142
|
+
assert.equal(allBody.endsWith("one $`$&$1$$ two $`$&$1$$ three"), true, "replace_all inserts $-patterns literally too");
|
|
143
|
+
});
|
|
144
|
+
test("metadata-only edit updates setters without touching the body", async () => {
|
|
145
|
+
freshRoot();
|
|
146
|
+
const session = manager();
|
|
147
|
+
const captured = makeExtension(session);
|
|
148
|
+
const ctx = context(session);
|
|
149
|
+
await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
|
|
150
|
+
const bare = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
151
|
+
assert.equal(bare.applied, 0, "a metadata-only update applies no edits");
|
|
152
|
+
assert.equal(bare.resolved_scope, "session");
|
|
153
|
+
assert.equal(bare.meta.stale, true, "stale is set without a body edit");
|
|
154
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "the body is untouched");
|
|
155
|
+
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
156
|
+
assert.equal(revived.meta.stale, false, "a later metadata-only update revives the note");
|
|
157
|
+
});
|
|
158
|
+
test("notes_edit returns a pi-edit-style diff of what changed", async () => {
|
|
159
|
+
freshRoot();
|
|
160
|
+
const session = manager();
|
|
161
|
+
const captured = makeExtension(session);
|
|
162
|
+
const ctx = context(session);
|
|
163
|
+
await call(captured, "notes_write", { path: "d.md", content: "alpha\nbeta" }, ctx);
|
|
164
|
+
// Content edit → body diff.
|
|
165
|
+
const bodyEdit = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
166
|
+
assert.match(bodyEdit.diff, /- *\d+ beta/);
|
|
167
|
+
assert.match(bodyEdit.diff, /\+ *\d+ B/);
|
|
168
|
+
assert.equal(bodyEdit.diff.includes("scope:"), false, "a content-only diff does not drag frontmatter in");
|
|
169
|
+
// Metadata-only → frontmatter diff.
|
|
170
|
+
const metaOnly = resultJson(await call(captured, "notes_edit", { path: "d.md", stale: true }, ctx));
|
|
171
|
+
assert.equal(metaOnly.applied, 0);
|
|
172
|
+
assert.match(metaOnly.diff, /- *\d+ stale: false/);
|
|
173
|
+
assert.match(metaOnly.diff, /\+ *\d+ stale: true/);
|
|
174
|
+
assert.equal(metaOnly.diff.includes("alpha"), false, "a metadata-only diff does not drag the body in");
|
|
175
|
+
// Both → one combined diff naming body and frontmatter changes, without moving homes.
|
|
176
|
+
const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], stale: false }, ctx));
|
|
177
|
+
assert.match(combined.diff, /- *\d+ alpha/);
|
|
178
|
+
assert.match(combined.diff, /\+ *\d+ ALPHA/);
|
|
179
|
+
assert.match(combined.diff, /- *\d+ stale: true/);
|
|
180
|
+
assert.match(combined.diff, /\+ *\d+ stale: false/);
|
|
181
|
+
});
|
|
182
|
+
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
183
|
+
freshRoot();
|
|
184
|
+
const session = manager();
|
|
185
|
+
const captured = makeExtension(session);
|
|
186
|
+
const ctx = context(session);
|
|
187
|
+
const nameOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md" }, ctx));
|
|
188
|
+
assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
|
|
189
|
+
await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta" }, ctx);
|
|
190
|
+
const empty = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [] }, ctx));
|
|
191
|
+
assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
|
|
192
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
193
|
+
assert.equal(editMissing.error, "note not found");
|
|
194
|
+
const readMissing = resultJson(await call(captured, "notes_read", { path: "missing.md" }, ctx));
|
|
195
|
+
assert.equal(readMissing.error, "note not found");
|
|
196
|
+
assert.equal(readMissing.path, "missing.md");
|
|
197
|
+
const file = physicalPath("session", "edit.md", ctx);
|
|
198
|
+
const before = readFileSync(file, "utf8");
|
|
199
|
+
const failed = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
|
|
200
|
+
assert.equal(failed.edit_index, 1, "the failing edit is named");
|
|
201
|
+
assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
|
|
202
|
+
const applied = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
|
|
203
|
+
assert.equal(applied.applied, 2);
|
|
204
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
|
|
205
|
+
const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
206
|
+
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
207
|
+
});
|
|
208
|
+
test.skip("scope resolution and movement are superseded by explicit address tests", async () => {
|
|
209
|
+
freshRoot();
|
|
210
|
+
const session = manager();
|
|
211
|
+
const captured = makeExtension(session);
|
|
212
|
+
const ctx = context(session);
|
|
213
|
+
await call(captured, "notes_write", { path: "shared.md", content: "global body", scope: "global" }, ctx);
|
|
214
|
+
await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
|
|
215
|
+
const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
|
|
216
|
+
assert.equal(first.details.scope, "session", "session wins the precedence over global");
|
|
217
|
+
const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
|
|
218
|
+
assert.ok(readAgain.content.includes("session body"));
|
|
219
|
+
const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
|
|
220
|
+
assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
|
|
221
|
+
const globalMeta = listNotes(ctx, { scope: "global" })[0].meta;
|
|
222
|
+
assert.equal(globalMeta.access_count, 0, "the global copy is untouched");
|
|
223
|
+
// Move the session copy to project; the global copy is untouched.
|
|
224
|
+
const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
|
|
225
|
+
assert.equal(moved.meta.scope, "project");
|
|
226
|
+
assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
|
|
227
|
+
assert.equal(existsSync(physicalPath("session", "shared.md", ctx)), false, "the source file moved away");
|
|
228
|
+
assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
|
|
229
|
+
// A move onto an existing target is refused and both files survive unchanged.
|
|
230
|
+
await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
|
|
231
|
+
await call(captured, "notes_write", { path: "clash.md", content: "global stay", scope: "global" }, ctx);
|
|
232
|
+
const beforeGlobal = readFileSync(physicalPath("global", "clash.md", ctx), "utf8");
|
|
233
|
+
const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "global" }, ctx));
|
|
234
|
+
assert.match(refusal.error, /already exists/);
|
|
235
|
+
assert.equal(readFileSync(physicalPath("global", "clash.md", ctx), "utf8"), beforeGlobal, "the target survives a refused move");
|
|
236
|
+
});
|
|
237
|
+
test("list and search merge scopes and carry scope; the path jail rejects escapes", async () => {
|
|
238
|
+
freshRoot();
|
|
239
|
+
const session = manager();
|
|
240
|
+
const captured = makeExtension(session);
|
|
241
|
+
const ctx = context(session);
|
|
242
|
+
await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
|
|
243
|
+
await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
|
|
244
|
+
await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "global" }, ctx);
|
|
245
|
+
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
246
|
+
assert.deepEqual([...listed.files].map((file) => file.scope).sort(), ["global", "project", "session"], "every merged row carries its scope");
|
|
247
|
+
for (const row of listed.files) {
|
|
248
|
+
assert.equal(typeof row.size_bytes, "number");
|
|
249
|
+
assert.equal(row.origin, "self");
|
|
250
|
+
assert.equal(row.status, "active");
|
|
251
|
+
assert.equal(row.stale, false);
|
|
252
|
+
}
|
|
253
|
+
const scoped = resultJson(await call(captured, "notes_list", { scope: "global" }, ctx));
|
|
254
|
+
assert.deepEqual(scoped.files.map((file) => file.path), ["three.md"], "a scope filter narrows the set");
|
|
255
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
256
|
+
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
257
|
+
assert.deepEqual([...searched.files].map((file) => file.scope).sort(), ["global", "project", "session"]);
|
|
258
|
+
assert.equal(searched.files.every((file) => file.matches_total === 1), true);
|
|
259
|
+
const hit = searched.files[0].matches[0];
|
|
260
|
+
assert.equal(hit.line, 1);
|
|
261
|
+
assert.equal(hit.offset_chars, 0);
|
|
262
|
+
assert.equal(hit.truncated, false);
|
|
263
|
+
assert.equal(hit.total_chars, searched.files[0].path === "three.md" ? "needle three".length : hit.text.length, "total_chars names the real line length");
|
|
264
|
+
const escaped = ["../evil", "/abs", "a\\b"];
|
|
265
|
+
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
266
|
+
for (const path of escaped) {
|
|
267
|
+
await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
|
271
|
+
await assert.rejects(() => call(captured, "notes_search", { query: "needle", pattern: "bad\\glob" }, ctx), /backslash/);
|
|
272
|
+
});
|
|
273
|
+
test("the boot index reads the physical store across scopes and excludes stale notes", async () => {
|
|
274
|
+
freshRoot();
|
|
275
|
+
const session = manager();
|
|
276
|
+
const captured = makeExtension(session);
|
|
277
|
+
const ctx = context(session);
|
|
278
|
+
await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
|
|
279
|
+
await call(captured, "notes_write", { path: "global.md", content: "global content", scope: "global" }, ctx);
|
|
280
|
+
await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
|
|
281
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
282
|
+
const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
|
|
283
|
+
assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
|
|
284
|
+
assert.ok(text.includes("global.md"), "a fresh global note is indexed");
|
|
285
|
+
assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
|
|
286
|
+
assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
|
|
287
|
+
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
|
|
288
|
+
assert.ok(PROTOCOL_BLOCK.includes(name), `the protocol block names ${name}`);
|
|
289
|
+
}
|
|
290
|
+
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
291
|
+
assert.equal(PROTOCOL_BLOCK.includes(legacy), false, `the protocol block no longer names ${legacy}`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
test("notes_read surfaces frontmatter and search reports body lines", async () => {
|
|
295
|
+
freshRoot();
|
|
296
|
+
const session = manager();
|
|
297
|
+
const captured = makeExtension(session);
|
|
298
|
+
const ctx = context(session);
|
|
299
|
+
await call(captured, "notes_write", { path: "compose.md", content: "line one\nneedle address\nline three" }, ctx);
|
|
300
|
+
const read = resultRead(await call(captured, "notes_read", { path: "compose.md" }, ctx));
|
|
301
|
+
assert.match(read.content, /^---\n/, "the model sees the frontmatter first");
|
|
302
|
+
assert.ok(read.content.includes("needle address"));
|
|
303
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
304
|
+
const match = searched.files[0].matches[0];
|
|
305
|
+
assert.equal(match.line, 2, "search reports the body line number");
|
|
306
|
+
assert.equal(match.offset_chars, Array.from("line one\n").length, "offset_chars addresses the query within the body");
|
|
307
|
+
});
|
|
308
|
+
test("mutations are atomic, leave no temp files, and a read bumps only the access keys", async () => {
|
|
309
|
+
freshRoot();
|
|
310
|
+
const session = manager();
|
|
311
|
+
const captured = makeExtension(session);
|
|
312
|
+
const ctx = context(session);
|
|
313
|
+
await call(captured, "notes_write", { path: "atomic.md", content: "alpha\nbeta" }, ctx);
|
|
314
|
+
await call(captured, "notes_edit", { path: "atomic.md", edits: [{ oldText: "alpha", newText: "A" }] }, ctx);
|
|
315
|
+
await call(captured, "notes_read", { path: "atomic.md" }, ctx);
|
|
316
|
+
const first = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
317
|
+
assert.equal(first.includes("alpha"), false, "the edit landed");
|
|
318
|
+
const secondRead = resultRead(await call(captured, "notes_read", { path: "atomic.md" }, ctx));
|
|
319
|
+
assert.ok(secondRead.content.endsWith("A\nbeta"), "a second read still delivers the body");
|
|
320
|
+
const afterRead = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
321
|
+
const lineOf = (text, key) => text.split("\n").find((line) => line.startsWith(`${key}:`));
|
|
322
|
+
for (const key of ["created_at", "updated_at", "origin", "status", "scope"]) {
|
|
323
|
+
assert.equal(lineOf(afterRead, key), lineOf(first, key), `${key} stays byte-stable across a read`);
|
|
324
|
+
}
|
|
325
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.access_count, 2, "two reads bump access_count twice");
|
|
326
|
+
// No torn-write temp files survive any mutation.
|
|
327
|
+
const temps = [];
|
|
328
|
+
const walk = (dir) => {
|
|
329
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
330
|
+
if (entry.isDirectory())
|
|
331
|
+
walk(join(dir, entry.name));
|
|
332
|
+
else if (entry.name.endsWith(".tmp"))
|
|
333
|
+
temps.push(join(dir, entry.name));
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
walk(process.env.PI_NOTES_HOME);
|
|
337
|
+
assert.deepEqual(temps, [], "tmp files are renamed away, never left behind");
|
|
338
|
+
});
|
|
339
|
+
test("write-time caps refuse an oversized vpath or serialized file, and edit refuses an oversized result", async () => {
|
|
340
|
+
freshRoot();
|
|
341
|
+
const session = manager();
|
|
342
|
+
const captured = makeExtension(session);
|
|
343
|
+
const ctx = context(session);
|
|
344
|
+
const seg = "b".repeat(120);
|
|
345
|
+
const okPath = [seg, seg, seg, seg, "x"].join("/");
|
|
346
|
+
const overPath = `${okPath}${seg}`;
|
|
347
|
+
assert.ok(Buffer.byteLength(okPath, "utf8") <= MAX_NOTE_PATH_BYTES);
|
|
348
|
+
assert.ok(Buffer.byteLength(overPath, "utf8") > MAX_NOTE_PATH_BYTES);
|
|
349
|
+
const refusedPath = resultJson(await call(captured, "notes_write", { path: overPath, content: "x" }, ctx));
|
|
350
|
+
assert.match(refusedPath.error, new RegExp(String(MAX_NOTE_PATH_BYTES)), "an over-cap vpath names the cap");
|
|
351
|
+
const accepted = resultJson(await call(captured, "notes_write", { path: okPath, content: "x" }, ctx));
|
|
352
|
+
assert.equal(accepted.path, okPath, "a path at the cap is accepted");
|
|
353
|
+
const refusedBody = resultJson(await call(captured, "notes_write", { path: "big.md", content: "x".repeat(MAX_NOTE_BYTES) }, ctx));
|
|
354
|
+
assert.match(refusedBody.error, new RegExp(String(MAX_NOTE_BYTES)), "an over-cap body names the size cap");
|
|
355
|
+
await call(captured, "notes_write", { path: "small.md", content: "small" }, ctx);
|
|
356
|
+
const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
|
|
357
|
+
assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
|
|
358
|
+
});
|
|
359
|
+
test("fresh global and project MAP.md bodies are both resident before the pocket", async () => {
|
|
360
|
+
freshRoot();
|
|
361
|
+
const session = manager();
|
|
362
|
+
const captured = makeExtension(session);
|
|
363
|
+
const ctx = context(session);
|
|
364
|
+
await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
|
|
365
|
+
await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
|
|
366
|
+
await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global\nMAP: second" }, ctx);
|
|
367
|
+
await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
|
|
368
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
369
|
+
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
370
|
+
assert.ok(boot.includes("MAP: global"), "the global map is injected");
|
|
371
|
+
assert.ok(boot.includes("MAP: project"), "the project map is injected");
|
|
372
|
+
assert.equal(boot.includes("MAP: session"), false, "the session map is never injected");
|
|
373
|
+
assert.ok(boot.indexOf("MAP: global") < boot.indexOf("MAP: project"), "the global map precedes the project map");
|
|
374
|
+
assert.ok(boot.indexOf("MAP: project") < boot.indexOf("crumpled note"), "both map bodies precede the pocket");
|
|
375
|
+
assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
|
|
376
|
+
});
|
|
377
|
+
test("the boot pocket applies per-home quotas in session, project, global order", async () => {
|
|
378
|
+
freshRoot();
|
|
379
|
+
const session = manager();
|
|
380
|
+
const captured = makeExtension(session);
|
|
381
|
+
const ctx = context(session);
|
|
382
|
+
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
|
383
|
+
for (const [scope, count] of [["session", 6], ["project", 3], ["global", 3]]) {
|
|
384
|
+
for (let index = 0; index < count; index++) {
|
|
385
|
+
const path = `${scope}-${index}.md`;
|
|
386
|
+
await call(captured, "notes_write", { path, content: `${scope} body`, scope }, ctx);
|
|
387
|
+
setUpdatedAt(scope, path, ctx, base + index * 1_000);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
|
|
391
|
+
await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
|
|
392
|
+
await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global" }, ctx);
|
|
393
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
394
|
+
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
395
|
+
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 global). 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");
|
|
396
|
+
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", "@global/global-2.md", "@global/global-1.md"]) {
|
|
397
|
+
assert.ok(boot.includes(name), `${name} stays in the pocket`);
|
|
398
|
+
}
|
|
399
|
+
for (const name of ["session-0.md", "@project/project-0.md", "@global/global-0.md", "MAP.md", "MAP: session"]) {
|
|
400
|
+
assert.equal(boot.includes(name), false, `${name} is not a pocket entry`);
|
|
401
|
+
}
|
|
402
|
+
assert.ok(boot.indexOf("session-5.md") < boot.indexOf("session-4.md"), "session notes are most-recent-first");
|
|
403
|
+
assert.ok(boot.indexOf("@project/project-2.md") < boot.indexOf("@project/project-1.md"), "project notes are most-recent-first");
|
|
404
|
+
assert.ok(boot.indexOf("@global/global-2.md") < boot.indexOf("@global/global-1.md"), "global notes are most-recent-first");
|
|
405
|
+
assert.ok(boot.indexOf("session-1.md") < boot.indexOf("@project/project-2.md"), "session notes precede project notes");
|
|
406
|
+
assert.ok(boot.indexOf("@project/project-1.md") < boot.indexOf("@global/global-2.md"), "project notes precede global notes");
|
|
407
|
+
});
|
|
408
|
+
test("project scope keys off the git root basename and sha1 prefix", () => {
|
|
409
|
+
const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
|
|
410
|
+
process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
411
|
+
const session = manager();
|
|
412
|
+
const ctx = context(session, undefined, undefined, true, root);
|
|
413
|
+
const key = projectKey(root);
|
|
414
|
+
assert.match(key, /^pi-context-proj-[^-]+-[0-9a-f]{8}$/, "the project key is basename plus an 8-hex sha1 prefix");
|
|
415
|
+
assert.equal(scopeDir("project", ctx), join(process.env.PI_NOTES_HOME, "project", key));
|
|
416
|
+
});
|
|
417
|
+
test("@ addresses select one home, reject illegal sigils, and never fall back", async () => {
|
|
418
|
+
const root = freshRoot();
|
|
419
|
+
const session = manager();
|
|
420
|
+
const captured = makeExtension(session);
|
|
421
|
+
const ctx = context(session);
|
|
422
|
+
await call(captured, "notes_write", { address: "same.md", content: "session" }, ctx);
|
|
423
|
+
await call(captured, "notes_write", { address: "@project/same.md", content: "project" }, ctx);
|
|
424
|
+
await call(captured, "notes_write", { address: "@global/same.md", content: "global" }, ctx);
|
|
425
|
+
assert.ok(existsSync(physicalPath("project", "same.md", ctx)), "@project writes to the current project home");
|
|
426
|
+
assert.ok(existsSync(physicalPath("global", "same.md", ctx)), "@global writes to the global home");
|
|
427
|
+
assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
|
|
428
|
+
assert.equal(resultJson(await call(captured, "notes_read", { address: "@project/missing.md" }, ctx)).error, "note not found");
|
|
429
|
+
await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@global\/.*bare names are the session home/);
|
|
430
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@global\/.*bare names are the session home/);
|
|
431
|
+
assert.equal(existsSync(join(root, "global", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
|
|
432
|
+
});
|
|
433
|
+
test("full addresses drive outputs and patterns; legacy scope is read then dropped", async () => {
|
|
434
|
+
freshRoot();
|
|
435
|
+
const session = manager();
|
|
436
|
+
const captured = makeExtension(session);
|
|
437
|
+
const ctx = context(session);
|
|
438
|
+
await call(captured, "notes_write", { address: "root.md", content: "needle" }, ctx);
|
|
439
|
+
await call(captured, "notes_write", { address: "@project/project.md", content: "needle" }, ctx);
|
|
440
|
+
await call(captured, "notes_write", { address: "@global/global.md", content: "needle" }, ctx);
|
|
441
|
+
const list = resultJson(await call(captured, "notes_list", { pattern: "**" }, ctx));
|
|
442
|
+
assert.deepEqual(list.files.map((file) => file.address).sort(), ["@global/global.md", "@project/project.md", "root.md"]);
|
|
443
|
+
assert.deepEqual(resultJson(await call(captured, "notes_list", { pattern: "*.md" }, ctx)).files.map((file) => file.address), ["root.md"]);
|
|
444
|
+
assert.deepEqual(resultJson(await call(captured, "notes_search", { query: "needle", pattern: "@project/**" }, ctx)).files.map((file) => file.address), ["@project/project.md"]);
|
|
445
|
+
const read = resultRead(await call(captured, "notes_read", { address: "@global/global.md" }, ctx));
|
|
446
|
+
assert.match(read.header, /^\[@global\/global\.md /, "the raw read header echoes the full address");
|
|
447
|
+
const legacy = physicalPath("project", "legacy.md", ctx);
|
|
448
|
+
writeFileSync(legacy, "---\nscope: global\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\nlegacy");
|
|
449
|
+
const legacyRead = resultRead(await call(captured, "notes_read", { address: "@project/legacy.md" }, ctx));
|
|
450
|
+
assert.equal(legacyRead.details.scope, "project", "scope is derived from the file location");
|
|
451
|
+
await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
|
|
452
|
+
assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
|
|
453
|
+
});
|
|
454
|
+
test("stale project and global maps are skipped independently", async () => {
|
|
455
|
+
freshRoot();
|
|
456
|
+
const session = manager();
|
|
457
|
+
const captured = makeExtension(session);
|
|
458
|
+
const ctx = context(session);
|
|
459
|
+
await call(captured, "notes_write", { address: "@project/MAP.md", content: "project fresh" }, ctx);
|
|
460
|
+
await call(captured, "notes_write", { address: "@global/MAP.md", content: "global stale", stale: true }, ctx);
|
|
461
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
462
|
+
let boot = String(captured.sent.at(-1)?.message.content ?? "");
|
|
463
|
+
assert.ok(boot.includes("project fresh"), "a fresh project map survives a stale global map");
|
|
464
|
+
assert.equal(boot.includes("global stale"), false, "the stale global map is skipped");
|
|
465
|
+
const second = manager();
|
|
466
|
+
const secondCaptured = makeExtension(second);
|
|
467
|
+
const secondCtx = context(second);
|
|
468
|
+
await call(secondCaptured, "notes_edit", { address: "@project/MAP.md", stale: true }, secondCtx);
|
|
469
|
+
await call(secondCaptured, "notes_edit", { address: "@global/MAP.md", stale: false }, secondCtx);
|
|
470
|
+
runHandlers(secondCaptured, "session_start", {}, secondCtx);
|
|
471
|
+
boot = String(secondCaptured.sent.at(-1)?.message.content ?? "");
|
|
472
|
+
assert.equal(boot.includes("project fresh"), false, "the stale project map is skipped");
|
|
473
|
+
assert.ok(boot.includes("global stale"), "a fresh global map survives a stale project map");
|
|
474
|
+
});
|