@astrosheep/pi-context 0.26.1 → 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 +6 -9
- 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/protocol.d.ts +4 -4
- package/dist/src/protocol.js +4 -4
- package/dist/test/boot.integration.test.js +1 -1
- package/dist/test/doctor.test.js +4 -5
- package/dist/test/notes.integration.test.js +18 -0
- package/dist/test/notes.test.js +16 -7
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/src/context/prompts.ts +3 -3
- package/src/dream/doctor.ts +0 -3
- package/src/notes/frontmatter.ts +1 -5
- package/src/protocol.ts +4 -4
package/README.md
CHANGED
|
@@ -109,9 +109,9 @@ const matches = await notes.search(["library"]);
|
|
|
109
109
|
|
|
110
110
|
`list` and `search` share the `NotesQuery` type. A merged query uses `{ pattern? }`; a single-home query adds `scope`. Only `scope: "agent" | "model"` accepts `who`. TypeScript rejects combinations such as `{ scope: "project", who: "root" }`, and JavaScript callers receive a runtime refusal.
|
|
111
111
|
|
|
112
|
-
The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names.
|
|
112
|
+
The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names. Unrecognized frontmatter keys, including old snake_case metadata, are preserved as ordinary extras; they are not interpreted as current camelCase fields or migrated automatically. Invalid addresses/identities and filesystem failures reject; only a missing `read` returns `undefined`. Notes remain markdown files with the existing size limits and same-directory atomic rename. Same-file read/modify/write work is serialized by absolute physical filename across all store instances in this process (including `.md` address aliases); symlink/case aliases and cross-process locking are not guaranteed. `list` and `search` asynchronously traverse homes and serialize each discovered file read against pending mutations, but are not global snapshots and may not discover a file created after traversal. Foreign named homes can be read (including the access-metadata update), but their bodies cannot be written or edited through the store. These are cooperative address rules, not an OS security sandbox.
|
|
113
113
|
|
|
114
|
-
Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata;
|
|
114
|
+
Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata; old snake_case keys are preserved as unrecognized extras, not interpreted or migrated. New session notes record the supplied project key.
|
|
115
115
|
|
|
116
116
|
### Library and plugin boundary
|
|
117
117
|
|
package/dist/build-info.json
CHANGED
package/dist/extension.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// <define:__PI_CONTEXT_BUILD__>
|
|
2
|
-
var define_PI_CONTEXT_BUILD_default = { version: "0.26.
|
|
2
|
+
var define_PI_CONTEXT_BUILD_default = { version: "0.26.2", sourceHash: "b35cc5826ee3ac651b5a18c57833aab912a9ee999af2c2dfc7f8672b74e31f81" };
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
5
|
import { VERSION as VERSION2 } from "@earendil-works/pi-coding-agent";
|
|
@@ -249,10 +249,10 @@ var WARNING_TYPE = "pi-context/warning";
|
|
|
249
249
|
var RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
250
250
|
var CONTINUATION_TYPE = "pi-context/continuation";
|
|
251
251
|
var POCKET_SESSION_LIMIT = 5;
|
|
252
|
-
var POCKET_PROJECT_LIMIT =
|
|
253
|
-
var POCKET_HUMAN_LIMIT =
|
|
254
|
-
var POCKET_AGENT_LIMIT =
|
|
255
|
-
var POCKET_MODEL_LIMIT =
|
|
252
|
+
var POCKET_PROJECT_LIMIT = 5;
|
|
253
|
+
var POCKET_HUMAN_LIMIT = 5;
|
|
254
|
+
var POCKET_AGENT_LIMIT = 5;
|
|
255
|
+
var POCKET_MODEL_LIMIT = 3;
|
|
256
256
|
var CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
257
257
|
var CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
258
258
|
var CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
@@ -594,7 +594,6 @@ var SCOPES = ["session", "project", "human", "agent", "model"];
|
|
|
594
594
|
var ORIGINS = ["user", "self", "external"];
|
|
595
595
|
var STATUSES = ["active", "superseded", "pending", "archived"];
|
|
596
596
|
var TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
|
|
597
|
-
var LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
|
|
598
597
|
var KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
|
|
599
598
|
var pad2 = (value) => String(value).padStart(2, "0");
|
|
600
599
|
function localIso(epochMs) {
|
|
@@ -668,8 +667,6 @@ function parseFrontmatter(raw) {
|
|
|
668
667
|
}
|
|
669
668
|
function parseNote(raw, now = Date.now()) {
|
|
670
669
|
const { fields, body } = parseFrontmatter(raw);
|
|
671
|
-
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
672
|
-
if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
673
670
|
const meta = { ...fields };
|
|
674
671
|
meta.scope = isScope(meta.scope) ? meta.scope : "session";
|
|
675
672
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
@@ -1483,7 +1480,7 @@ function notesIndex(snapshot) {
|
|
|
1483
1480
|
if (recentNotes.length > 0) {
|
|
1484
1481
|
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:`];
|
|
1485
1482
|
for (const row of recentNotes) {
|
|
1486
|
-
lines.push(`- ${row.address}
|
|
1483
|
+
lines.push(`- ${row.address} \xB7 ${Array.from(row.body).length} chars \xB7 ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
|
|
1487
1484
|
}
|
|
1488
1485
|
sections.push(lines.join("\n"));
|
|
1489
1486
|
}
|
|
@@ -32,8 +32,8 @@ function notesUnavailableNotice(snapshot) {
|
|
|
32
32
|
* per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
|
|
33
33
|
* pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
|
|
34
34
|
* POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
|
|
35
|
-
* most-recently-updated first within each home, one metadata line each: address,
|
|
36
|
-
*
|
|
35
|
+
* most-recently-updated first within each home, one metadata line each: address, body character
|
|
36
|
+
* count, relative update time at window open. Bodies never render
|
|
37
37
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
38
38
|
*/
|
|
39
39
|
function notesIndex(snapshot) {
|
|
@@ -59,7 +59,7 @@ function notesIndex(snapshot) {
|
|
|
59
59
|
if (recentNotes.length > 0) {
|
|
60
60
|
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:`];
|
|
61
61
|
for (const row of recentNotes) {
|
|
62
|
-
lines.push(`- ${row.address}
|
|
62
|
+
lines.push(`- ${row.address} · ${Array.from(row.body).length} chars · ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
|
|
63
63
|
}
|
|
64
64
|
sections.push(lines.join("\n"));
|
|
65
65
|
}
|
package/dist/src/dream/doctor.js
CHANGED
|
@@ -41,10 +41,6 @@ export function doctor(home) {
|
|
|
41
41
|
if (!valid.test(fields.get(key) ?? ""))
|
|
42
42
|
report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
43
43
|
}
|
|
44
|
-
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"]]) {
|
|
45
|
-
if (fields.has(legacy))
|
|
46
|
-
report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
|
|
47
|
-
}
|
|
48
44
|
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
49
45
|
const value = fields.get(key);
|
|
50
46
|
if (!value || !Number.isFinite(Date.parse(value)))
|
|
@@ -33,8 +33,7 @@ export declare function isScope(value: unknown): value is Scope;
|
|
|
33
33
|
export declare function isOrigin(value: unknown): value is Origin;
|
|
34
34
|
/**
|
|
35
35
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
36
|
-
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
37
|
-
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
36
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
38
37
|
*/
|
|
39
38
|
export declare function parseNote(raw: string, now?: number): {
|
|
40
39
|
meta: NoteMeta;
|
|
@@ -2,7 +2,6 @@ const SCOPES = ["session", "project", "human", "agent", "model"];
|
|
|
2
2
|
const ORIGINS = ["user", "self", "external"];
|
|
3
3
|
const STATUSES = ["active", "superseded", "pending", "archived"];
|
|
4
4
|
const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
|
|
5
|
-
const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
|
|
6
5
|
/** Emission order, exactly the Design's key list. */
|
|
7
6
|
const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
|
|
8
7
|
const pad2 = (value) => String(value).padStart(2, "0");
|
|
@@ -98,14 +97,10 @@ function parseFrontmatter(raw) {
|
|
|
98
97
|
}
|
|
99
98
|
/**
|
|
100
99
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
101
|
-
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
102
|
-
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
100
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
103
101
|
*/
|
|
104
102
|
export function parseNote(raw, now = Date.now()) {
|
|
105
103
|
const { fields, body } = parseFrontmatter(raw);
|
|
106
|
-
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
107
|
-
if (legacyKeys.length > 0)
|
|
108
|
-
throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
109
104
|
const meta = { ...fields };
|
|
110
105
|
// scope is a legacy on-disk field: store callers derive it from the file's home and
|
|
111
106
|
// overwrite it after parsing, so an absent or outdated value just falls back.
|
package/dist/src/protocol.d.ts
CHANGED
|
@@ -6,10 +6,10 @@ export declare const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
6
6
|
export declare const CONTINUATION_TYPE = "pi-context/continuation";
|
|
7
7
|
export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
|
|
8
8
|
export declare const POCKET_SESSION_LIMIT = 5;
|
|
9
|
-
export declare const POCKET_PROJECT_LIMIT =
|
|
10
|
-
export declare const POCKET_HUMAN_LIMIT =
|
|
11
|
-
export declare const POCKET_AGENT_LIMIT =
|
|
12
|
-
export declare const POCKET_MODEL_LIMIT =
|
|
9
|
+
export declare const POCKET_PROJECT_LIMIT = 5;
|
|
10
|
+
export declare const POCKET_HUMAN_LIMIT = 5;
|
|
11
|
+
export declare const POCKET_AGENT_LIMIT = 5;
|
|
12
|
+
export declare const POCKET_MODEL_LIMIT = 3;
|
|
13
13
|
export declare const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
14
|
export declare const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
15
15
|
export declare const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
package/dist/src/protocol.js
CHANGED
|
@@ -6,10 +6,10 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
6
6
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
7
7
|
export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
|
|
8
8
|
export const POCKET_SESSION_LIMIT = 5;
|
|
9
|
-
export const POCKET_PROJECT_LIMIT =
|
|
10
|
-
export const POCKET_HUMAN_LIMIT =
|
|
11
|
-
export const POCKET_AGENT_LIMIT =
|
|
12
|
-
export const POCKET_MODEL_LIMIT =
|
|
9
|
+
export const POCKET_PROJECT_LIMIT = 5;
|
|
10
|
+
export const POCKET_HUMAN_LIMIT = 5;
|
|
11
|
+
export const POCKET_AGENT_LIMIT = 5;
|
|
12
|
+
export const POCKET_MODEL_LIMIT = 3;
|
|
13
13
|
export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
14
|
export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
15
15
|
export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
@@ -69,7 +69,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
|
|
|
69
69
|
assert.ok(rootText.includes("decisions.md"));
|
|
70
70
|
const decisionsMeta = (await listNotes(ctx, { scope: "session" })).find((row) => row.path === "decisions.md")?.meta;
|
|
71
71
|
assert.ok(decisionsMeta);
|
|
72
|
-
assert.match(rootText,
|
|
72
|
+
assert.match(rootText, /· \d+s ago/, "boot note metadata carries a relative update time");
|
|
73
73
|
assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
|
|
74
74
|
// Reset: the marker and boot are committed together at the turn boundary.
|
|
75
75
|
await call(captured, "wipe_memory", {}, ctx);
|
package/dist/test/doctor.test.js
CHANGED
|
@@ -6,13 +6,12 @@ import test from "node:test";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
const note = "---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\nlastAccessed: 2026-01-01T00:00:00Z\naccessCount: 0\n---\n\n";
|
|
9
|
-
test("doctor
|
|
10
|
-
const root = mkdtempSync(join(tmpdir(), "dream-doctor-
|
|
9
|
+
test("doctor ignores unknown frontmatter keys while validating canonical metadata", (t) => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "dream-doctor-extra-"));
|
|
11
11
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
12
12
|
mkdirSync(join(root, "human"));
|
|
13
|
-
writeFileSync(join(root, "human/
|
|
14
|
-
|
|
15
|
-
assert.ok(issues.some((issue) => issue.includes("legacy metadata key created_at; manually migrate to createdAt")));
|
|
13
|
+
writeFileSync(join(root, "human/extra.md"), note.replace("accessCount: 0", "accessCount: 0\ncreated_at: 2025-01-01\nupdated_at: 2025-01-01\nlast_accessed: 2025-01-01\naccess_count: 7\nsource_window: old\nrecurrence_count: 2\nrecurrence_windows: old"));
|
|
14
|
+
assert.deepEqual(doctor(root), [], "unknown fields have no special diagnostics");
|
|
16
15
|
});
|
|
17
16
|
test("doctor validates without repairing files or running the dreamer", async () => {
|
|
18
17
|
const root = mkdtempSync(join(tmpdir(), "dream-doctor-test-"));
|
|
@@ -133,6 +133,24 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
133
133
|
assert.ok(rendered.includes("PROJECT_MAP_BODY") && rendered.includes("AGENT_MAP_BODY"), "MAP residency comes from the snapshot");
|
|
134
134
|
assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
|
|
135
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/);
|
|
136
154
|
const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
|
|
137
155
|
const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
|
|
138
156
|
if (scope === "human")
|
package/dist/test/notes.test.js
CHANGED
|
@@ -142,7 +142,7 @@ test("linked git worktrees share the main checkout's project key", () => {
|
|
|
142
142
|
assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
|
|
143
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");
|
|
144
144
|
});
|
|
145
|
-
test("
|
|
145
|
+
test("unrecognized metadata remains ordinary frontmatter; invalid project ownership stays unknown", async () => {
|
|
146
146
|
freshRoot();
|
|
147
147
|
const session = manager();
|
|
148
148
|
const captured = makeExtension(session);
|
|
@@ -157,15 +157,24 @@ created_at: 2026-01-01T00:00:00.000+00:00
|
|
|
157
157
|
updated_at: 2026-01-01T00:00:00.000+00:00
|
|
158
158
|
last_accessed: 2026-01-01T00:00:00.000+00:00
|
|
159
159
|
access_count: 0
|
|
160
|
+
source_window: old-window
|
|
161
|
+
recurrence_count: 2
|
|
162
|
+
recurrence_windows: old-window
|
|
160
163
|
---
|
|
161
164
|
|
|
162
165
|
legacy body`);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
178
|
const invalidFile = physicalPath("session", "invalid.md", ctx);
|
|
170
179
|
writeFileSync(invalidFile, `---
|
|
171
180
|
origin: self
|
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/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.
|
package/src/protocol.ts
CHANGED
|
@@ -6,10 +6,10 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
6
6
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
7
7
|
export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
|
|
8
8
|
export const POCKET_SESSION_LIMIT = 5;
|
|
9
|
-
export const POCKET_PROJECT_LIMIT =
|
|
10
|
-
export const POCKET_HUMAN_LIMIT =
|
|
11
|
-
export const POCKET_AGENT_LIMIT =
|
|
12
|
-
export const POCKET_MODEL_LIMIT =
|
|
9
|
+
export const POCKET_PROJECT_LIMIT = 5;
|
|
10
|
+
export const POCKET_HUMAN_LIMIT = 5;
|
|
11
|
+
export const POCKET_AGENT_LIMIT = 5;
|
|
12
|
+
export const POCKET_MODEL_LIMIT = 3;
|
|
13
13
|
export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
14
|
export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
15
15
|
export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|