@astrosheep/pi-context 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1861 -0
  4. package/dist/src/context/budget.js +150 -0
  5. package/dist/src/context/context-window.js +97 -0
  6. package/dist/src/context/prompts.js +94 -0
  7. package/dist/src/context/reset-lifecycle.js +134 -0
  8. package/dist/src/context/runtime.js +236 -0
  9. package/dist/src/context/thresholds.js +62 -0
  10. package/dist/src/dream/cli.js +1 -1
  11. package/dist/src/dream/doctor.js +34 -6
  12. package/dist/src/dream/runner.js +1 -1
  13. package/dist/src/dream/settings.js +30 -0
  14. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  15. package/dist/src/{history.js → history/history.js} +8 -46
  16. package/dist/src/index.js +27 -94
  17. package/dist/src/notes/address.js +97 -16
  18. package/dist/src/notes/frontmatter.js +18 -3
  19. package/dist/src/notes/notes-snapshot.js +30 -0
  20. package/dist/src/notes/paths.js +64 -7
  21. package/dist/src/notes/session-replay.js +41 -0
  22. package/dist/src/notes/store.js +76 -22
  23. package/dist/src/notes/tools.js +7 -7
  24. package/dist/src/protocol.js +11 -9
  25. package/dist/src/settings.js +16 -0
  26. package/dist/src/tool-schema.js +1 -1
  27. package/dist/test/agent-loop.test.js +813 -213
  28. package/dist/test/boot.integration.test.js +167 -0
  29. package/dist/test/budget-settings.integration.test.js +126 -0
  30. package/dist/test/doctor.test.js +14 -36
  31. package/dist/test/dream.test.js +37 -380
  32. package/dist/test/helpers/extension.js +393 -0
  33. package/dist/test/history.integration.test.js +316 -0
  34. package/dist/test/notes.integration.test.js +273 -0
  35. package/dist/test/notes.test.js +40 -370
  36. package/dist/test/reset-lifecycle.test.js +248 -180
  37. package/docs/architecture.md +35 -18
  38. package/docs/reset-lifecycle.md +16 -14
  39. package/package.json +11 -10
  40. package/src/context/budget.ts +148 -0
  41. package/src/context/context-window.ts +103 -0
  42. package/src/context/prompts.ts +111 -0
  43. package/src/context/reset-lifecycle.ts +145 -0
  44. package/src/context/runtime.ts +246 -0
  45. package/src/context/thresholds.ts +78 -0
  46. package/src/dream/cli.ts +1 -1
  47. package/src/dream/doctor.ts +27 -6
  48. package/src/dream/runner.ts +1 -1
  49. package/src/dream/settings.ts +32 -0
  50. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  51. package/src/{history.ts → history/history.ts} +9 -48
  52. package/src/index.ts +27 -89
  53. package/src/notes/address.ts +82 -16
  54. package/src/notes/frontmatter.ts +20 -3
  55. package/src/notes/notes-snapshot.ts +40 -0
  56. package/src/notes/paths.ts +64 -7
  57. package/src/notes/session-replay.ts +53 -0
  58. package/src/notes/store.ts +78 -25
  59. package/src/notes/tools.ts +7 -7
  60. package/src/protocol.ts +11 -9
  61. package/src/settings.ts +20 -0
  62. package/src/tool-schema.ts +1 -2
  63. package/dist/src/budget.js +0 -65
  64. package/dist/src/notes/model.js +0 -101
  65. package/dist/src/prompts.js +0 -88
  66. package/dist/src/reset-lifecycle.js +0 -155
  67. package/dist/src/thresholds.js +0 -102
  68. package/dist/src/warning.js +0 -44
  69. package/dist/test/coherence.test.js +0 -371
  70. package/dist/test/history.test.js +0 -26
  71. package/dist/test/integration.test.js +0 -1775
  72. package/dist/test/pagination.property.test.js +0 -471
  73. package/src/budget.ts +0 -67
  74. package/src/notes/model.ts +0 -109
  75. package/src/prompts.ts +0 -91
  76. package/src/reset-lifecycle.ts +0 -173
  77. package/src/thresholds.ts +0 -110
  78. package/src/warning.ts +0 -46
package/src/index.ts CHANGED
@@ -1,97 +1,35 @@
1
- import { registerHistoryTools } from "./history-tools.js";
1
+ import { VERSION, type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { registerHistoryTools } from "./history/history-tools.js";
2
3
  import { registerNotesTools } from "./notes/tools.js";
3
- import { registerBudget } from "./budget.js";
4
- import { output } from "./tool-output.js";
5
- import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
6
- import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, 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, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
- import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId, rootWindowId, windowIdOf } from "./history.js";
8
- import { assertVirtualPath } from "./notes/model.js";
9
- import { bootBlock } from "./prompts.js";
10
- export { historyFromSession } from "./history.js";
11
- export { notesFromSession } from "./notes/model.js";
12
- import { registerResetLifecycle } from "./reset-lifecycle.js";
13
- import { registerWarning } from "./warning.js";
14
- import { randomUUID } from "node:crypto";
15
- import { Type } from "@earendil-works/pi-ai";
16
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
-
18
- export default function piContext(pi: ExtensionAPI) {
19
- let enabled = true;
20
- registerBudget(pi, () => enabled);
21
- registerWarning(pi, () => enabled);
22
-
23
- pi.on("session_start", (_event, ctx) => {
24
- if (!enabled) return;
25
- // The root window has no compaction entry to carry the boot block, so persist
26
- // it once as a hidden custom message. Reset windows already carry theirs at
27
- // position 0 in the compaction summary, so a resumed session adds nothing.
28
- const rootId = rootWindowId(ctx.sessionManager.getSessionId());
29
- if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
30
- pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
31
- });
32
-
33
- pi.registerCommand("pi-context", {
34
- description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
35
- getArgumentCompletions: (prefix) =>
36
- ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
37
- handler: async (args, cmdCtx) => {
38
- const arg = args.trim().toLowerCase();
39
- if (arg === "on") enabled = true;
40
- else if (arg === "off") { enabled = false; resets.clear(); }
41
- else if (arg !== "") {
42
- cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
43
- return;
44
- }
45
- cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
46
- },
47
- });
4
+ import { deriveThresholds } from "./context/thresholds.js";
5
+ import { registerContext } from "./context/runtime.js";
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, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
8
+ import { assertVirtualPath } from "./notes/address.js";
9
+ export { historyFromSession } from "./history/history.js";
10
+ export { notesFromSession } from "./notes/session-replay.js";
48
11
 
12
+ function registerPiContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
13
+ const [major, minor] = VERSION.split(".").map(Number);
14
+ if (!(major > 0 || (major === 0 && minor >= 87))) {
15
+ throw new Error(`pi-context requires Pi >= 0.87.0; running ${VERSION}. Upgrade Pi and restart the process; /reload only reloads extensions.`);
16
+ }
17
+ registerContext(pi, settingsManager);
49
18
  registerHistoryTools(pi);
50
19
  registerNotesTools(pi);
20
+ }
51
21
 
52
- pi.registerTool(defineTool({
53
- name: "new_context",
54
- label: "New context",
55
- description: "Clear your mind and start a new context window. Your session, notes, and history survive.",
56
- parameters: Type.Object({}, { additionalProperties: false }),
57
- async execute() {
58
- if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
59
- return output({ status: resets.request() }, undefined, true);
60
- },
61
- }));
22
+ /**
23
+ * Create an extension factory bound to an SDK settings authority. The host must pass
24
+ * the same manager to createAgentSession and to this factory's resource loader.
25
+ */
26
+ export function createPiContext(options: { settingsManager?: SettingsManager } = {}): ExtensionFactory {
27
+ return (pi) => registerPiContext(pi, options.settingsManager);
28
+ }
62
29
 
63
- const resets = registerResetLifecycle(pi, {
64
- isEnabled: () => enabled,
65
- continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
66
- isCurrentReset: (entryId, ctx) => {
67
- const entry = ctx.sessionManager.getEntry(entryId);
68
- return entry?.type === "compaction" && resetV2WindowId(entry.details) === currentWindowId(ctx);
69
- },
70
- onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
71
- buildReset: (event, ctx, explicit) => {
72
- const sessionId = ctx.sessionManager.getSessionId();
73
- // Window IDs are independent of Pi entry IDs. Avoid reusing a window
74
- // identity already present on this branch.
75
- const windows = historyFromSession(ctx);
76
- const usedIds = new Set(windows.map((window) => window.windowId));
77
- let minted = { id: randomUUID().slice(0, 8) };
78
- while (usedIds.has(windowIdOf(sessionId, minted))) minted = { id: randomUUID().slice(0, 8) };
79
- const windowId = windowIdOf(sessionId, minted);
80
- const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
81
- // The reset marker stays as firstKeptEntryId; it no longer names the window.
82
- pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
83
- const markerId = ctx.sessionManager.getLeafId();
84
- if (!markerId) return { cancel: true };
85
- return {
86
- compaction: {
87
- summary: bootBlock(ctx, windowId, previousId, true),
88
- firstKeptEntryId: markerId,
89
- tokensBefore: event.preparation.tokensBefore,
90
- details: { piContext: RESET_V2, windowId },
91
- },
92
- };
93
- },
94
- });
30
+ /** The Pi-discovered extension keeps the standard file-backed settings behavior. */
31
+ export default function piContext(pi: ExtensionAPI): void {
32
+ registerPiContext(pi);
95
33
  }
96
34
 
97
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, 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_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, 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,33 +1,99 @@
1
- import { assertVirtualPath } from "./model.js";
2
- import type { Scope } from "./paths.js";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
3
3
 
4
- export type NoteAddress = { scope: Scope; path: string };
4
+ export type NoteAddress = { scope: Scope; path: string; who?: string };
5
5
 
6
- const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
6
+ export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, @agents/<name>/, @model/, and @models/<name>/; bare names are the session home";
7
+
8
+ export function assertVirtualPath(value: unknown): string {
9
+ if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
10
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
11
+ const parts = value.split("/");
12
+ if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
13
+ return value;
14
+ }
15
+
16
+ /**
17
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
18
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
19
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
20
+ * one non-`/` character. Everything else is literal and the match is anchored to the
21
+ * whole path.
22
+ */
23
+ export function globToRegExp(pattern: string): RegExp {
24
+ let source = "^";
25
+ for (let index = 0; index < pattern.length; index++) {
26
+ const char = pattern[index]!;
27
+ if (char === "*") {
28
+ if (pattern[index + 1] === "*") {
29
+ const followedBySlash = pattern[index + 2] === "/";
30
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
31
+ index += followedBySlash ? 2 : 1;
32
+ } else {
33
+ source += "[^/]*";
34
+ }
35
+ } else {
36
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
37
+ }
38
+ }
39
+ return new RegExp(`${source}$`);
40
+ }
41
+
42
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
43
+ export function assertGlobPattern(value: unknown): string | undefined {
44
+ if (value === undefined || value === null || value === "") return undefined;
45
+ if (typeof value !== "string") throw new Error("glob pattern must be a string");
46
+ if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
47
+ return value;
48
+ }
7
49
 
8
50
  /**
9
51
  * Decode the one public note address into its physical home and virtual path. This is a
10
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.
11
57
  */
12
58
  export function assertAddress(value: unknown): NoteAddress {
13
59
  if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
14
60
  let scope: Scope = "session";
15
61
  let path = value;
16
- if (value.startsWith("@project/")) {
17
- scope = "project";
18
- path = value.slice("@project/".length);
19
- } else if (value.startsWith("@personal/")) {
20
- scope = "personal";
21
- path = value.slice("@personal/".length);
22
- } else if (value.startsWith("@")) {
23
- throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
62
+ let who: string | undefined;
63
+ if (value.startsWith("@")) {
64
+ const rest = value.slice(1);
65
+ const headEnd = rest.indexOf("/");
66
+ const head = headEnd === -1 ? rest : rest.slice(0, headEnd);
67
+ const tail = headEnd === -1 ? "" : rest.slice(headEnd + 1);
68
+ path = tail;
69
+ if (head === "project") scope = "project";
70
+ else if (head === "human") scope = "human";
71
+ else if (head === "self") scope = "agent";
72
+ else if (head === "model") scope = "model";
73
+ else if (head === "agents" || head === "models") {
74
+ const nameEnd = tail.indexOf("/");
75
+ who = nameEnd === -1 ? tail : tail.slice(0, nameEnd);
76
+ if (!SLUG_PATTERN.test(who)) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
77
+ scope = head === "agents" ? "agent" : "model";
78
+ path = nameEnd === -1 ? "" : tail.slice(nameEnd + 1);
79
+ } else {
80
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
81
+ }
82
+ if (path === "" && scope !== "agent" && scope !== "model") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
83
+ if (path === "" && who === undefined) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
24
84
  }
25
85
  if (path.includes("@")) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
26
86
  assertVirtualPath(path);
27
- return { scope, path };
87
+ return { scope, path, who };
28
88
  }
29
89
 
30
- /** Render a virtual path in its one unambiguous public address form. */
31
- export function addressFor(scope: Scope, path: string): string {
32
- return scope === "session" ? path : `@${scope}/${path}`;
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 {
94
+ if (scope === "session") return path;
95
+ if (scope === "project") return `@project/${path}`;
96
+ if (scope === "human") return `@human/${path}`;
97
+ if (scope === "agent") return `@agents/${who ?? agentSlug(ctx)}/${path}`;
98
+ return `@models/${who ?? modelSlug(ctx)}/${path}`;
33
99
  }
@@ -1,4 +1,3 @@
1
- import { localIso } from "./model.js";
2
1
  import type { Scope } from "./paths.js";
3
2
 
4
3
  export type NoteStatus = "active" | "superseded" | "pending" | "archived";
@@ -25,13 +24,29 @@ export type NoteMeta = {
25
24
  [key: string]: unknown;
26
25
  };
27
26
 
28
- const SCOPES: readonly Scope[] = ["session", "project", "personal"];
27
+ const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model"];
29
28
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
30
29
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
31
30
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
32
31
  /** Emission order, exactly the Design's key list. */
33
32
  const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
34
33
 
34
+ const pad2 = (value: number) => String(value).padStart(2, "0");
35
+
36
+ /**
37
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
38
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
39
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
40
+ */
41
+ export function localIso(epochMs: number): string {
42
+ const date = new Date(epochMs);
43
+ const offsetMinutes = -date.getTimezoneOffset();
44
+ const absOffset = Math.abs(offsetMinutes);
45
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
46
+ const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
47
+ return `${wallClock}${offset}`;
48
+ }
49
+
35
50
  export function isScope(value: unknown): value is Scope {
36
51
  return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
37
52
  }
@@ -112,7 +127,9 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
112
127
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
113
128
  const { fields, body } = parseFrontmatter(raw);
114
129
  const meta = { ...fields } as Record<string, unknown>;
115
- meta.scope = isScope(meta.scope) ? meta.scope : "personal";
130
+ // scope is a legacy on-disk field: store callers derive it from the file's home and
131
+ // overwrite it after parsing, so an absent or outdated value just falls back.
132
+ meta.scope = isScope(meta.scope) ? meta.scope : "session";
116
133
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
117
134
  meta.status = isStatus(meta.status) ? meta.status : "active";
118
135
  meta.stale = meta.stale === true;
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { listNotes, type NoteRow, type Scope } from "./store.js";
3
+
4
+ const NOTES_HOMES = [
5
+ { scope: "session", label: "this session" },
6
+ { scope: "project", label: "@project" },
7
+ { scope: "human", label: "@human" },
8
+ { scope: "agent", label: "@self" },
9
+ { scope: "model", label: "@model" },
10
+ ] as const satisfies ReadonlyArray<{ scope: Scope; label: string }>;
11
+
12
+ export type NotesHome = (typeof NOTES_HOMES)[number];
13
+ export type NotesLoader = (ctx: ExtensionContext, scope: Scope) => NoteRow[];
14
+ export type NotesSnapshot = {
15
+ /** Wall-clock instant captured when this boot began; rendering never consults Date.now(). */
16
+ readonly openedAt: number;
17
+ readonly homes: ReadonlyMap<Scope, readonly NoteRow[]>;
18
+ readonly unavailable: readonly NotesHome[];
19
+ };
20
+
21
+ /**
22
+ * Acquire the five homes once for one boot. Only filesystem-style errno failures are isolated;
23
+ * malformed note data and unrelated construction errors remain visible to the caller.
24
+ */
25
+ export function loadNotesSnapshot(ctx: ExtensionContext, loadHome: NotesLoader = (context, scope) => listNotes(context, { scope })): NotesSnapshot {
26
+ const openedAt = Date.now();
27
+ const homes = new Map<Scope, readonly NoteRow[]>();
28
+ const unavailable: NotesHome[] = [];
29
+ for (const home of NOTES_HOMES) {
30
+ try {
31
+ homes.set(home.scope, loadHome(ctx, home.scope));
32
+ } catch (error) {
33
+ const code = typeof error === "object" && error !== null ? (error as NodeJS.ErrnoException).code : undefined;
34
+ if (typeof code !== "string" || !/^E[A-Z0-9_]+$/.test(code) || code.startsWith("ERR_")) throw error;
35
+ homes.set(home.scope, []);
36
+ unavailable.push(home);
37
+ }
38
+ }
39
+ return { openedAt, homes, unavailable };
40
+ }
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readdirSync, renameSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export type Scope = "session" | "project" | "personal";
7
+ export type Scope = "session" | "project" | "human" | "agent" | "model";
8
8
 
9
9
  /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
10
10
  export function notesRoot(): string {
@@ -44,13 +44,70 @@ function sessionId(ctx: ExtensionContext): string {
44
44
  return ctx.sessionManager.getSessionId();
45
45
  }
46
46
 
47
- /** Absolute directory holding every note of one scope. */
48
- export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
49
- if (scope === "personal") return join(notesRoot(), "personal");
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
+ /**
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-].
54
+ */
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");
63
+ }
64
+
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);
70
+ }
71
+
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");
50
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));
51
82
  return join(sessionHomesRoot(), sessionId(ctx));
52
83
  }
53
84
 
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;
97
+ }
98
+
99
+ /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
100
+ export function namespaceSlugs(namespace: "agents" | "models", home = notesRoot()): string[] {
101
+ try {
102
+ return readdirSync(join(home, namespace), { withFileTypes: true })
103
+ .filter((entry) => entry.isDirectory())
104
+ .map((entry) => entry.name)
105
+ .sort();
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
54
111
  /**
55
112
  * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
56
113
  * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
@@ -60,6 +117,6 @@ export function noteFileName(vpath: string): string {
60
117
  }
61
118
 
62
119
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
63
- export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext): string {
64
- return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
120
+ export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext, who?: string): string {
121
+ return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
65
122
  }
@@ -0,0 +1,53 @@
1
+ import type { SessionReader } from "../session-reader.js";
2
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
3
+ import { assertVirtualPath } from "./address.js";
4
+
5
+ export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
6
+ export type NoteOperation = {
7
+ op: "write" | "append";
8
+ path: string;
9
+ // Both are optional on the wire so mark-only and explicit-revive operations replay:
10
+ // at least one of text/stale is present, enforced by the note tools and isNoteOperation.
11
+ text?: string;
12
+ stale?: boolean;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ };
16
+
17
+ /** Replays only pi-context note operations from session custom entries. */
18
+ function isNoteOperation(data: unknown): data is NoteOperation {
19
+ if (typeof data !== "object" || data === null) return false;
20
+ const op = data as Partial<NoteOperation>;
21
+ return (
22
+ (op.op === "write" || op.op === "append") &&
23
+ typeof op.path === "string" &&
24
+ (op.text === undefined || typeof op.text === "string") &&
25
+ (op.stale === undefined || typeof op.stale === "boolean") &&
26
+ (op.text !== undefined || op.stale !== undefined) &&
27
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
28
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
29
+ );
30
+ }
31
+
32
+ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
33
+ const files = new Map<string, NoteFile>();
34
+ for (const entry of ctx.sessionManager.getBranch()) {
35
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
36
+ const op = entry.data;
37
+ try {
38
+ assertVirtualPath(op.path);
39
+ } catch {
40
+ continue;
41
+ }
42
+ const previous = files.get(op.path);
43
+ const hasText = op.text !== undefined;
44
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
45
+ if (!hasText && !previous) continue;
46
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text as string) : previous!.text;
47
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) continue;
48
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
49
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous!.stale;
50
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
51
+ }
52
+ return files;
53
+ }