@astrosheep/pi-context 0.24.0 → 0.25.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 (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
package/dist/src/index.js CHANGED
@@ -1,98 +1,31 @@
1
- import { registerHistoryTools } from "./history-tools.js";
1
+ import { VERSION } 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 } from "@earendil-works/pi-coding-agent";
17
- export default function piContext(pi) {
18
- let enabled = true;
19
- registerBudget(pi, () => enabled);
20
- registerWarning(pi, () => enabled);
21
- pi.on("session_start", (_event, ctx) => {
22
- if (!enabled)
23
- return;
24
- // The root window has no compaction entry to carry the boot block, so persist
25
- // it once as a hidden custom message. Reset windows already carry theirs at
26
- // position 0 in the compaction summary, so a resumed session adds nothing.
27
- const rootId = rootWindowId(ctx.sessionManager.getSessionId());
28
- if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE))
29
- return;
30
- pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
31
- });
32
- pi.registerCommand("pi-context", {
33
- description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
34
- getArgumentCompletions: (prefix) => ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
35
- handler: async (args, cmdCtx) => {
36
- const arg = args.trim().toLowerCase();
37
- if (arg === "on")
38
- enabled = true;
39
- else if (arg === "off") {
40
- enabled = false;
41
- resets.clear();
42
- }
43
- else if (arg !== "") {
44
- cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
45
- return;
46
- }
47
- cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
48
- },
49
- });
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, 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";
11
+ function registerPiContext(pi, settingsManager) {
12
+ const [major, minor] = VERSION.split(".").map(Number);
13
+ if (!(major > 0 || (major === 0 && minor >= 87))) {
14
+ throw new Error(`pi-context requires Pi >= 0.87.0; running ${VERSION}. Upgrade Pi and restart the process; /reload only reloads extensions.`);
15
+ }
16
+ registerContext(pi, settingsManager);
50
17
  registerHistoryTools(pi);
51
18
  registerNotesTools(pi);
52
- pi.registerTool(defineTool({
53
- name: "wipe_memory",
54
- label: "Wipe memory",
55
- description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
56
- parameters: Type.Object({}, { additionalProperties: false }),
57
- async execute() {
58
- if (!enabled)
59
- return output({ error: "pi-context is off (/pi-context on to enable)" });
60
- return output({ status: resets.request() }, undefined, true);
61
- },
62
- }));
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)))
79
- minted = { id: randomUUID().slice(0, 8) };
80
- const windowId = windowIdOf(sessionId, minted);
81
- const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
82
- // The reset marker stays as firstKeptEntryId; it no longer names the window.
83
- pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
84
- const markerId = ctx.sessionManager.getLeafId();
85
- if (!markerId)
86
- return { cancel: true };
87
- return {
88
- compaction: {
89
- summary: bootBlock(ctx, windowId, previousId, true),
90
- firstKeptEntryId: markerId,
91
- tokensBefore: event.preparation.tokensBefore,
92
- details: { piContext: RESET_V2, windowId },
93
- },
94
- };
95
- },
96
- });
97
19
  }
98
- 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 };
20
+ /**
21
+ * Create an extension factory bound to an SDK settings authority. The host must pass
22
+ * the same manager to createAgentSession and to this factory's resource loader.
23
+ */
24
+ export function createPiContext(options = {}) {
25
+ return (pi) => registerPiContext(pi, options.settingsManager);
26
+ }
27
+ /** The Pi-discovered extension keeps the standard file-backed settings behavior. */
28
+ export default function piContext(pi) {
29
+ registerPiContext(pi);
30
+ }
31
+ 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 };
@@ -1,31 +1,112 @@
1
- import { assertVirtualPath } from "./model.js";
2
- const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
1
+ import { agentSlug, modelSlug, SLUG_PATTERN } from "./paths.js";
2
+ export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
3
+ export function assertVirtualPath(value) {
4
+ if (typeof value !== "string" || value.length === 0)
5
+ throw new Error("path must be a non-empty virtual relative path");
6
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
7
+ throw new Error("path must be a safe virtual relative path");
8
+ const parts = value.split("/");
9
+ if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
10
+ throw new Error("path contains an unsupported component");
11
+ return value;
12
+ }
13
+ /**
14
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
15
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
16
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
17
+ * one non-`/` character. Everything else is literal and the match is anchored to the
18
+ * whole path.
19
+ */
20
+ export function globToRegExp(pattern) {
21
+ let source = "^";
22
+ for (let index = 0; index < pattern.length; index++) {
23
+ const char = pattern[index];
24
+ if (char === "*") {
25
+ if (pattern[index + 1] === "*") {
26
+ const followedBySlash = pattern[index + 2] === "/";
27
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
28
+ index += followedBySlash ? 2 : 1;
29
+ }
30
+ else {
31
+ source += "[^/]*";
32
+ }
33
+ }
34
+ else {
35
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
36
+ }
37
+ }
38
+ return new RegExp(`${source}$`);
39
+ }
40
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
41
+ export function assertGlobPattern(value) {
42
+ if (value === undefined || value === null || value === "")
43
+ return undefined;
44
+ if (typeof value !== "string")
45
+ throw new Error("glob pattern must be a string");
46
+ if (value.includes("\0") || value.includes("\\"))
47
+ throw new Error("glob pattern must not contain NUL or backslashes");
48
+ return value;
49
+ }
3
50
  /**
4
51
  * Decode the one public note address into its physical home and virtual path. This is a
5
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.
6
57
  */
7
58
  export function assertAddress(value) {
8
59
  if (typeof value !== "string")
9
60
  throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
10
61
  let scope = "session";
11
62
  let path = value;
12
- if (value.startsWith("@project/")) {
13
- scope = "project";
14
- path = value.slice("@project/".length);
15
- }
16
- else if (value.startsWith("@personal/")) {
17
- scope = "personal";
18
- path = value.slice("@personal/".length);
19
- }
20
- else if (value.startsWith("@")) {
21
- throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
63
+ let who;
64
+ if (value.startsWith("@")) {
65
+ const rest = value.slice(1);
66
+ const headEnd = rest.indexOf("/");
67
+ const head = headEnd === -1 ? rest : rest.slice(0, headEnd);
68
+ const tail = headEnd === -1 ? "" : rest.slice(headEnd + 1);
69
+ path = tail;
70
+ if (head === "project")
71
+ scope = "project";
72
+ else if (head === "human")
73
+ scope = "human";
74
+ else if (head === "self")
75
+ scope = "agent";
76
+ else if (head === "model")
77
+ scope = "model";
78
+ else if (head === "agents" || head === "models") {
79
+ const nameEnd = tail.indexOf("/");
80
+ who = nameEnd === -1 ? tail : tail.slice(0, nameEnd);
81
+ if (!SLUG_PATTERN.test(who))
82
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
83
+ scope = head === "agents" ? "agent" : "model";
84
+ path = nameEnd === -1 ? "" : tail.slice(nameEnd + 1);
85
+ }
86
+ else {
87
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
88
+ }
89
+ if (path === "" && scope !== "agent" && scope !== "model")
90
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
91
+ if (path === "" && who === undefined)
92
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
22
93
  }
23
94
  if (path.includes("@"))
24
95
  throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
25
96
  assertVirtualPath(path);
26
- return { scope, path };
97
+ return { scope, path, who };
27
98
  }
28
- /** Render a virtual path in its one unambiguous public address form. */
29
- export function addressFor(scope, path) {
30
- return scope === "session" ? path : `@${scope}/${path}`;
99
+ /** Render a virtual path in its one unambiguous public address form. Relative forms
100
+ * (@self/, @model/) never render: the canonical address always carries the resolved
101
+ * name, so listings alone tell every home apart. */
102
+ export function addressFor(ctx, scope, path, who) {
103
+ if (scope === "session")
104
+ return path;
105
+ if (scope === "project")
106
+ return `@project/${path}`;
107
+ if (scope === "human")
108
+ return `@human/${path}`;
109
+ if (scope === "agent")
110
+ return `@agents/${who ?? agentSlug(ctx)}/${path}`;
111
+ return `@models/${who ?? modelSlug(ctx)}/${path}`;
31
112
  }
@@ -1,10 +1,23 @@
1
- import { localIso } from "./model.js";
2
- const SCOPES = ["session", "project", "personal"];
1
+ const SCOPES = ["session", "project", "human", "agent", "model"];
3
2
  const ORIGINS = ["user", "self", "external"];
4
3
  const STATUSES = ["active", "superseded", "pending", "archived"];
5
4
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
6
5
  /** Emission order, exactly the Design's key list. */
7
6
  const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
7
+ const pad2 = (value) => String(value).padStart(2, "0");
8
+ /**
9
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
10
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
11
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
12
+ */
13
+ export function localIso(epochMs) {
14
+ const date = new Date(epochMs);
15
+ const offsetMinutes = -date.getTimezoneOffset();
16
+ const absOffset = Math.abs(offsetMinutes);
17
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
18
+ 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")}`;
19
+ return `${wallClock}${offset}`;
20
+ }
8
21
  export function isScope(value) {
9
22
  return typeof value === "string" && SCOPES.includes(value);
10
23
  }
@@ -89,7 +102,9 @@ function parseFrontmatter(raw) {
89
102
  export function parseNote(raw, now = Date.now()) {
90
103
  const { fields, body } = parseFrontmatter(raw);
91
104
  const meta = { ...fields };
92
- meta.scope = isScope(meta.scope) ? meta.scope : "personal";
105
+ // scope is a legacy on-disk field: store callers derive it from the file's home and
106
+ // overwrite it after parsing, so an absent or outdated value just falls back.
107
+ meta.scope = isScope(meta.scope) ? meta.scope : "session";
93
108
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
94
109
  meta.status = isStatus(meta.status) ? meta.status : "active";
95
110
  meta.stale = meta.stale === true;
@@ -0,0 +1,30 @@
1
+ import { listNotes } from "./store.js";
2
+ const NOTES_HOMES = [
3
+ { scope: "session", label: "this session" },
4
+ { scope: "project", label: "@project" },
5
+ { scope: "human", label: "@human" },
6
+ { scope: "agent", label: "@self" },
7
+ { scope: "model", label: "@model" },
8
+ ];
9
+ /**
10
+ * Acquire the five homes once for one boot. Only filesystem-style errno failures are isolated;
11
+ * malformed note data and unrelated construction errors remain visible to the caller.
12
+ */
13
+ export function loadNotesSnapshot(ctx, loadHome = (context, scope) => listNotes(context, { scope })) {
14
+ const openedAt = Date.now();
15
+ const homes = new Map();
16
+ const unavailable = [];
17
+ for (const home of NOTES_HOMES) {
18
+ try {
19
+ homes.set(home.scope, loadHome(ctx, home.scope));
20
+ }
21
+ catch (error) {
22
+ const code = typeof error === "object" && error !== null ? error.code : undefined;
23
+ if (typeof code !== "string" || !/^E[A-Z0-9_]+$/.test(code) || code.startsWith("ERR_"))
24
+ throw error;
25
+ homes.set(home.scope, []);
26
+ unavailable.push(home);
27
+ }
28
+ }
29
+ return { openedAt, homes, unavailable };
30
+ }
@@ -1,5 +1,5 @@
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
  /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
@@ -37,14 +37,71 @@ export function projectKey(cwd) {
37
37
  function sessionId(ctx) {
38
38
  return ctx.sessionManager.getSessionId();
39
39
  }
40
- /** Absolute directory holding every note of one scope. */
41
- export function scopeDir(scope, ctx) {
42
- if (scope === "personal")
43
- return join(notesRoot(), "personal");
40
+ /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
41
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
42
+ /**
43
+ * Identity slugs: one declared name per home, never detected from prompt content.
44
+ * `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
45
+ * from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
46
+ */
47
+ export function slugify(value) {
48
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49
+ return slug.length > 0 ? slug : "root";
50
+ }
51
+ /** The current agent's home name: the launch-declared identity, defaulting to "root". */
52
+ export function agentSlug(_ctx) {
53
+ return slugify(process.env.PI_NOTES_AGENT ?? "root");
54
+ }
55
+ /** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
56
+ export function modelSlug(ctx) {
57
+ const id = ctx.model?.id;
58
+ if (!id)
59
+ return "default";
60
+ return slugify(id.split("/").pop() ?? id);
61
+ }
62
+ /**
63
+ * Absolute directory holding every note of one scope. `who` names an agent or model
64
+ * home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
65
+ * model live from ctx.model).
66
+ */
67
+ export function scopeDir(scope, ctx, who) {
68
+ if (scope === "human")
69
+ return join(notesRoot(), "human");
44
70
  if (scope === "project")
45
71
  return join(notesRoot(), "project", projectKey(ctx.cwd));
72
+ if (scope === "agent")
73
+ return join(notesRoot(), "agents", who ?? agentSlug(ctx));
74
+ if (scope === "model")
75
+ return join(notesRoot(), "models", who ?? modelSlug(ctx));
46
76
  return join(sessionHomesRoot(), sessionId(ctx));
47
77
  }
78
+ /**
79
+ * One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
80
+ * activation; returns a warning string when both directories exist (no auto-merge),
81
+ * undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
82
+ */
83
+ export function migrateLegacyHomes(home = notesRoot()) {
84
+ const legacy = join(home, "personal");
85
+ const modern = join(home, "human");
86
+ if (!existsSync(legacy))
87
+ return undefined;
88
+ if (existsSync(modern))
89
+ return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
90
+ renameSync(legacy, modern);
91
+ return undefined;
92
+ }
93
+ /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
94
+ export function namespaceSlugs(namespace, home = notesRoot()) {
95
+ try {
96
+ return readdirSync(join(home, namespace), { withFileTypes: true })
97
+ .filter((entry) => entry.isDirectory())
98
+ .map((entry) => entry.name)
99
+ .sort();
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ }
48
105
  /**
49
106
  * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
50
107
  * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
@@ -53,6 +110,6 @@ export function noteFileName(vpath) {
53
110
  return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
54
111
  }
55
112
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
56
- export function physicalPath(scope, vpath, ctx) {
57
- return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
113
+ export function physicalPath(scope, vpath, ctx, who) {
114
+ return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
58
115
  }
@@ -0,0 +1,41 @@
1
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
2
+ import { assertVirtualPath } from "./address.js";
3
+ /** Replays only pi-context note operations from session custom entries. */
4
+ function isNoteOperation(data) {
5
+ if (typeof data !== "object" || data === null)
6
+ return false;
7
+ const op = data;
8
+ return ((op.op === "write" || op.op === "append") &&
9
+ typeof op.path === "string" &&
10
+ (op.text === undefined || typeof op.text === "string") &&
11
+ (op.stale === undefined || typeof op.stale === "boolean") &&
12
+ (op.text !== undefined || op.stale !== undefined) &&
13
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
14
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime()));
15
+ }
16
+ export function notesFromSession(ctx) {
17
+ const files = new Map();
18
+ for (const entry of ctx.sessionManager.getBranch()) {
19
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data))
20
+ continue;
21
+ const op = entry.data;
22
+ try {
23
+ assertVirtualPath(op.path);
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ const previous = files.get(op.path);
29
+ const hasText = op.text !== undefined;
30
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
31
+ if (!hasText && !previous)
32
+ continue;
33
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text) : previous.text;
34
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES)
35
+ continue;
36
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
37
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous.stale;
38
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
39
+ }
40
+ return files;
41
+ }
@@ -2,11 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { generateDiffString } from "@earendil-works/pi-coding-agent";
5
- import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
6
5
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
7
6
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
- import { addressFor } from "./address.js";
9
- import { physicalPath, scopeDir } from "./paths.js";
7
+ import { addressFor, assertGlobPattern, assertVirtualPath, globToRegExp } from "./address.js";
8
+ import { agentSlug, modelSlug, namespaceSlugs, physicalPath, scopeDir } from "./paths.js";
10
9
  import { earliestMatchOffsetChars } from "../tool-output.js";
11
10
  /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
12
11
  export class NoteError extends Error {
@@ -21,10 +20,10 @@ export class NoteError extends Error {
21
20
  this.edit_index = extra.edit_index;
22
21
  }
23
22
  }
24
- const SCOPE_ORDER = ["session", "project", "personal"];
23
+ const SCOPE_ORDER = ["session", "project", "human", "agent", "model"];
25
24
  function assertScope(value) {
26
25
  if (!isScope(value))
27
- throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
26
+ throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
28
27
  return value;
29
28
  }
30
29
  function assertOrigin(value) {
@@ -38,8 +37,13 @@ function walkMarkdown(dir, base = dir) {
38
37
  try {
39
38
  entries = readdirSync(dir, { withFileTypes: true });
40
39
  }
41
- catch {
42
- return [];
40
+ catch (error) {
41
+ // A home that has never been created is normal. Every other directory
42
+ // failure must reach the boot snapshot boundary instead of masquerading as
43
+ // an empty home.
44
+ if (typeof error === "object" && error !== null && error.code === "ENOENT")
45
+ return [];
46
+ throw error;
43
47
  }
44
48
  const paths = [];
45
49
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
@@ -87,6 +91,52 @@ function assertSerializedSize(content) {
87
91
  function frontmatterOf(meta) {
88
92
  return serializeNote(meta, "").slice(0, -2);
89
93
  }
94
+ /** Named agent/model homes are read-only to whoever is not running there. */
95
+ function assertWritableHome(scope, who, ctx) {
96
+ if (who === undefined)
97
+ return;
98
+ const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
99
+ if (who === current)
100
+ return;
101
+ const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
102
+ throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
103
+ }
104
+ function homesForPattern(pattern) {
105
+ if (!pattern || !pattern.startsWith("@"))
106
+ return undefined;
107
+ const head = /^@([^/]+)\//.exec(pattern)?.[1];
108
+ if (head === "project")
109
+ return [{ scope: "project" }];
110
+ if (head === "human")
111
+ return [{ scope: "human" }];
112
+ if (head === "self")
113
+ return [{ scope: "agent" }];
114
+ if (head === "model")
115
+ return [{ scope: "model" }];
116
+ if (head === "agents" || head === "models") {
117
+ const scope = head === "agents" ? "agent" : "model";
118
+ const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
119
+ if (name.length > 0 && !/[*?]/.test(name))
120
+ return [{ scope, who: name }];
121
+ return namespaceSlugs(head).map((who) => ({ scope, who }));
122
+ }
123
+ return [];
124
+ }
125
+ /** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
126
+ function normalizePattern(pattern, ctx) {
127
+ if (!pattern)
128
+ return pattern;
129
+ if (pattern.startsWith("@self/"))
130
+ return `@agents/${agentSlug(ctx)}/${pattern.slice("@self/".length)}`;
131
+ if (pattern.startsWith("@model/"))
132
+ return `@models/${modelSlug(ctx)}/${pattern.slice("@model/".length)}`;
133
+ return pattern;
134
+ }
135
+ function homesFor(ctx, opts) {
136
+ if (opts.scope !== undefined)
137
+ return [{ scope: opts.scope, who: opts.who }];
138
+ return homesForPattern(opts.pattern) ?? SCOPE_ORDER.map((scope) => ({ scope }));
139
+ }
90
140
  /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
91
141
  function matchLineNumbers(body, needle) {
92
142
  const lines = [];
@@ -105,8 +155,9 @@ export function writeNote(ctx, vpath, body, opts) {
105
155
  assertVirtualPath(vpath);
106
156
  assertWritablePath(vpath);
107
157
  const scope = assertScope(opts.scope);
158
+ assertWritableHome(scope, opts.who, ctx);
108
159
  const origin = assertOrigin(opts.origin);
109
- const path = physicalPath(scope, vpath, ctx);
160
+ const path = physicalPath(scope, vpath, ctx, opts.who);
110
161
  const now = Date.now();
111
162
  const cleanBody = stripLeadingFrontmatter(body);
112
163
  const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
@@ -131,9 +182,9 @@ export function writeNote(ctx, vpath, body, opts) {
131
182
  return { meta };
132
183
  }
133
184
  /** Dream harness mutation: metadata changes still use the store's atomic writer. */
134
- export function updateNoteMeta(ctx, vpath, scope, mutate) {
185
+ export function updateNoteMeta(ctx, vpath, scope, mutate, who) {
135
186
  assertVirtualPath(vpath);
136
- const path = physicalPath(scope, vpath, ctx);
187
+ const path = physicalPath(scope, vpath, ctx, who);
137
188
  if (!existsSync(path))
138
189
  throw new NoteError("not_found", `note not found: ${vpath}`);
139
190
  const parsed = parseNote(readFileSync(path, "utf8"));
@@ -146,14 +197,15 @@ export function updateNoteMeta(ctx, vpath, scope, mutate) {
146
197
  return { meta, body: parsed.body };
147
198
  }
148
199
  /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
149
- export function editNote(ctx, vpath, scope, edits, opts = {}) {
200
+ export function editNote(ctx, vpath, scope, edits, opts = {}, who) {
150
201
  assertVirtualPath(vpath);
151
202
  assertWritablePath(vpath);
203
+ assertWritableHome(scope, who, ctx);
152
204
  const operations = edits ?? [];
153
205
  if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
154
206
  throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
155
207
  }
156
- const path = physicalPath(scope, vpath, ctx);
208
+ const path = physicalPath(scope, vpath, ctx, who);
157
209
  if (!existsSync(path))
158
210
  throw new NoteError("not_found", "note not found");
159
211
  const raw = readFileSync(path, "utf8");
@@ -216,9 +268,9 @@ function accessedMeta(meta, scope, now) {
216
268
  return next;
217
269
  }
218
270
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
219
- export function readNote(ctx, vpath, scope) {
271
+ export function readNote(ctx, vpath, scope, who) {
220
272
  assertVirtualPath(vpath);
221
- const path = physicalPath(scope, vpath, ctx);
273
+ const path = physicalPath(scope, vpath, ctx, who);
222
274
  if (!existsSync(path))
223
275
  return undefined;
224
276
  const now = Date.now();
@@ -230,12 +282,13 @@ export function readNote(ctx, vpath, scope) {
230
282
  }
231
283
  /** Merged rows across homes, most recently updated first (address breaks ties). */
232
284
  export function listNotes(ctx, opts = {}) {
233
- const matcher = matcherFor(opts.pattern);
285
+ const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
234
286
  const rows = [];
235
- for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
236
- const root = scopeDir(scope, ctx);
287
+ for (const home of homesFor(ctx, opts)) {
288
+ const scope = home.scope;
289
+ const root = scopeDir(scope, ctx, home.who);
237
290
  for (const path of walkMarkdown(root)) {
238
- const address = addressFor(scope, path);
291
+ const address = addressFor(ctx, scope, path, home.who);
239
292
  if (matcher && !matcher.test(address))
240
293
  continue;
241
294
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
@@ -248,12 +301,13 @@ export function listNotes(ctx, opts = {}) {
248
301
  }
249
302
  /** Case-sensitive literal substring search over note bodies, with a match address per line. */
250
303
  export function searchNotes(ctx, queries, opts = {}) {
251
- const matcher = matcherFor(opts.pattern);
304
+ const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
252
305
  const rows = [];
253
- for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
254
- const root = scopeDir(scope, ctx);
306
+ for (const home of homesFor(ctx, opts)) {
307
+ const scope = home.scope;
308
+ const root = scopeDir(scope, ctx, home.who);
255
309
  for (const path of walkMarkdown(root)) {
256
- const address = addressFor(scope, path);
310
+ const address = addressFor(ctx, scope, path, home.who);
257
311
  if (matcher && !matcher.test(address))
258
312
  continue;
259
313
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));