@astrosheep/pi-context 0.19.0 → 0.20.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 +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/memory/store.ts +15 -0
- package/src/memory/tools.ts +12 -3
- package/src/protocol.ts +2 -2
|
@@ -0,0 +1,370 @@
|
|
|
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/memory/paths.js";
|
|
15
|
+
import { listNotes } from "../src/memory/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-memory-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
|
+
test("exactly the five notes tools are registered; the legacy five are gone", () => {
|
|
25
|
+
const captured = makeExtension(manager());
|
|
26
|
+
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
|
|
27
|
+
assert.ok(captured.tools.get(name), `${name} is registered`);
|
|
28
|
+
}
|
|
29
|
+
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
30
|
+
assert.equal(captured.tools.get(legacy), undefined, `${legacy} is unregistered`);
|
|
31
|
+
}
|
|
32
|
+
assert.equal(captured.tools.get("notes_write")?.executionMode, "sequential");
|
|
33
|
+
assert.equal(captured.tools.get("notes_edit")?.executionMode, "sequential");
|
|
34
|
+
assert.equal(captured.tools.get("notes_read")?.executionMode, undefined);
|
|
35
|
+
});
|
|
36
|
+
test("write lands a real markdown file with harness frontmatter and a pure body", async () => {
|
|
37
|
+
const root = freshRoot();
|
|
38
|
+
const session = manager();
|
|
39
|
+
const captured = makeExtension(session);
|
|
40
|
+
const ctx = context(session);
|
|
41
|
+
const sessionId = session.getSessionId();
|
|
42
|
+
const result = resultJson(await call(captured, "notes_write", { path: "a/b.md", content: "hello" }, ctx));
|
|
43
|
+
assert.equal(result.scope, "session");
|
|
44
|
+
assert.equal(result.size_bytes, 5);
|
|
45
|
+
const file = physicalPath("session", "a/b.md", ctx);
|
|
46
|
+
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
47
|
+
assert.ok(existsSync(file), "the note is a real file under the session scope dir");
|
|
48
|
+
const raw = readFileSync(file, "utf8");
|
|
49
|
+
assert.match(raw, /^---\n/, "the file opens with frontmatter");
|
|
50
|
+
assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
|
|
51
|
+
for (const [key, value] of [["scope", "session"], ["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
|
|
52
|
+
assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
|
|
53
|
+
}
|
|
54
|
+
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
55
|
+
assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
|
|
56
|
+
}
|
|
57
|
+
assert.equal(typeof result.meta.created_at, "string", "wire meta renders timestamps as ISO strings");
|
|
58
|
+
// A leading YAML block in user content is stripped from the body.
|
|
59
|
+
await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: global\nnonsense: true\n---\nreal body" }, ctx);
|
|
60
|
+
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
61
|
+
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
62
|
+
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
63
|
+
});
|
|
64
|
+
test("overwrite preserves created_at and unknown keys, bumps updated_at, and clears stale", async () => {
|
|
65
|
+
freshRoot();
|
|
66
|
+
const session = manager();
|
|
67
|
+
const captured = makeExtension(session);
|
|
68
|
+
const ctx = context(session);
|
|
69
|
+
const file = physicalPath("session", "keep.md", ctx);
|
|
70
|
+
await call(captured, "notes_write", { path: "keep.md", content: "first", stale: true }, ctx);
|
|
71
|
+
const created = listNotes(ctx, { scope: "session" })[0].meta.created_at;
|
|
72
|
+
// Inject an unknown frontmatter key the way the sleep-shift layer will.
|
|
73
|
+
const raw = readFileSync(file, "utf8");
|
|
74
|
+
writeFileSync(file, raw.replace(/\n---\n\n/, "\nsleep_shift_key: \"keep-me\"\nrecurrence_count: 4\n---\n\n"));
|
|
75
|
+
const rewrite = resultJson(await call(captured, "notes_write", { path: "keep.md", content: "second" }, ctx));
|
|
76
|
+
const after = readFileSync(file, "utf8");
|
|
77
|
+
assert.equal(after.includes("sleep_shift_key: keep-me"), true, "an unknown key survives a rewrite");
|
|
78
|
+
assert.equal(after.includes("recurrence_count: 4"), true, "a known sleep-shift key survives a rewrite");
|
|
79
|
+
assert.match(after, /\n---\n\nsecond$/, "the body is replaced");
|
|
80
|
+
assert.equal(rewrite.meta.origin, "self");
|
|
81
|
+
const listed = listNotes(ctx, { scope: "session" })[0];
|
|
82
|
+
assert.equal(listed.meta.created_at, created, "created_at is preserved across an overwrite");
|
|
83
|
+
assert.ok(listed.meta.updated_at >= created, "updated_at is bumped");
|
|
84
|
+
assert.equal(listed.meta.stale, false, "a plain rewrite clears stale");
|
|
85
|
+
assert.equal(listed.meta.status, "active");
|
|
86
|
+
});
|
|
87
|
+
test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
|
|
88
|
+
freshRoot();
|
|
89
|
+
const session = manager();
|
|
90
|
+
const captured = makeExtension(session);
|
|
91
|
+
const ctx = context(session);
|
|
92
|
+
await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
|
|
93
|
+
const ambiguous = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
94
|
+
assert.match(ambiguous.error, /occurs 2 times/);
|
|
95
|
+
assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
|
|
96
|
+
const missing = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
|
|
97
|
+
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
98
|
+
const all = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
99
|
+
assert.equal(all.applied, 1);
|
|
100
|
+
assert.equal(all.resolved_scope, "session", "the success return names the layer the file was resolved from");
|
|
101
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
102
|
+
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
103
|
+
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
104
|
+
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
105
|
+
});
|
|
106
|
+
test("metadata-only edit updates setters without touching the body", async () => {
|
|
107
|
+
freshRoot();
|
|
108
|
+
const session = manager();
|
|
109
|
+
const captured = makeExtension(session);
|
|
110
|
+
const ctx = context(session);
|
|
111
|
+
await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
|
|
112
|
+
const bare = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
113
|
+
assert.equal(bare.applied, 0, "a metadata-only update applies no edits");
|
|
114
|
+
assert.equal(bare.resolved_scope, "session");
|
|
115
|
+
assert.equal(bare.meta.stale, true, "stale is set without a body edit");
|
|
116
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "the body is untouched");
|
|
117
|
+
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
118
|
+
assert.equal(revived.meta.stale, false, "a later metadata-only update revives the note");
|
|
119
|
+
});
|
|
120
|
+
test("notes_edit returns a pi-edit-style diff of what changed", async () => {
|
|
121
|
+
freshRoot();
|
|
122
|
+
const session = manager();
|
|
123
|
+
const captured = makeExtension(session);
|
|
124
|
+
const ctx = context(session);
|
|
125
|
+
await call(captured, "notes_write", { path: "d.md", content: "alpha\nbeta" }, ctx);
|
|
126
|
+
// Content edit → body diff.
|
|
127
|
+
const bodyEdit = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
128
|
+
assert.match(bodyEdit.diff, /- *\d+ beta/);
|
|
129
|
+
assert.match(bodyEdit.diff, /\+ *\d+ B/);
|
|
130
|
+
assert.equal(bodyEdit.diff.includes("scope:"), false, "a content-only diff does not drag frontmatter in");
|
|
131
|
+
// Metadata-only → frontmatter diff.
|
|
132
|
+
const metaOnly = resultJson(await call(captured, "notes_edit", { path: "d.md", stale: true }, ctx));
|
|
133
|
+
assert.equal(metaOnly.applied, 0);
|
|
134
|
+
assert.match(metaOnly.diff, /- *\d+ stale: false/);
|
|
135
|
+
assert.match(metaOnly.diff, /\+ *\d+ stale: true/);
|
|
136
|
+
assert.equal(metaOnly.diff.includes("alpha"), false, "a metadata-only diff does not drag the body in");
|
|
137
|
+
// Both → one combined diff naming body and frontmatter changes.
|
|
138
|
+
const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], scope: "project" }, ctx));
|
|
139
|
+
assert.match(combined.diff, /- *\d+ alpha/);
|
|
140
|
+
assert.match(combined.diff, /\+ *\d+ ALPHA/);
|
|
141
|
+
assert.match(combined.diff, /- *\d+ scope: session/);
|
|
142
|
+
assert.match(combined.diff, /\+ *\d+ scope: project/);
|
|
143
|
+
});
|
|
144
|
+
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
145
|
+
freshRoot();
|
|
146
|
+
const session = manager();
|
|
147
|
+
const captured = makeExtension(session);
|
|
148
|
+
const ctx = context(session);
|
|
149
|
+
const nameOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md" }, ctx));
|
|
150
|
+
assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
|
|
151
|
+
await call(captured, "notes_write", { path: "edit.md", content: "alpha\nbeta" }, ctx);
|
|
152
|
+
const empty = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [] }, ctx));
|
|
153
|
+
assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
|
|
154
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
155
|
+
assert.equal(editMissing.error, "note not found");
|
|
156
|
+
const readMissing = resultJson(await call(captured, "notes_read", { path: "missing.md" }, ctx));
|
|
157
|
+
assert.equal(readMissing.error, "note not found");
|
|
158
|
+
assert.equal(readMissing.path, "missing.md");
|
|
159
|
+
const file = physicalPath("session", "edit.md", ctx);
|
|
160
|
+
const before = readFileSync(file, "utf8");
|
|
161
|
+
const failed = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
|
|
162
|
+
assert.equal(failed.edit_index, 1, "the failing edit is named");
|
|
163
|
+
assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
|
|
164
|
+
const applied = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
|
|
165
|
+
assert.equal(applied.applied, 2);
|
|
166
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
|
|
167
|
+
const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
168
|
+
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
169
|
+
});
|
|
170
|
+
test("scope resolution, access counting, and movement with a typed refusal", async () => {
|
|
171
|
+
freshRoot();
|
|
172
|
+
const session = manager();
|
|
173
|
+
const captured = makeExtension(session);
|
|
174
|
+
const ctx = context(session);
|
|
175
|
+
await call(captured, "notes_write", { path: "shared.md", content: "global body", scope: "global" }, ctx);
|
|
176
|
+
await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
|
|
177
|
+
const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
|
|
178
|
+
assert.equal(first.details.scope, "session", "session wins the precedence over global");
|
|
179
|
+
const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
|
|
180
|
+
assert.ok(readAgain.content.includes("session body"));
|
|
181
|
+
const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
|
|
182
|
+
assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
|
|
183
|
+
const globalMeta = listNotes(ctx, { scope: "global" })[0].meta;
|
|
184
|
+
assert.equal(globalMeta.access_count, 0, "the global copy is untouched");
|
|
185
|
+
// Move the session copy to project; the global copy is untouched.
|
|
186
|
+
const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
|
|
187
|
+
assert.equal(moved.meta.scope, "project");
|
|
188
|
+
assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
|
|
189
|
+
assert.equal(existsSync(physicalPath("session", "shared.md", ctx)), false, "the source file moved away");
|
|
190
|
+
assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
|
|
191
|
+
// A move onto an existing target is refused and both files survive unchanged.
|
|
192
|
+
await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
|
|
193
|
+
await call(captured, "notes_write", { path: "clash.md", content: "global stay", scope: "global" }, ctx);
|
|
194
|
+
const beforeGlobal = readFileSync(physicalPath("global", "clash.md", ctx), "utf8");
|
|
195
|
+
const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "global" }, ctx));
|
|
196
|
+
assert.match(refusal.error, /already exists/);
|
|
197
|
+
assert.equal(readFileSync(physicalPath("global", "clash.md", ctx), "utf8"), beforeGlobal, "the target survives a refused move");
|
|
198
|
+
});
|
|
199
|
+
test("list and search merge scopes and carry scope; the path jail rejects escapes", async () => {
|
|
200
|
+
freshRoot();
|
|
201
|
+
const session = manager();
|
|
202
|
+
const captured = makeExtension(session);
|
|
203
|
+
const ctx = context(session);
|
|
204
|
+
await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
|
|
205
|
+
await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
|
|
206
|
+
await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "global" }, ctx);
|
|
207
|
+
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
208
|
+
assert.deepEqual([...listed.files].map((file) => file.scope).sort(), ["global", "project", "session"], "every merged row carries its scope");
|
|
209
|
+
for (const row of listed.files) {
|
|
210
|
+
assert.equal(typeof row.size_bytes, "number");
|
|
211
|
+
assert.equal(row.origin, "self");
|
|
212
|
+
assert.equal(row.status, "active");
|
|
213
|
+
assert.equal(row.stale, false);
|
|
214
|
+
}
|
|
215
|
+
const scoped = resultJson(await call(captured, "notes_list", { scope: "global" }, ctx));
|
|
216
|
+
assert.deepEqual(scoped.files.map((file) => file.path), ["three.md"], "a scope filter narrows the set");
|
|
217
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
218
|
+
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
219
|
+
assert.deepEqual([...searched.files].map((file) => file.scope).sort(), ["global", "project", "session"]);
|
|
220
|
+
assert.equal(searched.files.every((file) => file.matches_total === 1), true);
|
|
221
|
+
const hit = searched.files[0].matches[0];
|
|
222
|
+
assert.equal(hit.line, 1);
|
|
223
|
+
assert.equal(hit.offset_chars, 0);
|
|
224
|
+
assert.equal(hit.truncated, false);
|
|
225
|
+
assert.equal(hit.total_chars, searched.files[0].path === "three.md" ? "needle three".length : hit.text.length, "total_chars names the real line length");
|
|
226
|
+
const escaped = ["../evil", "/abs", "a\\b"];
|
|
227
|
+
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
228
|
+
for (const path of escaped) {
|
|
229
|
+
await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
|
233
|
+
await assert.rejects(() => call(captured, "notes_search", { query: "needle", pattern: "bad\\glob" }, ctx), /backslash/);
|
|
234
|
+
});
|
|
235
|
+
test("the boot index reads the physical store across scopes and excludes stale notes", async () => {
|
|
236
|
+
freshRoot();
|
|
237
|
+
const session = manager();
|
|
238
|
+
const captured = makeExtension(session);
|
|
239
|
+
const ctx = context(session);
|
|
240
|
+
await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
|
|
241
|
+
await call(captured, "notes_write", { path: "global.md", content: "global content", scope: "global" }, ctx);
|
|
242
|
+
await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
|
|
243
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
244
|
+
const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
|
|
245
|
+
assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
|
|
246
|
+
assert.ok(text.includes("global.md"), "a fresh global note is indexed");
|
|
247
|
+
assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
|
|
248
|
+
assert.equal(text.includes("stale content"), false, "a stale preview is not rendered");
|
|
249
|
+
for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
|
|
250
|
+
assert.ok(PROTOCOL_BLOCK.includes(name), `the protocol block names ${name}`);
|
|
251
|
+
}
|
|
252
|
+
for (const legacy of ["notes_write_file", "notes_append_to_file", "notes_read_file", "notes_search_contents", "notes_list_files"]) {
|
|
253
|
+
assert.equal(PROTOCOL_BLOCK.includes(legacy), false, `the protocol block no longer names ${legacy}`);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
test("notes_read surfaces frontmatter and search reports body lines", async () => {
|
|
257
|
+
freshRoot();
|
|
258
|
+
const session = manager();
|
|
259
|
+
const captured = makeExtension(session);
|
|
260
|
+
const ctx = context(session);
|
|
261
|
+
await call(captured, "notes_write", { path: "compose.md", content: "line one\nneedle address\nline three" }, ctx);
|
|
262
|
+
const read = resultRead(await call(captured, "notes_read", { path: "compose.md" }, ctx));
|
|
263
|
+
assert.match(read.content, /^---\n/, "the model sees the frontmatter first");
|
|
264
|
+
assert.ok(read.content.includes("needle address"));
|
|
265
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
266
|
+
const match = searched.files[0].matches[0];
|
|
267
|
+
assert.equal(match.line, 2, "search reports the body line number");
|
|
268
|
+
assert.equal(match.offset_chars, Array.from("line one\n").length, "offset_chars addresses the query within the body");
|
|
269
|
+
});
|
|
270
|
+
test("mutations are atomic, leave no temp files, and a read bumps only the access keys", async () => {
|
|
271
|
+
freshRoot();
|
|
272
|
+
const session = manager();
|
|
273
|
+
const captured = makeExtension(session);
|
|
274
|
+
const ctx = context(session);
|
|
275
|
+
await call(captured, "notes_write", { path: "atomic.md", content: "alpha\nbeta" }, ctx);
|
|
276
|
+
await call(captured, "notes_edit", { path: "atomic.md", edits: [{ oldText: "alpha", newText: "A" }] }, ctx);
|
|
277
|
+
await call(captured, "notes_read", { path: "atomic.md" }, ctx);
|
|
278
|
+
const first = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
279
|
+
assert.equal(first.includes("alpha"), false, "the edit landed");
|
|
280
|
+
const secondRead = resultRead(await call(captured, "notes_read", { path: "atomic.md" }, ctx));
|
|
281
|
+
assert.ok(secondRead.content.endsWith("A\nbeta"), "a second read still delivers the body");
|
|
282
|
+
const afterRead = readFileSync(physicalPath("session", "atomic.md", ctx), "utf8");
|
|
283
|
+
const lineOf = (text, key) => text.split("\n").find((line) => line.startsWith(`${key}:`));
|
|
284
|
+
for (const key of ["created_at", "updated_at", "origin", "status", "scope"]) {
|
|
285
|
+
assert.equal(lineOf(afterRead, key), lineOf(first, key), `${key} stays byte-stable across a read`);
|
|
286
|
+
}
|
|
287
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0].meta.access_count, 2, "two reads bump access_count twice");
|
|
288
|
+
// No torn-write temp files survive any mutation.
|
|
289
|
+
const temps = [];
|
|
290
|
+
const walk = (dir) => {
|
|
291
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
292
|
+
if (entry.isDirectory())
|
|
293
|
+
walk(join(dir, entry.name));
|
|
294
|
+
else if (entry.name.endsWith(".tmp"))
|
|
295
|
+
temps.push(join(dir, entry.name));
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
walk(process.env.PI_NOTES_HOME);
|
|
299
|
+
assert.deepEqual(temps, [], "tmp files are renamed away, never left behind");
|
|
300
|
+
});
|
|
301
|
+
test("write-time caps refuse an oversized vpath or serialized file, and edit refuses an oversized result", async () => {
|
|
302
|
+
freshRoot();
|
|
303
|
+
const session = manager();
|
|
304
|
+
const captured = makeExtension(session);
|
|
305
|
+
const ctx = context(session);
|
|
306
|
+
const seg = "b".repeat(120);
|
|
307
|
+
const okPath = [seg, seg, seg, seg, "x"].join("/");
|
|
308
|
+
const overPath = `${okPath}${seg}`;
|
|
309
|
+
assert.ok(Buffer.byteLength(okPath, "utf8") <= MAX_NOTE_PATH_BYTES);
|
|
310
|
+
assert.ok(Buffer.byteLength(overPath, "utf8") > MAX_NOTE_PATH_BYTES);
|
|
311
|
+
const refusedPath = resultJson(await call(captured, "notes_write", { path: overPath, content: "x" }, ctx));
|
|
312
|
+
assert.match(refusedPath.error, new RegExp(String(MAX_NOTE_PATH_BYTES)), "an over-cap vpath names the cap");
|
|
313
|
+
const accepted = resultJson(await call(captured, "notes_write", { path: okPath, content: "x" }, ctx));
|
|
314
|
+
assert.equal(accepted.path, okPath, "a path at the cap is accepted");
|
|
315
|
+
const refusedBody = resultJson(await call(captured, "notes_write", { path: "big.md", content: "x".repeat(MAX_NOTE_BYTES) }, ctx));
|
|
316
|
+
assert.match(refusedBody.error, new RegExp(String(MAX_NOTE_BYTES)), "an over-cap body names the size cap");
|
|
317
|
+
await call(captured, "notes_write", { path: "small.md", content: "small" }, ctx);
|
|
318
|
+
const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
|
|
319
|
+
assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
|
|
320
|
+
});
|
|
321
|
+
test("a global TOC.md body is injected ahead of the recent-notes list", async () => {
|
|
322
|
+
freshRoot();
|
|
323
|
+
const session = manager();
|
|
324
|
+
const captured = makeExtension(session);
|
|
325
|
+
const ctx = context(session);
|
|
326
|
+
await call(captured, "notes_write", { path: "TOC.md", content: "MAP: global\nMAP: second", scope: "global" }, ctx);
|
|
327
|
+
await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
|
|
328
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
329
|
+
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
330
|
+
assert.ok(boot.includes("MAP: global"), "a global TOC is injected");
|
|
331
|
+
assert.ok(boot.indexOf("MAP: global") < boot.indexOf("crumpled note"), "the TOC body precedes the recent-notes list");
|
|
332
|
+
assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
|
|
333
|
+
});
|
|
334
|
+
test("a session TOC.md wins precedence over the global map", async () => {
|
|
335
|
+
freshRoot();
|
|
336
|
+
const session = manager();
|
|
337
|
+
const captured = makeExtension(session);
|
|
338
|
+
const ctx = context(session);
|
|
339
|
+
await call(captured, "notes_write", { path: "TOC.md", content: "MAP: global", scope: "global" }, ctx);
|
|
340
|
+
await call(captured, "notes_write", { path: "TOC.md", content: "MAP: session", scope: "session" }, ctx);
|
|
341
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
342
|
+
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
343
|
+
const injected = boot.slice(0, boot.indexOf("crumpled note") === -1 ? boot.length : boot.indexOf("crumpled note"));
|
|
344
|
+
assert.ok(injected.includes("MAP: session"), "the session TOC wins the precedence");
|
|
345
|
+
assert.equal(injected.includes("MAP: global"), false, "only the first-hit TOC is injected");
|
|
346
|
+
});
|
|
347
|
+
test("the boot index admits up to five fresh notes and the protocol carries the exact stale line", async () => {
|
|
348
|
+
freshRoot();
|
|
349
|
+
const session = manager();
|
|
350
|
+
const captured = makeExtension(session);
|
|
351
|
+
const ctx = context(session);
|
|
352
|
+
for (let index = 0; index < 6; index++) {
|
|
353
|
+
await call(captured, "notes_write", { path: `fresh-${index}.md`, content: `body ${index}` }, ctx);
|
|
354
|
+
}
|
|
355
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
356
|
+
const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
|
|
357
|
+
assert.match(boot, /\(up to 5, most recent first\)/, "the pocket line says up to 5");
|
|
358
|
+
assert.equal((boot.match(/^- /gm) ?? []).length, 5, "exactly five fresh notes are indexed, not six");
|
|
359
|
+
assert.ok(PROTOCOL_BLOCK.includes("Mark outdated or unneeded notes stale — leave them, and they will keep misleading you."), "the protocol block carries the v2 stale line verbatim");
|
|
360
|
+
assert.equal(PROTOCOL_BLOCK.split("Mark outdated or unneeded notes stale — leave them, and they will keep misleading you.").length - 1, 1, "the stale line appears exactly once");
|
|
361
|
+
});
|
|
362
|
+
test("project scope keys off the git root basename and sha1 prefix", () => {
|
|
363
|
+
const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
|
|
364
|
+
process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
365
|
+
const session = manager();
|
|
366
|
+
const ctx = context(session, undefined, undefined, true, root);
|
|
367
|
+
const key = projectKey(root);
|
|
368
|
+
assert.match(key, /^pi-context-proj-[^-]+-[0-9a-f]{8}$/, "the project key is basename plus an 8-hex sha1 prefix");
|
|
369
|
+
assert.equal(scopeDir("project", ctx), join(process.env.PI_NOTES_HOME, "project", key));
|
|
370
|
+
});
|