@astrosheep/pi-context 0.25.1 → 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 +616 -370
  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 +19 -15
  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 +318 -19
  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 +25 -17
  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
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { VERSION, type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import { registerHistoryTools } from "./history/history-tools.js";
3
- import { registerNotesTools } from "./notes/tools.js";
3
+ import { registerNotesTools } from "./pi/notes/tools.js";
4
4
  import { deriveThresholds } from "./context/thresholds.js";
5
5
  import { registerContext } from "./context/runtime.js";
6
6
  import { mergePiContextSettings } from "./settings.js";
7
- import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
+ import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, CONTINUATION, WARNING_PROMPT, WARNING_CONTENT } from "./protocol.js";
8
8
  import { assertVirtualPath } from "./notes/address.js";
9
9
  export { historyFromSession } from "./history/history.js";
10
- export { notesFromSession } from "./notes/session-replay.js";
10
+ export { notesFromSession } from "./pi/notes/session-replay.js";
11
11
 
12
12
  function registerPiContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
13
13
  const [major, minor] = VERSION.split(".").map(Number);
@@ -32,4 +32,4 @@ export default function piContext(pi: ExtensionAPI): void {
32
32
  registerPiContext(pi);
33
33
  }
34
34
 
35
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
35
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_CONTENT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
@@ -1,5 +1,5 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
1
+ import type { NotesContext } from "./context.js";
2
+ import { SLUG_PATTERN, type Scope } from "./paths.js";
3
3
 
4
4
  export type NoteAddress = { scope: Scope; path: string; who?: string };
5
5
 
@@ -39,7 +39,7 @@ export function globToRegExp(pattern: string): RegExp {
39
39
  return new RegExp(`${source}$`);
40
40
  }
41
41
 
42
- /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
42
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard. */
43
43
  export function assertGlobPattern(value: unknown): string | undefined {
44
44
  if (value === undefined || value === null || value === "") return undefined;
45
45
  if (typeof value !== "string") throw new Error("glob pattern must be a string");
@@ -48,12 +48,8 @@ export function assertGlobPattern(value: unknown): string | undefined {
48
48
  }
49
49
 
50
50
  /**
51
- * Decode the one public note address into its physical home and virtual path. This is a
52
- * tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
53
- * The word after `@` is always a reserved home name; agent and model names live at the
54
- * second level (@agents/faye/, never @faye/), so user-chosen names can never collide with
55
- * the reserved set. `@self` and `@model` are relative — `who` stays undefined and the
56
- * store resolves the current agent/model at call time.
51
+ * Decode one public note address into its physical home and virtual path. @self and @model
52
+ * are relative; explicit agents/models addresses always name a canonical slug.
57
53
  */
58
54
  export function assertAddress(value: unknown): NoteAddress {
59
55
  if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
@@ -87,13 +83,11 @@ export function assertAddress(value: unknown): NoteAddress {
87
83
  return { scope, path, who };
88
84
  }
89
85
 
90
- /** Render a virtual path in its one unambiguous public address form. Relative forms
91
- * (@self/, @model/) never render: the canonical address always carries the resolved
92
- * name, so listings alone tell every home apart. */
93
- export function addressFor(ctx: ExtensionContext, scope: Scope, path: string, who?: string): string {
86
+ /** Render a virtual path in its one unambiguous public address form. */
87
+ export function addressFor(context: NotesContext, scope: Scope, path: string, who?: string): string {
94
88
  if (scope === "session") return path;
95
89
  if (scope === "project") return `@project/${path}`;
96
90
  if (scope === "human") return `@human/${path}`;
97
- if (scope === "agent") return `@agents/${who ?? agentSlug(ctx)}/${path}`;
98
- return `@models/${who ?? modelSlug(ctx)}/${path}`;
91
+ if (scope === "agent") return `@agents/${who ?? context.agent}/${path}`;
92
+ return `@models/${who ?? context.model}/${path}`;
99
93
  }
@@ -0,0 +1,3 @@
1
+ export const MAX_NOTE_BYTES = 1_000_000;
2
+ /** Write-time cap on a virtual note path. Reads and legacy replay stay uncapped. */
3
+ export const MAX_NOTE_PATH_BYTES = 512;
@@ -0,0 +1,40 @@
1
+ import { resolve } from "node:path";
2
+ import { SLUG_PATTERN } from "./paths.js";
3
+
4
+ /** Explicit, host-neutral identity for one notes store. */
5
+ export type NotesContext = Readonly<{
6
+ home: string;
7
+ sessionId: string;
8
+ projectKey: string;
9
+ agent: string;
10
+ model: string;
11
+ }>;
12
+
13
+ function requireString(field: string, value: unknown): string {
14
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`notes context ${field} must be a non-empty string`);
15
+ return value;
16
+ }
17
+
18
+ function assertDirectoryComponent(field: string, value: string): void {
19
+ if (value === "." || value === ".." || /[\\/\0:]/.test(value)) {
20
+ throw new TypeError(`notes context ${field} must be a safe single directory component`);
21
+ }
22
+ }
23
+
24
+ /** Validate and snapshot caller identity; no global or environment defaults are consulted. */
25
+ export function snapshotNotesContext(value: NotesContext): NotesContext {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("notes context must be a plain object");
27
+ const prototype = Object.getPrototypeOf(value);
28
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("notes context must be a plain object");
29
+ const homeValue = requireString("home", value.home);
30
+ const sessionId = requireString("sessionId", value.sessionId);
31
+ const projectKey = requireString("projectKey", value.projectKey);
32
+ const agent = requireString("agent", value.agent);
33
+ const model = requireString("model", value.model);
34
+ assertDirectoryComponent("sessionId", sessionId);
35
+ assertDirectoryComponent("projectKey", projectKey);
36
+ if (!SLUG_PATTERN.test(agent)) throw new TypeError("notes context agent must be a canonical lowercase slug");
37
+ if (!SLUG_PATTERN.test(model)) throw new TypeError("notes context model must be a canonical lowercase slug");
38
+ const home = resolve(homeValue);
39
+ return Object.freeze({ home, sessionId, projectKey, agent, model });
40
+ }
@@ -13,23 +13,26 @@ export type NoteMeta = {
13
13
  origin: Origin;
14
14
  status: NoteStatus;
15
15
  stale: boolean;
16
- created_at: number;
17
- updated_at: number;
18
- last_accessed: number;
19
- access_count: number;
20
- source_window?: string;
16
+ createdAt: number;
17
+ updatedAt: number;
18
+ lastAccessed: number;
19
+ accessCount: number;
20
+ sourceWindow?: string;
21
21
  supersedes?: string;
22
- recurrence_count?: number;
23
- recurrence_windows?: string[];
22
+ recurrenceCount?: number;
23
+ recurrenceWindows?: string[];
24
+ /** Project ownership on newly-created session notes; legacy/invalid values are preserved as-is. */
25
+ project?: unknown;
24
26
  [key: string]: unknown;
25
27
  };
26
28
 
27
29
  const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model"];
28
30
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
29
31
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
30
- const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
32
+ const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"] as const;
33
+ const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"] as const;
31
34
  /** Emission order, exactly the Design's key list. */
32
- const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
35
+ const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"] as const;
33
36
 
34
37
  const pad2 = (value: number) => String(value).padStart(2, "0");
35
38
 
@@ -96,7 +99,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
96
99
  }
97
100
  }
98
101
  if (close === -1) return { fields: {}, body: raw };
99
- const fields: Record<string, unknown> = {};
102
+ const fields = Object.create(null) as Record<string, unknown>;
100
103
  for (let index = 1; index < close; index++) {
101
104
  const line = lines[index]!;
102
105
  const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
@@ -122,10 +125,13 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
122
125
 
123
126
  /**
124
127
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
125
- * access_count 0, timestamps now); unknown keys are carried through untouched.
128
+ * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
129
+ * snake_case metadata is refused because it requires the explicit manual migration.
126
130
  */
127
131
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
128
132
  const { fields, body } = parseFrontmatter(raw);
133
+ const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
134
+ if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
129
135
  const meta = { ...fields } as Record<string, unknown>;
130
136
  // scope is a legacy on-disk field: store callers derive it from the file's home and
131
137
  // overwrite it after parsing, so an absent or outdated value just falls back.
@@ -134,7 +140,7 @@ export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body
134
140
  meta.status = isStatus(meta.status) ? meta.status : "active";
135
141
  meta.stale = meta.stale === true;
136
142
  for (const key of TIMESTAMP_KEYS) meta[key] = toEpoch(meta[key], now);
137
- meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
143
+ meta.accessCount = typeof meta.accessCount === "number" && Number.isFinite(meta.accessCount) ? meta.accessCount : 0;
138
144
  return { meta: meta as NoteMeta, body };
139
145
  }
140
146
 
@@ -0,0 +1,22 @@
1
+ export { createNotesStore, NoteError } from "./store.js";
2
+ export { projectKey, slugify } from "./paths.js";
3
+ export type { NotesContext } from "./context.js";
4
+ export type {
5
+ EditOperation,
6
+ EditOptions,
7
+ NoteChange,
8
+ NoteEditResult,
9
+ NoteErrorCode,
10
+ NoteMatch,
11
+ NoteMeta,
12
+ NoteReadResult,
13
+ NoteRow,
14
+ NoteSearchRow,
15
+ NoteStatus,
16
+ NoteWriteResult,
17
+ NotesQuery,
18
+ NotesStore,
19
+ Origin,
20
+ Scope,
21
+ WriteOptions,
22
+ } from "./store.js";
@@ -1,34 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readdirSync, renameSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
5
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
+ import type { NotesContext } from "./context.js";
6
6
 
7
7
  export type Scope = "session" | "project" | "human" | "agent" | "model";
8
8
 
9
- /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
10
- export function notesRoot(): string {
11
- const override = process.env.PI_NOTES_HOME;
12
- return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
13
- }
14
-
15
- /** Absolute directory holding the per-session note homes. */
16
- export function sessionHomesRoot(home = notesRoot()): string {
17
- return join(home, "pi", "session");
18
- }
9
+ /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
10
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
11
 
20
- /**
21
- * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
22
- * No git root yields undefined, which projectKey then replaces with the cwd itself.
23
- */
24
- function gitRoot(cwd: string): string | undefined {
25
- let dir = resolve(cwd);
26
- for (;;) {
27
- if (existsSync(join(dir, ".git"))) return dir;
28
- const parent = dirname(dir);
29
- if (parent === dir) return undefined;
30
- dir = parent;
31
- }
12
+ /** Slugify a caller-declared identity; identity is never inferred from note content. */
13
+ export function slugify(value: string): string {
14
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
+ return slug.length > 0 ? slug : "root";
32
16
  }
33
17
 
34
18
  /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
@@ -39,67 +23,67 @@ export function projectKey(cwd: string): string {
39
23
  return `${basename(root)}-${digest}`;
40
24
  }
41
25
 
42
- /** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
43
- function sessionId(ctx: ExtensionContext): string {
44
- return ctx.sessionManager.getSessionId();
45
- }
46
-
47
- /** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
48
- export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
49
-
50
26
  /**
51
- * Identity slugs: one declared name per home, never detected from prompt content.
52
- * `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
53
- * from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
27
+ * Repository root behind one `.git` entry. A `.git` directory is the main checkout
28
+ * itself. A `.git` file is a worktree or submodule pointer: a linked worktree names
29
+ * `<main>/.git/worktrees/<name>` and resolves to `<main>`, so every worktree of one
30
+ * repository shares one project identity. Submodules, bare repositories, and separate
31
+ * git dirs keep the current directory.
54
32
  */
55
- export function slugify(value: string): string {
56
- const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57
- return slug.length > 0 ? slug : "root";
58
- }
59
-
60
- /** The current agent's home name: the launch-declared identity, defaulting to "root". */
61
- export function agentSlug(_ctx: ExtensionContext): string {
62
- return slugify(process.env.PI_NOTES_AGENT ?? "root");
33
+ function repositoryRoot(dir: string): string {
34
+ let stats;
35
+ try {
36
+ stats = statSync(join(dir, ".git"));
37
+ } catch {
38
+ return dir;
39
+ }
40
+ if (stats.isDirectory()) return dir;
41
+ let pointer: string;
42
+ try {
43
+ pointer = readFileSync(join(dir, ".git"), "utf8");
44
+ } catch {
45
+ return dir;
46
+ }
47
+ const match = /^gitdir:\s*(.+)$/m.exec(pointer);
48
+ if (!match) return dir;
49
+ const parts = resolve(dir, match[1]!.trim()).split(sep);
50
+ const worktrees = parts.lastIndexOf("worktrees");
51
+ if (worktrees <= 0 || worktrees !== parts.length - 2) return dir;
52
+ const common = parts.slice(0, worktrees).join(sep);
53
+ return basename(common) === ".git" ? dirname(common) : dir;
63
54
  }
64
55
 
65
- /** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
66
- export function modelSlug(ctx: ExtensionContext): string {
67
- const id = ctx.model?.id;
68
- if (!id) return "default";
69
- return slugify(id.split("/").pop() ?? id);
56
+ /** Absolute repository root for cwd, walking upward until a directory holds a `.git` entry. */
57
+ function gitRoot(cwd: string): string | undefined {
58
+ let dir = resolve(cwd);
59
+ for (;;) {
60
+ if (existsSync(join(dir, ".git"))) return repositoryRoot(dir);
61
+ const parent = dirname(dir);
62
+ if (parent === dir) return undefined;
63
+ dir = parent;
64
+ }
70
65
  }
71
66
 
72
- /**
73
- * Absolute directory holding every note of one scope. `who` names an agent or model
74
- * home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
75
- * model live from ctx.model).
76
- */
77
- export function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string {
78
- if (scope === "human") return join(notesRoot(), "human");
79
- if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
80
- if (scope === "agent") return join(notesRoot(), "agents", who ?? agentSlug(ctx));
81
- if (scope === "model") return join(notesRoot(), "models", who ?? modelSlug(ctx));
82
- return join(sessionHomesRoot(), sessionId(ctx));
67
+ /** Absolute directory holding the per-session note homes. */
68
+ export function sessionHomesRoot(home: string): string {
69
+ return join(home, "pi", "session");
83
70
  }
84
71
 
85
- /**
86
- * One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
87
- * activation; returns a warning string when both directories exist (no auto-merge),
88
- * undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
89
- */
90
- export function migrateLegacyHomes(home = notesRoot()): string | undefined {
91
- const legacy = join(home, "personal");
92
- const modern = join(home, "human");
93
- if (!existsSync(legacy)) return undefined;
94
- if (existsSync(modern)) return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
95
- renameSync(legacy, modern);
96
- return undefined;
72
+ /** Absolute directory holding one scope's notes. The context's home is already resolved. */
73
+ export function scopeDir(scope: Scope, context: NotesContext, who?: string): string {
74
+ if (!["session", "project", "human", "agent", "model"].includes(scope)) throw new TypeError("invalid notes scope");
75
+ if (who !== undefined && (scope !== "agent" && scope !== "model" || !SLUG_PATTERN.test(who))) throw new TypeError("who must be a canonical agent/model slug");
76
+ if (scope === "human") return join(context.home, "human");
77
+ if (scope === "project") return join(context.home, "project", context.projectKey);
78
+ if (scope === "agent") return join(context.home, "agents", who ?? context.agent);
79
+ if (scope === "model") return join(context.home, "models", who ?? context.model);
80
+ return join(sessionHomesRoot(context.home), context.sessionId);
97
81
  }
98
82
 
99
- /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
100
- export function namespaceSlugs(namespace: "agents" | "models", home = notesRoot()): string[] {
83
+ /** Every existing home directory of the agents/ or models/ namespace, as names. */
84
+ export async function namespaceSlugs(namespace: "agents" | "models", home: string): Promise<string[]> {
101
85
  try {
102
- return readdirSync(join(home, namespace), { withFileTypes: true })
86
+ return (await readdir(join(home, namespace), { withFileTypes: true }))
103
87
  .filter((entry) => entry.isDirectory())
104
88
  .map((entry) => entry.name)
105
89
  .sort();
@@ -117,6 +101,8 @@ export function noteFileName(vpath: string): string {
117
101
  }
118
102
 
119
103
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
120
- export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext, who?: string): string {
121
- return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
104
+ export function physicalPath(scope: Scope, vpath: string, context: NotesContext, who?: string): string {
105
+ if (typeof vpath !== "string" || vpath.length === 0 || vpath.includes("\0") || vpath.includes("\\") || vpath.startsWith("/")) throw new TypeError("path must be a safe virtual relative path");
106
+ if (vpath.split("/").some((part) => part.length === 0 || part === "." || part === "..")) throw new TypeError("path contains an unsupported component");
107
+ return join(scopeDir(scope, context, who), ...noteFileName(vpath).split("/"));
122
108
  }