@astrosheep/pi-context 0.25.2 → 0.26.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 (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +613 -367
  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 +16 -12
  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 +298 -242
  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/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +309 -10
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +19 -11
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -0,0 +1,16 @@
1
+ import { type PiContextSettings } from "../settings.js";
2
+ export type DreamerSetting = {
3
+ pattern?: string;
4
+ warnings: string[];
5
+ };
6
+ /**
7
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
8
+ * with one warning; absent means no configured pattern, so the automatic model applies.
9
+ */
10
+ export declare function deriveDreamer(settings: PiContextSettings): DreamerSetting;
11
+ /**
12
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
13
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
14
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
15
+ */
16
+ export declare function readDreamerSettings(cwd?: string): DreamerSetting;
@@ -0,0 +1,2 @@
1
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export declare function registerHistoryTools(pi: ExtensionAPI): void;
@@ -0,0 +1,57 @@
1
+ import type { SessionReader } from "../session-reader.js";
2
+ type HistoryItem = {
3
+ windowId: string;
4
+ itemId: string;
5
+ role: "user" | "assistant" | "tool_call" | "tool" | "system" | "developer";
6
+ content: string;
7
+ createdAt: string | undefined;
8
+ toolName?: string;
9
+ outputTruncated?: boolean;
10
+ fullOutputPath?: string;
11
+ toolError?: boolean;
12
+ };
13
+ type HistoryWindow = {
14
+ windowId: string;
15
+ createdAt?: string;
16
+ items: HistoryItem[];
17
+ };
18
+ type HistoryFilter = {
19
+ window_id?: string | null;
20
+ role?: HistoryItem["role"] | null;
21
+ tool_name?: string | null;
22
+ recent_first?: boolean;
23
+ };
24
+ export declare function contentText(content: unknown): string;
25
+ /** Build durable, on-demand history directly from every entry on the current session branch. */
26
+ export declare function historyFromSession(ctx: SessionReader): HistoryWindow[];
27
+ export declare function visibleItem(item: HistoryItem, maxChars?: number): {
28
+ truncated: boolean;
29
+ total_chars: number;
30
+ truncated_content: string;
31
+ tool_error?: boolean | undefined;
32
+ output_truncated?: boolean | undefined;
33
+ full_output_path?: string | null | undefined;
34
+ window_id: string;
35
+ item_id: string;
36
+ role: "user" | "assistant" | "tool_call" | "tool" | "system" | "developer";
37
+ tool_name: string | null;
38
+ };
39
+ export declare function allItems(ctx: SessionReader): HistoryItem[];
40
+ /**
41
+ * window_id must name a real window; anything else is a named error, not a silent empty page
42
+ * (a window that exists but has no matching items after the other filters stays a legal empty
43
+ * page). Returns the teaching message plus the known window ids so the error is self-healing.
44
+ */
45
+ export declare function unknownWindowId(ctx: SessionReader, params: HistoryFilter): {
46
+ message: string;
47
+ known: string[];
48
+ } | undefined;
49
+ /**
50
+ * A role×tool_name combination is vacuous — provably empty from the taxonomy alone, before
51
+ * any data is read — when tool_name is given alongside a role that never carries one. Only
52
+ * "tool_call" and "tool" items have a tool name. Returns the teaching error message, or
53
+ * undefined when the combination can match.
54
+ */
55
+ export declare function vacuousRoleToolCombo(params: HistoryFilter): string | undefined;
56
+ export declare function filteredItems(ctx: SessionReader, params: HistoryFilter): HistoryItem[];
57
+ export {};
@@ -0,0 +1,39 @@
1
+ import { type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { deriveThresholds } from "./context/thresholds.js";
3
+ import { mergePiContextSettings } from "./settings.js";
4
+ import { assertVirtualPath } from "./notes/address.js";
5
+ export { historyFromSession } from "./history/history.js";
6
+ export { notesFromSession } from "./pi/notes/session-replay.js";
7
+ /**
8
+ * Create an extension factory bound to an SDK settings authority. The host must pass
9
+ * the same manager to createAgentSession and to this factory's resource loader.
10
+ */
11
+ export declare function createPiContext(options?: {
12
+ settingsManager?: SettingsManager;
13
+ }): ExtensionFactory;
14
+ /** The Pi-discovered extension keeps the standard file-backed settings behavior. */
15
+ export default function piContext(pi: ExtensionAPI): void;
16
+ export declare const internal: {
17
+ MAX_NOTE_BYTES: number;
18
+ NOTE_TYPE: string;
19
+ BOOT_TYPE: string;
20
+ GUIDANCE_TYPE: string;
21
+ WARNING_TYPE: string;
22
+ CONTINUATION_TYPE: string;
23
+ WARNING_PROMPT: string;
24
+ WARNING_CONTENT: string;
25
+ WARNING_RUNWAY_TOKENS: number;
26
+ RESET_MARKER_TYPE: string;
27
+ CONTINUATION: string;
28
+ CONTEXT_WINDOW_OPEN_TAG: string;
29
+ CONTEXT_WINDOW_CLOSE_TAG: string;
30
+ CONTEXT_WINDOW_PROTOCOL_OPEN_TAG: string;
31
+ CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG: string;
32
+ GUIDANCE_OPEN_TAG: string;
33
+ PI_CONTEXT_SETTINGS_KEY: string;
34
+ DEFAULT_RESERVE_TOKENS: number;
35
+ DEFAULT_REMINDER_MARGIN_TOKENS: number;
36
+ deriveThresholds: typeof deriveThresholds;
37
+ mergePiContextSettings: typeof mergePiContextSettings;
38
+ assertVirtualPath: typeof assertVirtualPath;
39
+ };
package/dist/src/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { VERSION } 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
  function registerPiContext(pi, settingsManager) {
12
12
  const [major, minor] = VERSION.split(".").map(Number);
13
13
  if (!(major > 0 || (major === 0 && minor >= 87))) {
@@ -28,4 +28,4 @@ export function createPiContext(options = {}) {
28
28
  export default function piContext(pi) {
29
29
  registerPiContext(pi);
30
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 };
31
+ 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 };
@@ -0,0 +1,26 @@
1
+ import type { NotesContext } from "./context.js";
2
+ import { type Scope } from "./paths.js";
3
+ export type NoteAddress = {
4
+ scope: Scope;
5
+ path: string;
6
+ who?: string;
7
+ };
8
+ export declare const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
9
+ export declare function assertVirtualPath(value: unknown): string;
10
+ /**
11
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
12
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
13
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
14
+ * one non-`/` character. Everything else is literal and the match is anchored to the
15
+ * whole path.
16
+ */
17
+ export declare function globToRegExp(pattern: string): RegExp;
18
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard. */
19
+ export declare function assertGlobPattern(value: unknown): string | undefined;
20
+ /**
21
+ * Decode one public note address into its physical home and virtual path. @self and @model
22
+ * are relative; explicit agents/models addresses always name a canonical slug.
23
+ */
24
+ export declare function assertAddress(value: unknown): NoteAddress;
25
+ /** Render a virtual path in its one unambiguous public address form. */
26
+ export declare function addressFor(context: NotesContext, scope: Scope, path: string, who?: string): string;
@@ -1,4 +1,4 @@
1
- import { agentSlug, modelSlug, SLUG_PATTERN } from "./paths.js";
1
+ import { SLUG_PATTERN } from "./paths.js";
2
2
  export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
3
3
  export function assertVirtualPath(value) {
4
4
  if (typeof value !== "string" || value.length === 0)
@@ -37,7 +37,7 @@ export function globToRegExp(pattern) {
37
37
  }
38
38
  return new RegExp(`${source}$`);
39
39
  }
40
- /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
40
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard. */
41
41
  export function assertGlobPattern(value) {
42
42
  if (value === undefined || value === null || value === "")
43
43
  return undefined;
@@ -48,12 +48,8 @@ export function assertGlobPattern(value) {
48
48
  return value;
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) {
59
55
  if (typeof value !== "string")
@@ -96,10 +92,8 @@ export function assertAddress(value) {
96
92
  assertVirtualPath(path);
97
93
  return { scope, path, who };
98
94
  }
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) {
95
+ /** Render a virtual path in its one unambiguous public address form. */
96
+ export function addressFor(context, scope, path, who) {
103
97
  if (scope === "session")
104
98
  return path;
105
99
  if (scope === "project")
@@ -107,6 +101,6 @@ export function addressFor(ctx, scope, path, who) {
107
101
  if (scope === "human")
108
102
  return `@human/${path}`;
109
103
  if (scope === "agent")
110
- return `@agents/${who ?? agentSlug(ctx)}/${path}`;
111
- return `@models/${who ?? modelSlug(ctx)}/${path}`;
104
+ return `@agents/${who ?? context.agent}/${path}`;
105
+ return `@models/${who ?? context.model}/${path}`;
112
106
  }
@@ -0,0 +1,3 @@
1
+ export declare const MAX_NOTE_BYTES = 1000000;
2
+ /** Write-time cap on a virtual note path. Reads and legacy replay stay uncapped. */
3
+ export declare const MAX_NOTE_PATH_BYTES = 512;
@@ -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,10 @@
1
+ /** Explicit, host-neutral identity for one notes store. */
2
+ export type NotesContext = Readonly<{
3
+ home: string;
4
+ sessionId: string;
5
+ projectKey: string;
6
+ agent: string;
7
+ model: string;
8
+ }>;
9
+ /** Validate and snapshot caller identity; no global or environment defaults are consulted. */
10
+ export declare function snapshotNotesContext(value: NotesContext): NotesContext;
@@ -0,0 +1,33 @@
1
+ import { resolve } from "node:path";
2
+ import { SLUG_PATTERN } from "./paths.js";
3
+ function requireString(field, value) {
4
+ if (typeof value !== "string" || value.length === 0)
5
+ throw new TypeError(`notes context ${field} must be a non-empty string`);
6
+ return value;
7
+ }
8
+ function assertDirectoryComponent(field, value) {
9
+ if (value === "." || value === ".." || /[\\/\0:]/.test(value)) {
10
+ throw new TypeError(`notes context ${field} must be a safe single directory component`);
11
+ }
12
+ }
13
+ /** Validate and snapshot caller identity; no global or environment defaults are consulted. */
14
+ export function snapshotNotesContext(value) {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value))
16
+ throw new TypeError("notes context must be a plain object");
17
+ const prototype = Object.getPrototypeOf(value);
18
+ if (prototype !== Object.prototype && prototype !== null)
19
+ throw new TypeError("notes context must be a plain object");
20
+ const homeValue = requireString("home", value.home);
21
+ const sessionId = requireString("sessionId", value.sessionId);
22
+ const projectKey = requireString("projectKey", value.projectKey);
23
+ const agent = requireString("agent", value.agent);
24
+ const model = requireString("model", value.model);
25
+ assertDirectoryComponent("sessionId", sessionId);
26
+ assertDirectoryComponent("projectKey", projectKey);
27
+ if (!SLUG_PATTERN.test(agent))
28
+ throw new TypeError("notes context agent must be a canonical lowercase slug");
29
+ if (!SLUG_PATTERN.test(model))
30
+ throw new TypeError("notes context model must be a canonical lowercase slug");
31
+ const home = resolve(homeValue);
32
+ return Object.freeze({ home, sessionId, projectKey, agent, model });
33
+ }
@@ -0,0 +1,46 @@
1
+ import type { Scope } from "./paths.js";
2
+ export type NoteStatus = "active" | "superseded" | "pending" | "archived";
3
+ export type Origin = "user" | "self" | "external";
4
+ /**
5
+ * Harness-owned note metadata. The eight required keys are always written; the four optional
6
+ * sleep-shift keys survive this layer untouched when present. `[key: string]: unknown` carries
7
+ * any frontmatter key this layer does not know, preserved verbatim across rewrites.
8
+ */
9
+ export type NoteMeta = {
10
+ scope: Scope;
11
+ origin: Origin;
12
+ status: NoteStatus;
13
+ stale: boolean;
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ lastAccessed: number;
17
+ accessCount: number;
18
+ sourceWindow?: string;
19
+ supersedes?: string;
20
+ recurrenceCount?: number;
21
+ recurrenceWindows?: string[];
22
+ /** Project ownership on newly-created session notes; legacy/invalid values are preserved as-is. */
23
+ project?: unknown;
24
+ [key: string]: unknown;
25
+ };
26
+ /**
27
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
28
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
29
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
30
+ */
31
+ export declare function localIso(epochMs: number): string;
32
+ export declare function isScope(value: unknown): value is Scope;
33
+ export declare function isOrigin(value: unknown): value is Origin;
34
+ /**
35
+ * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
36
+ * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
37
+ * snake_case metadata is refused because it requires the explicit manual migration.
38
+ */
39
+ export declare function parseNote(raw: string, now?: number): {
40
+ meta: NoteMeta;
41
+ body: string;
42
+ };
43
+ /** Serialize frontmatter + blank line + body. Known keys emit in Design order, extras after. */
44
+ export declare function serializeNote(meta: NoteMeta, body: string): string;
45
+ /** Strip a leading frontmatter block from user content, so a note body is pure content. */
46
+ export declare function stripLeadingFrontmatter(content: string): string;
@@ -1,9 +1,10 @@
1
1
  const SCOPES = ["session", "project", "human", "agent", "model"];
2
2
  const ORIGINS = ["user", "self", "external"];
3
3
  const STATUSES = ["active", "superseded", "pending", "archived"];
4
- const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
4
+ const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
5
+ const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
5
6
  /** Emission order, exactly the Design's key list. */
6
- const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
7
+ const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
7
8
  const pad2 = (value) => String(value).padStart(2, "0");
8
9
  /**
9
10
  * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
@@ -69,7 +70,7 @@ function parseFrontmatter(raw) {
69
70
  }
70
71
  if (close === -1)
71
72
  return { fields: {}, body: raw };
72
- const fields = {};
73
+ const fields = Object.create(null);
73
74
  for (let index = 1; index < close; index++) {
74
75
  const line = lines[index];
75
76
  const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
@@ -97,10 +98,14 @@ function parseFrontmatter(raw) {
97
98
  }
98
99
  /**
99
100
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
100
- * access_count 0, timestamps now); unknown keys are carried through untouched.
101
+ * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
102
+ * snake_case metadata is refused because it requires the explicit manual migration.
101
103
  */
102
104
  export function parseNote(raw, now = Date.now()) {
103
105
  const { fields, body } = parseFrontmatter(raw);
106
+ const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
107
+ if (legacyKeys.length > 0)
108
+ throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
104
109
  const meta = { ...fields };
105
110
  // scope is a legacy on-disk field: store callers derive it from the file's home and
106
111
  // overwrite it after parsing, so an absent or outdated value just falls back.
@@ -110,7 +115,7 @@ export function parseNote(raw, now = Date.now()) {
110
115
  meta.stale = meta.stale === true;
111
116
  for (const key of TIMESTAMP_KEYS)
112
117
  meta[key] = toEpoch(meta[key], now);
113
- meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
118
+ meta.accessCount = typeof meta.accessCount === "number" && Number.isFinite(meta.accessCount) ? meta.accessCount : 0;
114
119
  return { meta: meta, body };
115
120
  }
116
121
  /** Emit a YAML scalar: bare for safe strings and JSON literals, JSON-quoted otherwise. */
@@ -0,0 +1,4 @@
1
+ export { createNotesStore, NoteError } from "./store.js";
2
+ export { projectKey, slugify } from "./paths.js";
3
+ export type { NotesContext } from "./context.js";
4
+ export type { EditOperation, EditOptions, NoteChange, NoteEditResult, NoteErrorCode, NoteMatch, NoteMeta, NoteReadResult, NoteRow, NoteSearchRow, NoteStatus, NoteWriteResult, NotesQuery, NotesStore, Origin, Scope, WriteOptions, } from "./store.js";
@@ -0,0 +1,2 @@
1
+ export { createNotesStore, NoteError } from "./store.js";
2
+ export { projectKey, slugify } from "./paths.js";
@@ -0,0 +1,21 @@
1
+ import type { NotesContext } from "./context.js";
2
+ export type Scope = "session" | "project" | "human" | "agent" | "model";
3
+ /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
4
+ export declare const SLUG_PATTERN: RegExp;
5
+ /** Slugify a caller-declared identity; identity is never inferred from note content. */
6
+ export declare function slugify(value: string): string;
7
+ /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
8
+ export declare function projectKey(cwd: string): string;
9
+ /** Absolute directory holding the per-session note homes. */
10
+ export declare function sessionHomesRoot(home: string): string;
11
+ /** Absolute directory holding one scope's notes. The context's home is already resolved. */
12
+ export declare function scopeDir(scope: Scope, context: NotesContext, who?: string): string;
13
+ /** Every existing home directory of the agents/ or models/ namespace, as names. */
14
+ export declare function namespaceSlugs(namespace: "agents" | "models", home: string): Promise<string[]>;
15
+ /**
16
+ * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
17
+ * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
18
+ */
19
+ export declare function noteFileName(vpath: string): string;
20
+ /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
21
+ export declare function physicalPath(scope: Scope, vpath: string, context: NotesContext, who?: string): string;
@@ -1,99 +1,91 @@
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
- /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
6
- export function notesRoot() {
7
- const override = process.env.PI_NOTES_HOME;
8
- return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
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
+ /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
6
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ /** Slugify a caller-declared identity; identity is never inferred from note content. */
8
+ export function slugify(value) {
9
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10
+ return slug.length > 0 ? slug : "root";
9
11
  }
10
- /** Absolute directory holding the per-session note homes. */
11
- export function sessionHomesRoot(home = notesRoot()) {
12
- return join(home, "pi", "session");
12
+ /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
13
+ export function projectKey(cwd) {
14
+ const absolute = resolve(cwd);
15
+ const root = gitRoot(absolute) ?? absolute;
16
+ const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
17
+ return `${basename(root)}-${digest}`;
13
18
  }
14
19
  /**
15
- * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
16
- * No git root yields undefined, which projectKey then replaces with the cwd itself.
20
+ * Repository root behind one `.git` entry. A `.git` directory is the main checkout
21
+ * itself. A `.git` file is a worktree or submodule pointer: a linked worktree names
22
+ * `<main>/.git/worktrees/<name>` and resolves to `<main>`, so every worktree of one
23
+ * repository shares one project identity. Submodules, bare repositories, and separate
24
+ * git dirs keep the current directory.
17
25
  */
26
+ function repositoryRoot(dir) {
27
+ let stats;
28
+ try {
29
+ stats = statSync(join(dir, ".git"));
30
+ }
31
+ catch {
32
+ return dir;
33
+ }
34
+ if (stats.isDirectory())
35
+ return dir;
36
+ let pointer;
37
+ try {
38
+ pointer = readFileSync(join(dir, ".git"), "utf8");
39
+ }
40
+ catch {
41
+ return dir;
42
+ }
43
+ const match = /^gitdir:\s*(.+)$/m.exec(pointer);
44
+ if (!match)
45
+ return dir;
46
+ const parts = resolve(dir, match[1].trim()).split(sep);
47
+ const worktrees = parts.lastIndexOf("worktrees");
48
+ if (worktrees <= 0 || worktrees !== parts.length - 2)
49
+ return dir;
50
+ const common = parts.slice(0, worktrees).join(sep);
51
+ return basename(common) === ".git" ? dirname(common) : dir;
52
+ }
53
+ /** Absolute repository root for cwd, walking upward until a directory holds a `.git` entry. */
18
54
  function gitRoot(cwd) {
19
55
  let dir = resolve(cwd);
20
56
  for (;;) {
21
57
  if (existsSync(join(dir, ".git")))
22
- return dir;
58
+ return repositoryRoot(dir);
23
59
  const parent = dirname(dir);
24
60
  if (parent === dir)
25
61
  return undefined;
26
62
  dir = parent;
27
63
  }
28
64
  }
29
- /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
30
- export function projectKey(cwd) {
31
- const absolute = resolve(cwd);
32
- const root = gitRoot(absolute) ?? absolute;
33
- const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
34
- return `${basename(root)}-${digest}`;
35
- }
36
- /** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
37
- function sessionId(ctx) {
38
- return ctx.sessionManager.getSessionId();
39
- }
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);
65
+ /** Absolute directory holding the per-session note homes. */
66
+ export function sessionHomesRoot(home) {
67
+ return join(home, "pi", "session");
61
68
  }
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) {
69
+ /** Absolute directory holding one scope's notes. The context's home is already resolved. */
70
+ export function scopeDir(scope, context, who) {
71
+ if (!["session", "project", "human", "agent", "model"].includes(scope))
72
+ throw new TypeError("invalid notes scope");
73
+ if (who !== undefined && (scope !== "agent" && scope !== "model" || !SLUG_PATTERN.test(who)))
74
+ throw new TypeError("who must be a canonical agent/model slug");
68
75
  if (scope === "human")
69
- return join(notesRoot(), "human");
76
+ return join(context.home, "human");
70
77
  if (scope === "project")
71
- return join(notesRoot(), "project", projectKey(ctx.cwd));
78
+ return join(context.home, "project", context.projectKey);
72
79
  if (scope === "agent")
73
- return join(notesRoot(), "agents", who ?? agentSlug(ctx));
80
+ return join(context.home, "agents", who ?? context.agent);
74
81
  if (scope === "model")
75
- return join(notesRoot(), "models", who ?? modelSlug(ctx));
76
- return join(sessionHomesRoot(), sessionId(ctx));
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;
82
+ return join(context.home, "models", who ?? context.model);
83
+ return join(sessionHomesRoot(context.home), context.sessionId);
92
84
  }
93
- /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
94
- export function namespaceSlugs(namespace, home = notesRoot()) {
85
+ /** Every existing home directory of the agents/ or models/ namespace, as names. */
86
+ export async function namespaceSlugs(namespace, home) {
95
87
  try {
96
- return readdirSync(join(home, namespace), { withFileTypes: true })
88
+ return (await readdir(join(home, namespace), { withFileTypes: true }))
97
89
  .filter((entry) => entry.isDirectory())
98
90
  .map((entry) => entry.name)
99
91
  .sort();
@@ -110,6 +102,10 @@ export function noteFileName(vpath) {
110
102
  return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
111
103
  }
112
104
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
113
- export function physicalPath(scope, vpath, ctx, who) {
114
- return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
105
+ export function physicalPath(scope, vpath, context, who) {
106
+ if (typeof vpath !== "string" || vpath.length === 0 || vpath.includes("\0") || vpath.includes("\\") || vpath.startsWith("/"))
107
+ throw new TypeError("path must be a safe virtual relative path");
108
+ if (vpath.split("/").some((part) => part.length === 0 || part === "." || part === ".."))
109
+ throw new TypeError("path contains an unsupported component");
110
+ return join(scopeDir(scope, context, who), ...noteFileName(vpath).split("/"));
115
111
  }