@astrosheep/pi-context 0.25.2 → 0.26.1

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.
Files changed (120) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +635 -381
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +51 -28
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +276 -236
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/text-match.d.ts +5 -0
  60. package/dist/src/text-match.js +15 -0
  61. package/dist/src/tool-output.d.ts +97 -0
  62. package/dist/src/tool-output.js +1 -15
  63. package/dist/src/tool-schema.d.ts +17 -0
  64. package/dist/test/agent-loop.test.d.ts +1 -0
  65. package/dist/test/agent-loop.test.js +387 -20
  66. package/dist/test/boot.integration.test.d.ts +1 -0
  67. package/dist/test/boot.integration.test.js +59 -35
  68. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  69. package/dist/test/budget-settings.integration.test.js +28 -12
  70. package/dist/test/doctor.test.d.ts +1 -0
  71. package/dist/test/doctor.test.js +10 -2
  72. package/dist/test/dream-skill.test.d.ts +1 -0
  73. package/dist/test/dream-skill.test.js +69 -0
  74. package/dist/test/dream.test.d.ts +1 -0
  75. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  76. package/dist/test/helpers/extension-test-environment.js +9 -0
  77. package/dist/test/helpers/extension.d.ts +104 -0
  78. package/dist/test/helpers/extension.js +7 -81
  79. package/dist/test/helpers/notes.d.ts +6 -0
  80. package/dist/test/helpers/notes.js +13 -0
  81. package/dist/test/history.integration.test.d.ts +1 -0
  82. package/dist/test/history.integration.test.js +17 -19
  83. package/dist/test/notes-library.test.d.ts +1 -0
  84. package/dist/test/notes-library.test.js +128 -0
  85. package/dist/test/notes.integration.test.d.ts +1 -0
  86. package/dist/test/notes.integration.test.js +51 -55
  87. package/dist/test/notes.test.d.ts +1 -0
  88. package/dist/test/notes.test.js +162 -34
  89. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  90. package/dist/test/reset-lifecycle.test.js +142 -85
  91. package/docs/architecture.md +8 -8
  92. package/docs/reset-lifecycle.md +63 -79
  93. package/package.json +35 -2
  94. package/playbook.md +33 -32
  95. package/skills/dream/SKILL.md +12 -0
  96. package/src/context/boot.ts +44 -25
  97. package/src/context/budget.ts +52 -27
  98. package/src/context/context-window.ts +16 -1
  99. package/src/context/prompts.ts +2 -2
  100. package/src/context/reset-artifacts.ts +26 -24
  101. package/src/context/reset-lifecycle.ts +117 -111
  102. package/src/context/runtime.ts +50 -22
  103. package/src/context/thresholds.ts +1 -1
  104. package/src/dream/cli.ts +1 -1
  105. package/src/dream/doctor.ts +5 -2
  106. package/src/index.ts +4 -4
  107. package/src/notes/address.ts +9 -15
  108. package/src/notes/constants.ts +3 -0
  109. package/src/notes/context.ts +40 -0
  110. package/src/notes/frontmatter.ts +18 -12
  111. package/src/notes/index.ts +22 -0
  112. package/src/notes/paths.ts +64 -78
  113. package/src/notes/store.ts +289 -238
  114. package/src/pi/notes/adapter.ts +44 -0
  115. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  116. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  117. package/src/{notes → pi/notes}/tools.ts +25 -23
  118. package/src/protocol.ts +5 -6
  119. package/src/text-match.ts +13 -0
  120. package/src/tool-output.ts +2 -14
@@ -2,10 +2,11 @@ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
2
  import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
3
3
  import { registerBudget } from "./budget.js";
4
4
  import { output } from "../tool-output.js";
5
- import { migrateLegacyHomes } from "../notes/paths.js";
6
- import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
5
+ import { migrateLegacyHomes } from "../pi/notes/adapter.js";
6
+ import { currentReset, currentWindowId, isCheckpointBackedReset, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId, type WindowMarker } from "./context-window.js";
7
7
  import { registerResetLifecycle } from "./reset-lifecycle.js";
8
- import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
8
+ import { buildResetDrafts, resetTailCommitted } from "./reset-artifacts.js";
9
+ import { BOOT_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
9
10
  import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
10
11
 
11
12
  declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
@@ -22,6 +23,7 @@ function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean
22
23
  /** Register the context-window runtime and its context-owned commands/tools. */
23
24
  export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
24
25
  let enabled = true;
26
+ let lifecycleGeneration = 0;
25
27
  let missingBootNotice: string | undefined;
26
28
  const incompleteNotesNotified = new Set<string>();
27
29
  const pendingResetNotices = new Set<string>();
@@ -32,8 +34,8 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
32
34
  if (pendingResetNotices.size === 0) return;
33
35
  const branch = ctx.sessionManager.getBranch();
34
36
  for (const windowId of pendingResetNotices) {
35
- const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
36
- if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
37
+ const marker = branch.find((entry): entry is WindowMarker => isWindowMarker(entry) && entry.data.windowId === windowId);
38
+ if (!marker || !isCheckpointBackedReset(ctx, marker) || !resetTailCommitted(ctx, marker.id, windowId)) continue;
37
39
  pendingResetNotices.delete(windowId);
38
40
  ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
39
41
  }
@@ -52,18 +54,24 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
52
54
  const migrationWarning = migrateLegacyHomes();
53
55
  if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
54
56
 
55
- const budget = registerBudget(pi, () => enabled, settingsManager);
57
+ const budget = registerBudget(pi, () => enabled, settingsManager, (windowId) => resets.closeOut(windowId, "automatic"));
56
58
 
57
- pi.on("session_start", (_event, ctx) => {
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ const generation = ++lifecycleGeneration;
58
61
  if (!enabled) return;
59
62
  missingBootNotice = undefined;
60
63
  pendingResetNotices.clear();
61
- ensureBoot(pi, ctx, notifyIncompleteNotes);
64
+ await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
62
65
  });
63
- pi.on("session_tree", (_event, ctx) => {
66
+ pi.on("session_tree", async (_event, ctx) => {
67
+ const generation = ++lifecycleGeneration;
64
68
  missingBootNotice = undefined;
65
69
  pendingResetNotices.clear();
66
- if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
70
+ if (enabled) await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
71
+ });
72
+ pi.on("session_shutdown", () => {
73
+ lifecycleGeneration++;
74
+ pendingResetNotices.clear();
67
75
  });
68
76
 
69
77
  // Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
@@ -81,7 +89,11 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
81
89
  const reset = currentReset(ctx);
82
90
  const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
83
91
  try {
84
- return { messages: reset ? projectWindow(event.messages, windowId) : projectRootWindow(event.messages, windowId) };
92
+ if (reset) {
93
+ if (!event.messages.some((message) => isWindowBoot(message, windowId))) throw new Error(`Missing boot for context window ${windowId}`);
94
+ return { messages: isCheckpointBackedReset(ctx, reset) ? event.messages : projectWindow(event.messages, windowId) };
95
+ }
96
+ return { messages: projectRootWindow(event.messages, windowId) };
85
97
  } catch (error) {
86
98
  if (missingBootNotice !== windowId) {
87
99
  missingBootNotice = windowId;
@@ -101,9 +113,12 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
101
113
  const arg = args.trim().toLowerCase();
102
114
  if (arg === "on") {
103
115
  enabled = true;
104
- ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
116
+ const generation = ++lifecycleGeneration;
117
+ await ensureBoot(pi, cmdCtx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
105
118
  } else if (arg === "off") {
106
119
  enabled = false;
120
+ lifecycleGeneration++;
121
+ pendingResetNotices.clear();
107
122
  budget.clear();
108
123
  resets.clear();
109
124
  } else if (arg !== "") {
@@ -115,16 +130,25 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
115
130
  });
116
131
 
117
132
  pi.registerCommand("wipe-memory", {
118
- description: "Persist a fresh context window without calling the model",
133
+ description: "Ask the agent to close out its notes, then start a fresh context window",
119
134
  handler: async (_args, cmdCtx) => {
120
135
  if (!enabled) {
121
136
  cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
122
137
  return;
123
138
  }
139
+ const requestedWindowId = currentWindowId(cmdCtx);
140
+ await cmdCtx.waitForIdle();
141
+ if (!enabled || currentWindowId(cmdCtx) !== requestedWindowId) return;
142
+ const armed = resets.closeOut(requestedWindowId, "manual");
143
+ if (armed === "already-pending") return;
144
+ try {
145
+ pi.sendMessage({ customType: WARNING_TYPE, content: WARNING_CONTENT, display: false }, { triggerTurn: true });
146
+ } catch (error) {
147
+ resets.clear();
148
+ cmdCtx.ui.notify(`pi-context: could not start manual close-out (${String(error)}).`, "error");
149
+ return;
150
+ }
124
151
  await cmdCtx.waitForIdle();
125
- if (!enabled) return;
126
- resets.clear();
127
- notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
128
152
  },
129
153
  });
130
154
 
@@ -133,18 +157,22 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
133
157
  label: "Wipe memory",
134
158
  description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
135
159
  parameters: Type.Object({}, { additionalProperties: false }),
136
- async execute() {
160
+ async execute(_id, _params, _signal, _update, ctx) {
137
161
  if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
138
- return output({ status: resets.request() }, undefined, true);
162
+ return output({ status: resets.request(currentWindowId(ctx)) }, undefined, true);
139
163
  },
140
164
  }));
141
165
 
142
166
  const resets = registerResetLifecycle(pi, {
143
167
  isEnabled: () => enabled,
144
- buildReset: (ctx) => {
145
- const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
146
- pendingResetNotices.add(drafts[1].details.windowId);
147
- return drafts;
168
+ buildReset: (ctx, isCurrent) => buildResetDrafts(ctx, notifyIncompleteNotes, isCurrent),
169
+ getLifecycleGeneration: () => lifecycleGeneration,
170
+ onResetReady: (_ctx, drafts) => {
171
+ const boot = drafts.find((draft) => draft.type === "custom_message" && draft.customType === BOOT_TYPE);
172
+ if (boot?.type === "custom_message" && boot.details && typeof boot.details === "object") {
173
+ const windowId = (boot.details as { windowId?: unknown }).windowId;
174
+ if (typeof windowId === "string") pendingResetNotices.add(windowId);
175
+ }
148
176
  },
149
177
  budget,
150
178
  });
@@ -24,7 +24,7 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextSettin
24
24
  else {
25
25
  const parsed = validMargin(margins.reminderMarginTokens);
26
26
  if (parsed === undefined) {
27
- warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
27
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using the default reminder margin.`);
28
28
  reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
29
29
  } else reminderMargin = parsed;
30
30
  }
package/src/dream/cli.ts CHANGED
@@ -8,7 +8,7 @@ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult,
8
8
  import { gitCommit } from "./git.js";
9
9
  import { readDreamerSettings, type DreamerSetting } from "./settings.js";
10
10
  import { doctor } from "./doctor.js";
11
- import { notesRoot } from "../notes/paths.js";
11
+ import { notesRoot } from "../pi/notes/adapter.js";
12
12
 
13
13
  function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
14
14
  function packageRoot(): string {
@@ -27,10 +27,13 @@ export function doctor(home: string): string[] {
27
27
  if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
28
28
  fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
29
29
  }
30
- for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
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 key of ["created_at", "updated_at", "last_accessed"]) {
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
+ for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
34
37
  const value = fields.get(key);
35
38
  if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
36
39
  }
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { VERSION, type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import { registerHistoryTools } from "./history/history-tools.js";
3
- import { registerNotesTools } from "./notes/tools.js";
3
+ import { registerNotesTools } from "./pi/notes/tools.js";
4
4
  import { deriveThresholds } from "./context/thresholds.js";
5
5
  import { registerContext } from "./context/runtime.js";
6
6
  import { mergePiContextSettings } from "./settings.js";
7
- import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
+ import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, CONTINUATION, WARNING_PROMPT, WARNING_CONTENT } from "./protocol.js";
8
8
  import { assertVirtualPath } from "./notes/address.js";
9
9
  export { historyFromSession } from "./history/history.js";
10
- export { notesFromSession } from "./notes/session-replay.js";
10
+ export { notesFromSession } from "./pi/notes/session-replay.js";
11
11
 
12
12
  function registerPiContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
13
13
  const [major, minor] = VERSION.split(".").map(Number);
@@ -32,4 +32,4 @@ export default function piContext(pi: ExtensionAPI): void {
32
32
  registerPiContext(pi);
33
33
  }
34
34
 
35
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
35
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_CONTENT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
@@ -1,5 +1,5 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
1
+ import type { NotesContext } from "./context.js";
2
+ import { SLUG_PATTERN, type Scope } from "./paths.js";
3
3
 
4
4
  export type NoteAddress = { scope: Scope; path: string; who?: string };
5
5
 
@@ -39,7 +39,7 @@ export function globToRegExp(pattern: string): RegExp {
39
39
  return new RegExp(`${source}$`);
40
40
  }
41
41
 
42
- /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
42
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard. */
43
43
  export function assertGlobPattern(value: unknown): string | undefined {
44
44
  if (value === undefined || value === null || value === "") return undefined;
45
45
  if (typeof value !== "string") throw new Error("glob pattern must be a string");
@@ -48,12 +48,8 @@ export function assertGlobPattern(value: unknown): string | undefined {
48
48
  }
49
49
 
50
50
  /**
51
- * Decode the one public note address into its physical home and virtual path. This is a
52
- * tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
53
- * The word after `@` is always a reserved home name; agent and model names live at the
54
- * second level (@agents/faye/, never @faye/), so user-chosen names can never collide with
55
- * the reserved set. `@self` and `@model` are relative — `who` stays undefined and the
56
- * store resolves the current agent/model at call time.
51
+ * Decode one public note address into its physical home and virtual path. @self and @model
52
+ * are relative; explicit agents/models addresses always name a canonical slug.
57
53
  */
58
54
  export function assertAddress(value: unknown): NoteAddress {
59
55
  if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
@@ -87,13 +83,11 @@ export function assertAddress(value: unknown): NoteAddress {
87
83
  return { scope, path, who };
88
84
  }
89
85
 
90
- /** Render a virtual path in its one unambiguous public address form. Relative forms
91
- * (@self/, @model/) never render: the canonical address always carries the resolved
92
- * name, so listings alone tell every home apart. */
93
- export function addressFor(ctx: ExtensionContext, scope: Scope, path: string, who?: string): string {
86
+ /** Render a virtual path in its one unambiguous public address form. */
87
+ export function addressFor(context: NotesContext, scope: Scope, path: string, who?: string): string {
94
88
  if (scope === "session") return path;
95
89
  if (scope === "project") return `@project/${path}`;
96
90
  if (scope === "human") return `@human/${path}`;
97
- if (scope === "agent") return `@agents/${who ?? agentSlug(ctx)}/${path}`;
98
- return `@models/${who ?? modelSlug(ctx)}/${path}`;
91
+ if (scope === "agent") return `@agents/${who ?? context.agent}/${path}`;
92
+ return `@models/${who ?? context.model}/${path}`;
99
93
  }
@@ -0,0 +1,3 @@
1
+ export const MAX_NOTE_BYTES = 1_000_000;
2
+ /** Write-time cap on a virtual note path. Reads and legacy replay stay uncapped. */
3
+ export const MAX_NOTE_PATH_BYTES = 512;
@@ -0,0 +1,40 @@
1
+ import { resolve } from "node:path";
2
+ import { SLUG_PATTERN } from "./paths.js";
3
+
4
+ /** Explicit, host-neutral identity for one notes store. */
5
+ export type NotesContext = Readonly<{
6
+ home: string;
7
+ sessionId: string;
8
+ projectKey: string;
9
+ agent: string;
10
+ model: string;
11
+ }>;
12
+
13
+ function requireString(field: string, value: unknown): string {
14
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`notes context ${field} must be a non-empty string`);
15
+ return value;
16
+ }
17
+
18
+ function assertDirectoryComponent(field: string, value: string): void {
19
+ if (value === "." || value === ".." || /[\\/\0:]/.test(value)) {
20
+ throw new TypeError(`notes context ${field} must be a safe single directory component`);
21
+ }
22
+ }
23
+
24
+ /** Validate and snapshot caller identity; no global or environment defaults are consulted. */
25
+ export function snapshotNotesContext(value: NotesContext): NotesContext {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("notes context must be a plain object");
27
+ const prototype = Object.getPrototypeOf(value);
28
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("notes context must be a plain object");
29
+ const homeValue = requireString("home", value.home);
30
+ const sessionId = requireString("sessionId", value.sessionId);
31
+ const projectKey = requireString("projectKey", value.projectKey);
32
+ const agent = requireString("agent", value.agent);
33
+ const model = requireString("model", value.model);
34
+ assertDirectoryComponent("sessionId", sessionId);
35
+ assertDirectoryComponent("projectKey", projectKey);
36
+ if (!SLUG_PATTERN.test(agent)) throw new TypeError("notes context agent must be a canonical lowercase slug");
37
+ if (!SLUG_PATTERN.test(model)) throw new TypeError("notes context model must be a canonical lowercase slug");
38
+ const home = resolve(homeValue);
39
+ return Object.freeze({ home, sessionId, projectKey, agent, model });
40
+ }
@@ -13,23 +13,26 @@ export type NoteMeta = {
13
13
  origin: Origin;
14
14
  status: NoteStatus;
15
15
  stale: boolean;
16
- created_at: number;
17
- updated_at: number;
18
- last_accessed: number;
19
- access_count: number;
20
- source_window?: string;
16
+ createdAt: number;
17
+ updatedAt: number;
18
+ lastAccessed: number;
19
+ accessCount: number;
20
+ sourceWindow?: string;
21
21
  supersedes?: string;
22
- recurrence_count?: number;
23
- recurrence_windows?: string[];
22
+ recurrenceCount?: number;
23
+ recurrenceWindows?: string[];
24
+ /** Project ownership on newly-created session notes; legacy/invalid values are preserved as-is. */
25
+ project?: unknown;
24
26
  [key: string]: unknown;
25
27
  };
26
28
 
27
29
  const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model"];
28
30
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
29
31
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
30
- const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
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;
31
34
  /** Emission order, exactly the Design's key list. */
32
- const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
35
+ const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"] as const;
33
36
 
34
37
  const pad2 = (value: number) => String(value).padStart(2, "0");
35
38
 
@@ -96,7 +99,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
96
99
  }
97
100
  }
98
101
  if (close === -1) return { fields: {}, body: raw };
99
- const fields: Record<string, unknown> = {};
102
+ const fields = Object.create(null) as Record<string, unknown>;
100
103
  for (let index = 1; index < close; index++) {
101
104
  const line = lines[index]!;
102
105
  const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
@@ -122,10 +125,13 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
122
125
 
123
126
  /**
124
127
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
125
- * access_count 0, timestamps now); unknown keys are carried through untouched.
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.
126
130
  */
127
131
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
128
132
  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`);
129
135
  const meta = { ...fields } as Record<string, unknown>;
130
136
  // scope is a legacy on-disk field: store callers derive it from the file's home and
131
137
  // overwrite it after parsing, so an absent or outdated value just falls back.
@@ -134,7 +140,7 @@ export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body
134
140
  meta.status = isStatus(meta.status) ? meta.status : "active";
135
141
  meta.stale = meta.stale === true;
136
142
  for (const key of TIMESTAMP_KEYS) meta[key] = toEpoch(meta[key], now);
137
- meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
143
+ meta.accessCount = typeof meta.accessCount === "number" && Number.isFinite(meta.accessCount) ? meta.accessCount : 0;
138
144
  return { meta: meta as NoteMeta, body };
139
145
  }
140
146
 
@@ -0,0 +1,22 @@
1
+ export { createNotesStore, NoteError } from "./store.js";
2
+ export { projectKey, slugify } from "./paths.js";
3
+ export type { NotesContext } from "./context.js";
4
+ export type {
5
+ EditOperation,
6
+ EditOptions,
7
+ NoteChange,
8
+ NoteEditResult,
9
+ NoteErrorCode,
10
+ NoteMatch,
11
+ NoteMeta,
12
+ NoteReadResult,
13
+ NoteRow,
14
+ NoteSearchRow,
15
+ NoteStatus,
16
+ NoteWriteResult,
17
+ NotesQuery,
18
+ NotesStore,
19
+ Origin,
20
+ Scope,
21
+ WriteOptions,
22
+ } from "./store.js";
@@ -1,34 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readdirSync, renameSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
5
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
+ import type { NotesContext } from "./context.js";
6
6
 
7
7
  export type Scope = "session" | "project" | "human" | "agent" | "model";
8
8
 
9
- /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
10
- export function notesRoot(): string {
11
- const override = process.env.PI_NOTES_HOME;
12
- return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
13
- }
14
-
15
- /** Absolute directory holding the per-session note homes. */
16
- export function sessionHomesRoot(home = notesRoot()): string {
17
- return join(home, "pi", "session");
18
- }
9
+ /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
10
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
11
 
20
- /**
21
- * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
22
- * No git root yields undefined, which projectKey then replaces with the cwd itself.
23
- */
24
- function gitRoot(cwd: string): string | undefined {
25
- let dir = resolve(cwd);
26
- for (;;) {
27
- if (existsSync(join(dir, ".git"))) return dir;
28
- const parent = dirname(dir);
29
- if (parent === dir) return undefined;
30
- dir = parent;
31
- }
12
+ /** Slugify a caller-declared identity; identity is never inferred from note content. */
13
+ export function slugify(value: string): string {
14
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
+ return slug.length > 0 ? slug : "root";
32
16
  }
33
17
 
34
18
  /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
@@ -39,67 +23,67 @@ export function projectKey(cwd: string): string {
39
23
  return `${basename(root)}-${digest}`;
40
24
  }
41
25
 
42
- /** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
43
- function sessionId(ctx: ExtensionContext): string {
44
- return ctx.sessionManager.getSessionId();
45
- }
46
-
47
- /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
48
- export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
49
-
50
26
  /**
51
- * Identity slugs: one declared name per home, never detected from prompt content.
52
- * `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
53
- * from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
27
+ * Repository root behind one `.git` entry. A `.git` directory is the main checkout
28
+ * itself. A `.git` file is a worktree or submodule pointer: a linked worktree names
29
+ * `<main>/.git/worktrees/<name>` and resolves to `<main>`, so every worktree of one
30
+ * repository shares one project identity. Submodules, bare repositories, and separate
31
+ * git dirs keep the current directory.
54
32
  */
55
- export function slugify(value: string): string {
56
- const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57
- return slug.length > 0 ? slug : "root";
58
- }
59
-
60
- /** The current agent's home name: the launch-declared identity, defaulting to "root". */
61
- export function agentSlug(_ctx: ExtensionContext): string {
62
- return slugify(process.env.PI_NOTES_AGENT ?? "root");
33
+ function repositoryRoot(dir: string): string {
34
+ let stats;
35
+ try {
36
+ stats = statSync(join(dir, ".git"));
37
+ } catch {
38
+ return dir;
39
+ }
40
+ if (stats.isDirectory()) return dir;
41
+ let pointer: string;
42
+ try {
43
+ pointer = readFileSync(join(dir, ".git"), "utf8");
44
+ } catch {
45
+ return dir;
46
+ }
47
+ const match = /^gitdir:\s*(.+)$/m.exec(pointer);
48
+ if (!match) return dir;
49
+ const parts = resolve(dir, match[1]!.trim()).split(sep);
50
+ const worktrees = parts.lastIndexOf("worktrees");
51
+ if (worktrees <= 0 || worktrees !== parts.length - 2) return dir;
52
+ const common = parts.slice(0, worktrees).join(sep);
53
+ return basename(common) === ".git" ? dirname(common) : dir;
63
54
  }
64
55
 
65
- /** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
66
- export function modelSlug(ctx: ExtensionContext): string {
67
- const id = ctx.model?.id;
68
- if (!id) return "default";
69
- return slugify(id.split("/").pop() ?? id);
56
+ /** Absolute repository root for cwd, walking upward until a directory holds a `.git` entry. */
57
+ function gitRoot(cwd: string): string | undefined {
58
+ let dir = resolve(cwd);
59
+ for (;;) {
60
+ if (existsSync(join(dir, ".git"))) return repositoryRoot(dir);
61
+ const parent = dirname(dir);
62
+ if (parent === dir) return undefined;
63
+ dir = parent;
64
+ }
70
65
  }
71
66
 
72
- /**
73
- * Absolute directory holding every note of one scope. `who` names an agent or model
74
- * home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
75
- * model live from ctx.model).
76
- */
77
- export function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string {
78
- if (scope === "human") return join(notesRoot(), "human");
79
- if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
80
- if (scope === "agent") return join(notesRoot(), "agents", who ?? agentSlug(ctx));
81
- if (scope === "model") return join(notesRoot(), "models", who ?? modelSlug(ctx));
82
- return join(sessionHomesRoot(), sessionId(ctx));
67
+ /** Absolute directory holding the per-session note homes. */
68
+ export function sessionHomesRoot(home: string): string {
69
+ return join(home, "pi", "session");
83
70
  }
84
71
 
85
- /**
86
- * One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
87
- * activation; returns a warning string when both directories exist (no auto-merge),
88
- * undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
89
- */
90
- export function migrateLegacyHomes(home = notesRoot()): string | undefined {
91
- const legacy = join(home, "personal");
92
- const modern = join(home, "human");
93
- if (!existsSync(legacy)) return undefined;
94
- if (existsSync(modern)) return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
95
- renameSync(legacy, modern);
96
- return undefined;
72
+ /** Absolute directory holding one scope's notes. The context's home is already resolved. */
73
+ export function scopeDir(scope: Scope, context: NotesContext, who?: string): string {
74
+ if (!["session", "project", "human", "agent", "model"].includes(scope)) throw new TypeError("invalid notes scope");
75
+ if (who !== undefined && (scope !== "agent" && scope !== "model" || !SLUG_PATTERN.test(who))) throw new TypeError("who must be a canonical agent/model slug");
76
+ if (scope === "human") return join(context.home, "human");
77
+ if (scope === "project") return join(context.home, "project", context.projectKey);
78
+ if (scope === "agent") return join(context.home, "agents", who ?? context.agent);
79
+ if (scope === "model") return join(context.home, "models", who ?? context.model);
80
+ return join(sessionHomesRoot(context.home), context.sessionId);
97
81
  }
98
82
 
99
- /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
100
- export function namespaceSlugs(namespace: "agents" | "models", home = notesRoot()): string[] {
83
+ /** Every existing home directory of the agents/ or models/ namespace, as names. */
84
+ export async function namespaceSlugs(namespace: "agents" | "models", home: string): Promise<string[]> {
101
85
  try {
102
- return readdirSync(join(home, namespace), { withFileTypes: true })
86
+ return (await readdir(join(home, namespace), { withFileTypes: true }))
103
87
  .filter((entry) => entry.isDirectory())
104
88
  .map((entry) => entry.name)
105
89
  .sort();
@@ -117,6 +101,8 @@ export function noteFileName(vpath: string): string {
117
101
  }
118
102
 
119
103
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
120
- export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext, who?: string): string {
121
- return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
104
+ export function physicalPath(scope: Scope, vpath: string, context: NotesContext, who?: string): string {
105
+ if (typeof vpath !== "string" || vpath.length === 0 || vpath.includes("\0") || vpath.includes("\\") || vpath.startsWith("/")) throw new TypeError("path must be a safe virtual relative path");
106
+ if (vpath.split("/").some((part) => part.length === 0 || part === "." || part === "..")) throw new TypeError("path contains an unsupported component");
107
+ return join(scopeDir(scope, context, who), ...noteFileName(vpath).split("/"));
122
108
  }