@astrosheep/pi-context 0.26.0 → 0.26.2
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 +2 -2
- package/dist/build-info.json +2 -2
- package/dist/extension.js +69 -64
- package/dist/src/context/budget.js +37 -18
- package/dist/src/context/prompts.js +3 -3
- package/dist/src/dream/doctor.js +0 -4
- package/dist/src/notes/frontmatter.d.ts +1 -2
- package/dist/src/notes/frontmatter.js +1 -6
- package/dist/src/notes/store.js +24 -40
- package/dist/src/protocol.d.ts +4 -4
- package/dist/src/protocol.js +4 -4
- package/dist/src/text-match.d.ts +5 -0
- package/dist/src/text-match.js +15 -0
- package/dist/src/tool-output.d.ts +1 -5
- package/dist/src/tool-output.js +1 -15
- package/dist/test/agent-loop.test.js +78 -10
- package/dist/test/boot.integration.test.js +5 -7
- package/dist/test/budget-settings.integration.test.js +20 -5
- package/dist/test/doctor.test.js +4 -5
- package/dist/test/helpers/extension-test-environment.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.js +9 -0
- package/dist/test/helpers/extension.d.ts +0 -11
- package/dist/test/helpers/extension.js +1 -75
- package/dist/test/history.integration.test.js +17 -19
- package/dist/test/notes-library.test.js +17 -0
- package/dist/test/notes.integration.test.js +47 -31
- package/dist/test/notes.test.js +51 -44
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/src/context/budget.ts +35 -18
- package/src/context/prompts.ts +3 -3
- package/src/dream/doctor.ts +0 -3
- package/src/notes/frontmatter.ts +1 -5
- package/src/notes/store.ts +24 -37
- package/src/protocol.ts +4 -4
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
|
@@ -7,11 +7,9 @@ import { renderBootBlock } from "../src/context/prompts.js";
|
|
|
7
7
|
import { localIso } from "../src/notes/frontmatter.js";
|
|
8
8
|
import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
|
|
9
9
|
import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
10
|
-
import { assertWithinBudget, call, context, explicitBoot,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
14
|
-
test.after(() => testEnvironment.dispose());
|
|
10
|
+
import { assertWithinBudget, call, context, explicitBoot, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
|
|
11
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
12
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
15
13
|
test("notes_list is most-recently-updated first across merged scopes", async () => {
|
|
16
14
|
const session = manager();
|
|
17
15
|
const captured = makeExtension(session);
|
|
@@ -37,47 +35,47 @@ test("notes are real files that persist across sessions and round-trip Unicode",
|
|
|
37
35
|
const original = manager();
|
|
38
36
|
const captured = makeExtension(original);
|
|
39
37
|
const ctx = context(original);
|
|
40
|
-
await call(captured, "notes_write", {
|
|
38
|
+
await call(captured, "notes_write", { address: "@human/checkpoint/进度.md", content: "第一行\nneedle Café" }, ctx);
|
|
41
39
|
// A brand-new session over the same physical root sees the human note: nothing is replayed
|
|
42
40
|
// from session entries, the file itself is the durable artifact.
|
|
43
41
|
const restored = manager();
|
|
44
42
|
const restoredCaptured = makeExtension(restored);
|
|
45
43
|
const restoredCtx = context(restored);
|
|
46
|
-
const rawRead = await call(restoredCaptured, "notes_read", {
|
|
44
|
+
const rawRead = await call(restoredCaptured, "notes_read", { address: "@human/checkpoint/进度.md", offset_chars: -4 }, restoredCtx);
|
|
47
45
|
const read = resultRead(rawRead);
|
|
48
46
|
assert.equal(read.details.address, "@human/checkpoint/进度.md");
|
|
49
47
|
assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
|
|
50
|
-
const searched = resultJson(await call(restoredCaptured, "notes_search", {
|
|
48
|
+
const searched = resultJson(await call(restoredCaptured, "notes_search", { pattern: "@human/**", query: "Café" }, restoredCtx));
|
|
51
49
|
assert.equal(searched.files[0]?.matches[0]?.line, 2);
|
|
52
|
-
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**"
|
|
50
|
+
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/checkpoint/**" }, restoredCtx));
|
|
53
51
|
assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
|
|
54
|
-
assert.equal(listedFiles.files[0]?.
|
|
52
|
+
assert.equal(listedFiles.files[0]?.address, "@human/checkpoint/进度.md");
|
|
55
53
|
// A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
|
|
56
|
-
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "
|
|
54
|
+
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "@human/*" }, restoredCtx));
|
|
57
55
|
assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
|
|
58
56
|
assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
|
|
59
|
-
await assert.rejects(() => call(captured, "notes_write", {
|
|
57
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "../escape", content: "x" }, ctx), /unsupported component/);
|
|
60
58
|
});
|
|
61
59
|
test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
|
|
62
60
|
const sm = manager();
|
|
63
61
|
const captured = makeExtension(sm);
|
|
64
62
|
const ctx = context(sm);
|
|
65
|
-
await call(captured, "notes_write", {
|
|
63
|
+
await call(captured, "notes_write", { address: "journal.md", content: "log line" }, ctx);
|
|
66
64
|
// metadata-only: content unchanged, flag set, applied 0
|
|
67
|
-
const markOnly = resultJson(await call(captured, "notes_edit", {
|
|
65
|
+
const markOnly = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: true }, ctx));
|
|
68
66
|
assert.equal(markOnly.applied, 0);
|
|
69
67
|
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
70
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
68
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
|
|
71
69
|
// explicit revive
|
|
72
|
-
const revived = resultJson(await call(captured, "notes_edit", {
|
|
70
|
+
const revived = resultJson(await call(captured, "notes_edit", { address: "journal.md", stale: false }, ctx));
|
|
73
71
|
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
|
|
74
72
|
// write+stale closure then plain write revival
|
|
75
|
-
await call(captured, "notes_write", {
|
|
73
|
+
await call(captured, "notes_write", { address: "journal.md", content: "final", stale: true }, ctx);
|
|
76
74
|
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
|
|
77
|
-
await call(captured, "notes_write", {
|
|
75
|
+
await call(captured, "notes_write", { address: "journal.md", content: "reopened" }, ctx);
|
|
78
76
|
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
|
|
79
77
|
// metadata-only on a missing path is the typed not-found arm
|
|
80
|
-
const missing = resultJson(await call(captured, "notes_edit", {
|
|
78
|
+
const missing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
|
|
81
79
|
assert.equal(missing.error, "note not found");
|
|
82
80
|
});
|
|
83
81
|
test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
|
|
@@ -135,6 +133,24 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
135
133
|
assert.ok(rendered.includes("PROJECT_MAP_BODY") && rendered.includes("AGENT_MAP_BODY"), "MAP residency comes from the snapshot");
|
|
136
134
|
assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
|
|
137
135
|
assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
|
|
136
|
+
assert.match(rendered, /- session\.md · 19 chars · \d+s ago/);
|
|
137
|
+
assert.equal(rendered.includes("UTF-8 bytes"), false, "pocket rows omit implementation-oriented byte counts");
|
|
138
|
+
const expanded = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
139
|
+
if (scope === "project" || scope === "human" || scope === "agent" || scope === "model") {
|
|
140
|
+
return Array.from({ length: 6 }, (_, i) => note(scope, `note-${i}.md`, `@${scope === "agent" ? "agents/root" : scope === "model" ? "models/default" : scope}/note-${i}.md`, `body ${i}`));
|
|
141
|
+
}
|
|
142
|
+
return rows.get(scope) ?? [];
|
|
143
|
+
});
|
|
144
|
+
const expandedText = renderBootBlock({ ...renderData, notes: expanded });
|
|
145
|
+
for (const prefix of ["@project", "@human", "@agents/root"]) {
|
|
146
|
+
for (let i = 0; i < 5; i++)
|
|
147
|
+
assert.ok(expandedText.includes(`- ${prefix}/note-${i}.md · `), `${prefix} includes note ${i}`);
|
|
148
|
+
assert.equal(expandedText.includes(`- ${prefix}/note-5.md · `), false, `${prefix} is capped at five`);
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < 3; i++)
|
|
151
|
+
assert.ok(expandedText.includes(`- @models/default/note-${i}.md · `), `@model includes note ${i}`);
|
|
152
|
+
assert.equal(expandedText.includes("- @models/default/note-3.md · "), false, "@model is capped at three");
|
|
153
|
+
assert.match(expandedText, /5 from @project, 5 from @human, 5 from @self, 3 from @model/);
|
|
138
154
|
const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
|
|
139
155
|
const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
140
156
|
if (scope === "human")
|
|
@@ -181,8 +197,8 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
181
197
|
const ctx = context(session);
|
|
182
198
|
const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
183
199
|
const text = `${huge}\ntail line`;
|
|
184
|
-
await call(captured, "notes_write", {
|
|
185
|
-
const rawFirst = await call(captured, "notes_read", {
|
|
200
|
+
await call(captured, "notes_write", { address: "huge.md", content: text }, ctx);
|
|
201
|
+
const rawFirst = await call(captured, "notes_read", { address: "huge.md" }, ctx);
|
|
186
202
|
assertWithinBudget(rawFirst, "single oversized note");
|
|
187
203
|
const first = resultRead(rawFirst);
|
|
188
204
|
assert.ok(first.content.length > 0, "the page is not empty");
|
|
@@ -196,7 +212,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
196
212
|
const parts = [first.content];
|
|
197
213
|
let offset = first.next_offset_chars;
|
|
198
214
|
while (offset !== null) {
|
|
199
|
-
const rawChunk = await call(captured, "notes_read", {
|
|
215
|
+
const rawChunk = await call(captured, "notes_read", { address: "huge.md", offset_chars: offset }, ctx);
|
|
200
216
|
assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
|
|
201
217
|
const chunk = resultRead(rawChunk);
|
|
202
218
|
assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
|
|
@@ -205,7 +221,7 @@ test("an over-budget note is delivered as a prefix and resumed by next_offset_ch
|
|
|
205
221
|
}
|
|
206
222
|
assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
|
|
207
223
|
// A success carries structured details; an error stays a JSON envelope with no details.
|
|
208
|
-
const missingResult = await call(captured, "notes_read", {
|
|
224
|
+
const missingResult = await call(captured, "notes_read", { address: "no-such.md" }, ctx);
|
|
209
225
|
const missing = resultJson(missingResult);
|
|
210
226
|
assert.deepEqual(Object.keys(missing).sort(), ["address", "error"], "the read error carries exactly error and address");
|
|
211
227
|
assert.equal(missing.error, "note not found");
|
|
@@ -217,8 +233,8 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
217
233
|
const ctx = context(session);
|
|
218
234
|
// The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
|
|
219
235
|
const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
220
|
-
await call(captured, "notes_write", {
|
|
221
|
-
await call(captured, "notes_write", {
|
|
236
|
+
await call(captured, "notes_write", { address: "a.md", content: "needle small" }, ctx);
|
|
237
|
+
await call(captured, "notes_write", { address: "search.md", content: hugeLine }, ctx);
|
|
222
238
|
const pages = [];
|
|
223
239
|
let cursor = 0;
|
|
224
240
|
let next = 0;
|
|
@@ -230,7 +246,7 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
230
246
|
if (next !== null)
|
|
231
247
|
cursor = next;
|
|
232
248
|
}
|
|
233
|
-
assert.deepEqual(pages.map((file) => file.
|
|
249
|
+
assert.deepEqual(pages.map((file) => file.address), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
|
|
234
250
|
const oversized = pages[1];
|
|
235
251
|
assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
|
|
236
252
|
assert.equal(oversized.matches.length, 1);
|
|
@@ -239,13 +255,13 @@ test("an over-budget note search match is a named prefix with an honest line add
|
|
|
239
255
|
assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
|
|
240
256
|
assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
|
|
241
257
|
assert.equal(match.line, 1, "the informational line number survives");
|
|
242
|
-
const atMatch = resultRead(await call(captured, "notes_read", {
|
|
258
|
+
const atMatch = resultRead(await call(captured, "notes_read", { address: "search.md", offset_chars: match.offset_chars }, ctx));
|
|
243
259
|
assert.ok(atMatch.content.startsWith("needle"), "the search offset starts a read at the matched substring");
|
|
244
260
|
// The body is reconstructible by following notes_read's cursor from the start of the file.
|
|
245
261
|
const parts = [];
|
|
246
262
|
let offset = 0;
|
|
247
263
|
while (offset !== null) {
|
|
248
|
-
const rawChunk = await call(captured, "notes_read", {
|
|
264
|
+
const rawChunk = await call(captured, "notes_read", { address: "search.md", offset_chars: offset }, ctx);
|
|
249
265
|
assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
|
|
250
266
|
const chunk = resultRead(rawChunk);
|
|
251
267
|
assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
|
|
@@ -258,10 +274,10 @@ test("notes_search scopes by glob pattern; a non-matching pattern is an empty pa
|
|
|
258
274
|
const session = manager();
|
|
259
275
|
const captured = makeExtension(session);
|
|
260
276
|
const ctx = context(session);
|
|
261
|
-
await call(captured, "notes_write", {
|
|
262
|
-
await call(captured, "notes_write", {
|
|
277
|
+
await call(captured, "notes_write", { address: "deep/nested/a.md", content: "needle here" }, ctx);
|
|
278
|
+
await call(captured, "notes_write", { address: "top.md", content: "needle there" }, ctx);
|
|
263
279
|
const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
|
|
264
|
-
assert.deepEqual(scoped.files.map((file) => file.
|
|
280
|
+
assert.deepEqual(scoped.files.map((file) => file.address), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
|
|
265
281
|
const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
|
|
266
282
|
assert.equal(none.error, undefined, "a non-matching pattern is not an error");
|
|
267
283
|
assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
|
package/dist/test/notes.test.js
CHANGED
|
@@ -14,11 +14,9 @@ import test from "node:test";
|
|
|
14
14
|
import { parseNote } from "../src/notes/frontmatter.js";
|
|
15
15
|
import { projectKey } from "../src/notes/paths.js";
|
|
16
16
|
import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
|
|
17
|
-
import { call, context, explicitBoot,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
21
|
-
test.after(() => testEnvironment.dispose());
|
|
17
|
+
import { call, context, explicitBoot, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
|
|
18
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
19
|
+
const testEnvironment = installExtensionTestHooks("pi-context-notes");
|
|
22
20
|
function freshRoot() {
|
|
23
21
|
return testEnvironment.newNotesRoot();
|
|
24
22
|
}
|
|
@@ -59,7 +57,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
59
57
|
const captured = makeExtension(session);
|
|
60
58
|
const ctx = context(session);
|
|
61
59
|
const sessionId = session.getSessionId();
|
|
62
|
-
const result = resultJson(await call(captured, "notes_write", {
|
|
60
|
+
const result = resultJson(await call(captured, "notes_write", { address: "a/b.md", content: "hello" }, ctx));
|
|
63
61
|
assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
|
|
64
62
|
const file = physicalPath("session", "a/b.md", ctx);
|
|
65
63
|
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
@@ -79,7 +77,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
79
77
|
assert.equal(result.written, true);
|
|
80
78
|
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
|
|
81
79
|
// A leading YAML block in user content is stripped from the body.
|
|
82
|
-
await call(captured, "notes_write", {
|
|
80
|
+
await call(captured, "notes_write", { address: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
|
|
83
81
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
84
82
|
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
85
83
|
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
@@ -96,33 +94,33 @@ test("session-note project ownership is per note, persistent across sessions, an
|
|
|
96
94
|
const firstSession = manager();
|
|
97
95
|
const firstCaptured = makeExtension(firstSession);
|
|
98
96
|
const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
|
|
99
|
-
await call(firstCaptured, "notes_write", {
|
|
97
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "first project session" }, firstCtx);
|
|
100
98
|
const firstFile = physicalPath("session", "first.md", firstCtx);
|
|
101
99
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
|
|
102
100
|
const secondSession = manager();
|
|
103
101
|
const secondCaptured = makeExtension(secondSession);
|
|
104
102
|
const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
|
|
105
|
-
await call(secondCaptured, "notes_write", {
|
|
103
|
+
await call(secondCaptured, "notes_write", { address: "second.md", content: "same project, another session" }, secondCtx);
|
|
106
104
|
const secondFile = physicalPath("session", "second.md", secondCtx);
|
|
107
105
|
assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
|
|
108
106
|
const thirdSession = manager();
|
|
109
107
|
const thirdCaptured = makeExtension(thirdSession);
|
|
110
108
|
const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
|
|
111
|
-
await call(thirdCaptured, "notes_write", {
|
|
109
|
+
await call(thirdCaptured, "notes_write", { address: "third.md", content: "different project" }, thirdCtx);
|
|
112
110
|
const thirdFile = physicalPath("session", "third.md", thirdCtx);
|
|
113
111
|
assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
|
|
114
112
|
const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
|
|
115
113
|
assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
|
|
116
114
|
const movedContext = context(firstSession, undefined, undefined, true, cwdB);
|
|
117
|
-
await call(firstCaptured, "notes_write", {
|
|
115
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "overwritten from another cwd" }, movedContext);
|
|
118
116
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
|
|
119
|
-
await call(firstCaptured, "notes_edit", {
|
|
117
|
+
await call(firstCaptured, "notes_edit", { address: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
|
|
120
118
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
|
|
121
|
-
await call(firstCaptured, "notes_read", {
|
|
119
|
+
await call(firstCaptured, "notes_read", { address: "first.md" }, movedContext);
|
|
122
120
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
|
|
123
|
-
await call(firstCaptured, "notes_write", {
|
|
121
|
+
await call(firstCaptured, "notes_write", { address: "new-from-project-b.md", content: "new note" }, movedContext);
|
|
124
122
|
assert.equal(parseNote(readFileSync(physicalPath("session", "new-from-project-b.md", movedContext), "utf8")).meta.project, projectB, "only a newly-created session note uses the current project key");
|
|
125
|
-
await call(firstCaptured, "notes_write", {
|
|
123
|
+
await call(firstCaptured, "notes_write", { address: "@project/project-note.md", content: "project home note" }, movedContext);
|
|
126
124
|
assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
|
|
127
125
|
assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
|
|
128
126
|
});
|
|
@@ -144,7 +142,7 @@ test("linked git worktrees share the main checkout's project key", () => {
|
|
|
144
142
|
assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
|
|
145
143
|
assert.equal(scopeDir("project", context(manager(), undefined, undefined, true, worktree)), scopeDir("project", context(manager(), undefined, undefined, true, main)), "@project uses the same physical home from both checkouts");
|
|
146
144
|
});
|
|
147
|
-
test("
|
|
145
|
+
test("unrecognized metadata remains ordinary frontmatter; invalid project ownership stays unknown", async () => {
|
|
148
146
|
freshRoot();
|
|
149
147
|
const session = manager();
|
|
150
148
|
const captured = makeExtension(session);
|
|
@@ -159,15 +157,24 @@ created_at: 2026-01-01T00:00:00.000+00:00
|
|
|
159
157
|
updated_at: 2026-01-01T00:00:00.000+00:00
|
|
160
158
|
last_accessed: 2026-01-01T00:00:00.000+00:00
|
|
161
159
|
access_count: 0
|
|
160
|
+
source_window: old-window
|
|
161
|
+
recurrence_count: 2
|
|
162
|
+
recurrence_windows: old-window
|
|
162
163
|
---
|
|
163
164
|
|
|
164
165
|
legacy body`);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
const parsed = parseNote(readFileSync(legacyFile, "utf8"), Date.parse("2026-02-01T00:00:00Z"));
|
|
167
|
+
assert.equal(parsed.meta.createdAt, Date.parse("2026-02-01T00:00:00Z"), "missing canonical timestamp takes the normal default");
|
|
168
|
+
assert.equal(parsed.meta.created_at, "2026-01-01T00:00:00.000+00:00", "unrecognized fields remain ordinary extras");
|
|
169
|
+
assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.address, "legacy.md");
|
|
170
|
+
assert.match(resultRead(await call(captured, "notes_read", { address: "legacy.md" }, ctx)).content, /legacy body$/);
|
|
171
|
+
await call(captured, "notes_edit", { address: "legacy.md", edits: [{ oldText: "legacy body", newText: "edited body" }] }, ctx);
|
|
172
|
+
await call(captured, "notes_write", { address: "legacy.md", content: "overwritten body" }, ctx);
|
|
173
|
+
const rewritten = parseNote(readFileSync(legacyFile, "utf8"));
|
|
174
|
+
assert.equal(rewritten.body, "overwritten body");
|
|
175
|
+
for (const key of ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"]) {
|
|
176
|
+
assert.deepEqual(rewritten.meta[key], parsed.meta[key], `${key} is preserved as unrecognized frontmatter, not migrated`);
|
|
177
|
+
}
|
|
171
178
|
const invalidFile = physicalPath("session", "invalid.md", ctx);
|
|
172
179
|
writeFileSync(invalidFile, `---
|
|
173
180
|
origin: self
|
|
@@ -183,7 +190,7 @@ project: 17
|
|
|
183
190
|
invalid owner`);
|
|
184
191
|
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
|
|
185
192
|
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", {
|
|
193
|
+
await call(captured, "notes_write", { address: "invalid.md", content: "still invalid" }, ctx);
|
|
187
194
|
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
|
|
188
195
|
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
|
|
189
196
|
});
|
|
@@ -192,19 +199,19 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
|
|
|
192
199
|
const session = manager();
|
|
193
200
|
const captured = makeExtension(session);
|
|
194
201
|
const ctx = context(session);
|
|
195
|
-
await call(captured, "notes_write", {
|
|
196
|
-
const ambiguous = resultJson(await call(captured, "notes_edit", {
|
|
202
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
|
|
203
|
+
const ambiguous = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
197
204
|
assert.match(ambiguous.error, /occurs 2 times/);
|
|
198
205
|
assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
|
|
199
|
-
const missing = resultJson(await call(captured, "notes_edit", {
|
|
206
|
+
const missing = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
|
|
200
207
|
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
201
|
-
const all = resultJson(await call(captured, "notes_edit", {
|
|
208
|
+
const all = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
202
209
|
assert.equal(all.applied, 1);
|
|
203
210
|
assert.equal(all.address, "edit.md");
|
|
204
211
|
assertNoPublicScope(all, "notes_edit");
|
|
205
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
212
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
206
213
|
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
207
|
-
const frontmatterOnly = resultJson(await call(captured, "notes_edit", {
|
|
214
|
+
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
208
215
|
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
209
216
|
});
|
|
210
217
|
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
@@ -212,25 +219,25 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
|
|
|
212
219
|
const session = manager();
|
|
213
220
|
const captured = makeExtension(session);
|
|
214
221
|
const ctx = context(session);
|
|
215
|
-
const nameOnly = resultJson(await call(captured, "notes_edit", {
|
|
222
|
+
const nameOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md" }, ctx));
|
|
216
223
|
assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
|
|
217
|
-
await call(captured, "notes_write", {
|
|
218
|
-
const empty = resultJson(await call(captured, "notes_edit", {
|
|
224
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta" }, ctx);
|
|
225
|
+
const empty = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [] }, ctx));
|
|
219
226
|
assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
|
|
220
|
-
const editMissing = resultJson(await call(captured, "notes_edit", {
|
|
227
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
|
|
221
228
|
assert.equal(editMissing.error, "note not found");
|
|
222
|
-
const readMissing = resultJson(await call(captured, "notes_read", {
|
|
229
|
+
const readMissing = resultJson(await call(captured, "notes_read", { address: "missing.md" }, ctx));
|
|
223
230
|
assert.equal(readMissing.error, "note not found");
|
|
224
|
-
assert.equal(readMissing.
|
|
231
|
+
assert.equal(readMissing.address, "missing.md");
|
|
225
232
|
const file = physicalPath("session", "edit.md", ctx);
|
|
226
233
|
const before = readFileSync(file, "utf8");
|
|
227
|
-
const failed = resultJson(await call(captured, "notes_edit", {
|
|
234
|
+
const failed = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
|
|
228
235
|
assert.equal(failed.edit_index, 1, "the failing edit is named");
|
|
229
236
|
assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
|
|
230
|
-
const applied = resultJson(await call(captured, "notes_edit", {
|
|
237
|
+
const applied = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
|
|
231
238
|
assert.equal(applied.applied, 2);
|
|
232
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
233
|
-
const zero = resultJson(await call(captured, "notes_edit", {
|
|
239
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
|
|
240
|
+
const zero = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
234
241
|
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
235
242
|
});
|
|
236
243
|
test("all notes tool results use address as the only home identity", async () => {
|
|
@@ -270,16 +277,16 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
270
277
|
const session = manager();
|
|
271
278
|
const captured = makeExtension(session);
|
|
272
279
|
const ctx = context(session);
|
|
273
|
-
await call(captured, "notes_write", {
|
|
274
|
-
await call(captured, "notes_write", {
|
|
275
|
-
await call(captured, "notes_write", {
|
|
280
|
+
await call(captured, "notes_write", { address: "one.md", content: "needle one" }, ctx);
|
|
281
|
+
await call(captured, "notes_write", { address: "@project/two.md", content: "needle two" }, ctx);
|
|
282
|
+
await call(captured, "notes_write", { address: "@human/three.md", content: "needle three" }, ctx);
|
|
276
283
|
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
277
284
|
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
|
|
278
285
|
for (const row of listed.files) {
|
|
279
286
|
assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
|
|
280
287
|
assert.equal(row.stale, false);
|
|
281
288
|
}
|
|
282
|
-
const scoped = resultJson(await call(captured, "notes_list", {
|
|
289
|
+
const scoped = resultJson(await call(captured, "notes_list", { pattern: "@human/**" }, ctx));
|
|
283
290
|
assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
|
|
284
291
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
285
292
|
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
@@ -295,7 +302,7 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
295
302
|
const escaped = ["../evil", "/abs", "a\\b"];
|
|
296
303
|
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
297
304
|
for (const path of escaped) {
|
|
298
|
-
await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
305
|
+
await assert.rejects(() => call(captured, tool, { address: path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
package/docs/architecture.md
CHANGED
|
@@ -43,7 +43,7 @@ Boot is a fixed snapshot for its window, stored as an extension custom message a
|
|
|
43
43
|
|
|
44
44
|
History reads reconstruct the selected session branch on demand without a cache, so branch navigation cannot expose history from a sibling.
|
|
45
45
|
|
|
46
|
-
The boot notes index in `pi/notes/snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. Note storage and home traversal use `node:fs/promises`; same-file operations queue by absolute physical filename across store instances in this process, without promising symlink/case-alias or cross-process locking. Known persisted metadata is camelCase
|
|
46
|
+
The boot notes index in `pi/notes/snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. Note storage and home traversal use `node:fs/promises`; same-file operations queue by absolute physical filename across store instances in this process, without promising symlink/case-alias or cross-process locking. Known persisted metadata is camelCase. Old snake_case keys are unrecognized extras, not interpreted or migrated; startup does not migrate notes. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. Boot/reset construction captures session, agent, and model identity before awaiting the snapshot, then checks lifecycle generation, active window, enabled state, and abort status before sending or returning artifacts; stale completions cannot commit into a switched or shut-down session. Note reads never mutate files or create fallback state.
|
|
47
47
|
|
|
48
48
|
`notes/session-replay.ts` accepts only supported operations, safe virtual paths, representable timestamps and results within the UTF-8 size limit. Invalid operations are ignored; they cannot replace a valid note. Notes remain in their filesystem-backed homes, unchanged by session branch navigation.
|
|
49
49
|
|
package/package.json
CHANGED
package/src/context/budget.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
|
|
4
4
|
import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } from "./thresholds.js";
|
|
5
|
-
import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
|
|
5
|
+
import { currentWindowId, hasWindowMessage, isWindowMarker, rootWindowId, windowUsage } from "./context-window.js";
|
|
6
6
|
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
7
|
import { output } from "../tool-output.js";
|
|
8
8
|
|
|
@@ -45,16 +45,30 @@ export function registerBudget(
|
|
|
45
45
|
const invalidateThresholds = () => { cachedPolicy = undefined; };
|
|
46
46
|
let pendingGuidance: { windowId: string; content: string; remaining: number } | undefined;
|
|
47
47
|
let pendingWarning: { windowId: string; content: string; remaining: number } | undefined;
|
|
48
|
-
let pendingNotices
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
let pendingNotices = new Map<string, { sessionId: string; windowId: string }>();
|
|
49
|
+
const noticeKey = (sessionId: string, windowId: string) => `${sessionId}:${windowId}`;
|
|
50
|
+
const warningCommittedInWindow = (ctx: ExtensionContext, notice: { sessionId: string; windowId: string }): boolean => {
|
|
51
|
+
if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
|
|
52
|
+
let windowId = rootWindowId(notice.sessionId);
|
|
53
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
54
|
+
if (isWindowMarker(entry)) {
|
|
55
|
+
windowId = entry.data.windowId;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
const notifyCommittedWarnings = (ctx: ExtensionContext, settled = false) => {
|
|
63
|
+
for (const [key, notice] of pendingNotices) {
|
|
64
|
+
if (warningCommittedInWindow(ctx, notice)) {
|
|
65
|
+
pendingNotices.delete(key);
|
|
66
|
+
ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
|
|
67
|
+
} else if (settled) {
|
|
68
|
+
// An uncommitted draft must not be matched to a later manual warning.
|
|
69
|
+
pendingNotices.delete(key);
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
|
-
pendingNotices = [];
|
|
58
72
|
};
|
|
59
73
|
|
|
60
74
|
const clearStaged = () => {
|
|
@@ -63,7 +77,7 @@ export function registerBudget(
|
|
|
63
77
|
};
|
|
64
78
|
const resetForTransition = () => {
|
|
65
79
|
clearStaged();
|
|
66
|
-
pendingNotices
|
|
80
|
+
pendingNotices.clear();
|
|
67
81
|
invalidateThresholds();
|
|
68
82
|
notifiedWarnings.clear();
|
|
69
83
|
};
|
|
@@ -76,7 +90,10 @@ export function registerBudget(
|
|
|
76
90
|
clearStaged();
|
|
77
91
|
const windowId = currentWindowId(ctx);
|
|
78
92
|
const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
|
|
79
|
-
|
|
93
|
+
if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
|
|
94
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
95
|
+
pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
|
|
96
|
+
}
|
|
80
97
|
return drafts.map((draft) => ({
|
|
81
98
|
type: "custom_message" as const,
|
|
82
99
|
customType: draft.customType,
|
|
@@ -89,12 +106,12 @@ export function registerBudget(
|
|
|
89
106
|
pi.on("session_tree", resetForTransition);
|
|
90
107
|
pi.on("model_select", resetForTransition);
|
|
91
108
|
pi.on("session_shutdown", resetForTransition);
|
|
92
|
-
// A
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
109
|
+
// A warning can be committed at turn_end, before a tool turn or a reset changes the
|
|
110
|
+
// active window. Observe the active branch at public lifecycle boundaries and match
|
|
111
|
+
// the candidate against its originating window segment, not only the current window.
|
|
112
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
|
|
96
113
|
pi.on("agent_settled", (_event, ctx) => {
|
|
97
|
-
|
|
114
|
+
notifyCommittedWarnings(ctx, true);
|
|
98
115
|
clearStaged();
|
|
99
116
|
});
|
|
100
117
|
pi.on("context", (_event, ctx) => {
|
|
@@ -151,6 +168,6 @@ export function registerBudget(
|
|
|
151
168
|
automaticResetEnabled,
|
|
152
169
|
hardReserveDue,
|
|
153
170
|
consumeTurnEnd,
|
|
154
|
-
clear: () => { clearStaged(); pendingNotices
|
|
171
|
+
clear: () => { clearStaged(); pendingNotices.clear(); },
|
|
155
172
|
};
|
|
156
173
|
}
|
package/src/context/prompts.ts
CHANGED
|
@@ -36,8 +36,8 @@ function notesUnavailableNotice(snapshot: NotesSnapshot): string | undefined {
|
|
|
36
36
|
* per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
|
|
37
37
|
* pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
|
|
38
38
|
* POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
|
|
39
|
-
* most-recently-updated first within each home, one metadata line each: address,
|
|
40
|
-
*
|
|
39
|
+
* most-recently-updated first within each home, one metadata line each: address, body character
|
|
40
|
+
* count, relative update time at window open. Bodies never render
|
|
41
41
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
42
42
|
*/
|
|
43
43
|
function notesIndex(snapshot: NotesSnapshot): string {
|
|
@@ -62,7 +62,7 @@ function notesIndex(snapshot: NotesSnapshot): string {
|
|
|
62
62
|
if (recentNotes.length > 0) {
|
|
63
63
|
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
|
|
64
64
|
for (const row of recentNotes) {
|
|
65
|
-
lines.push(`- ${row.address}
|
|
65
|
+
lines.push(`- ${row.address} · ${Array.from(row.body).length} chars · ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
|
|
66
66
|
}
|
|
67
67
|
sections.push(lines.join("\n"));
|
|
68
68
|
}
|
package/src/dream/doctor.ts
CHANGED
|
@@ -30,9 +30,6 @@ export function doctor(home: string): string[] {
|
|
|
30
30
|
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
|
|
31
31
|
if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
32
32
|
}
|
|
33
|
-
for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
|
|
34
|
-
if (fields.has(legacy)) report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
|
|
35
|
-
}
|
|
36
33
|
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
37
34
|
const value = fields.get(key);
|
|
38
35
|
if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
package/src/notes/frontmatter.ts
CHANGED
|
@@ -30,7 +30,6 @@ const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model
|
|
|
30
30
|
const ORIGINS: readonly Origin[] = ["user", "self", "external"];
|
|
31
31
|
const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
|
|
32
32
|
const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"] as const;
|
|
33
|
-
const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"] as const;
|
|
34
33
|
/** Emission order, exactly the Design's key list. */
|
|
35
34
|
const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"] as const;
|
|
36
35
|
|
|
@@ -125,13 +124,10 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
|
|
|
125
124
|
|
|
126
125
|
/**
|
|
127
126
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
128
|
-
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
129
|
-
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
127
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
130
128
|
*/
|
|
131
129
|
export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
|
|
132
130
|
const { fields, body } = parseFrontmatter(raw);
|
|
133
|
-
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
134
|
-
if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
135
131
|
const meta = { ...fields } as Record<string, unknown>;
|
|
136
132
|
// scope is a legacy on-disk field: store callers derive it from the file's home and
|
|
137
133
|
// overwrite it after parsing, so an absent or outdated value just falls back.
|