@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 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. A note carrying a known legacy snake_case metadata key is refused with an explicit manual-migration-required error; import and construction never migrate note data. 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.
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; known legacy snake_case keys require the root-coordinated manual migration before use. New session notes record the supplied project key.
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
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.26.1",
3
- "sourceHash": "fb313022cd1aab93e422144e8f336eced42b67c4d35b89a1133be2254f066093"
2
+ "version": "0.26.2",
3
+ "sourceHash": "b35cc5826ee3ac651b5a18c57833aab912a9ee999af2c2dfc7f8672b74e31f81"
4
4
  }
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.1", sourceHash: "fb313022cd1aab93e422144e8f336eced42b67c4d35b89a1133be2254f066093" };
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 = 2;
253
- var POCKET_HUMAN_LIMIT = 2;
254
- var POCKET_AGENT_LIMIT = 1;
255
- var POCKET_MODEL_LIMIT = 1;
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} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
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, line count,
36
- * UTF-8 byte count, relative update time at window open. Bodies never render
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} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
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
  }
@@ -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. Known
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. Known
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.
@@ -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 = 2;
10
- export declare const POCKET_HUMAN_LIMIT = 2;
11
- export declare const POCKET_AGENT_LIMIT = 1;
12
- export declare const POCKET_MODEL_LIMIT = 1;
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>";
@@ -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 = 2;
10
- export const POCKET_HUMAN_LIMIT = 2;
11
- export const POCKET_AGENT_LIMIT = 1;
12
- export const POCKET_MODEL_LIMIT = 1;
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, /updated \d+s ago\)/, "boot note metadata carries a relative update time");
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);
@@ -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 identifies legacy metadata as requiring manual migration", (t) => {
10
- const root = mkdtempSync(join(tmpdir(), "dream-doctor-legacy-"));
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/legacy.md"), note.replace("createdAt", "created_at"));
14
- const issues = doctor(root);
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")
@@ -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("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
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
- assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
164
- const legacyBytes = readFileSync(legacyFile, "utf8");
165
- await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
166
- await assert.rejects(() => call(captured, "notes_read", { address: "legacy.md" }, ctx), /requires manual migration/);
167
- await assert.rejects(() => call(captured, "notes_write", { address: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
168
- assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
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
@@ -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, and a known legacy snake_case key refuses use until the root-coordinated manual migration; 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
4
4
  "type": "module",
5
5
  "main": "./dist/src/index.js",
6
6
  "types": "./dist/src/index.d.ts",
@@ -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, line count,
40
- * UTF-8 byte count, relative update time at window open. Bodies never render
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} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
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
  }
@@ -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`);
@@ -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. Known
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 = 2;
10
- export const POCKET_HUMAN_LIMIT = 2;
11
- export const POCKET_AGENT_LIMIT = 1;
12
- export const POCKET_MODEL_LIMIT = 1;
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>";