@astrosheep/pi-context 0.20.0 → 0.21.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 (53) hide show
  1. package/dist/src/budget.js +10 -8
  2. package/dist/src/dream/cli.js +9 -8
  3. package/dist/src/dream/gates.js +2 -1
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/runner.js +84 -25
  6. package/dist/src/history-tools.js +5 -5
  7. package/dist/src/history.js +11 -6
  8. package/dist/src/index.js +14 -15
  9. package/dist/src/notes/address.js +31 -0
  10. package/dist/src/{memory → notes}/frontmatter.js +5 -3
  11. package/dist/src/{notes.js → notes/model.js} +1 -1
  12. package/dist/src/{memory → notes}/paths.js +5 -1
  13. package/dist/src/{memory → notes}/store.js +45 -72
  14. package/dist/src/notes/tools.js +153 -0
  15. package/dist/src/prompts.js +31 -29
  16. package/dist/src/protocol.js +8 -4
  17. package/dist/src/thresholds.js +4 -1
  18. package/dist/src/tool-output.js +4 -1
  19. package/dist/src/warning.js +3 -3
  20. package/dist/test/agent-loop.test.js +6 -4
  21. package/dist/test/coherence.test.js +5 -1
  22. package/dist/test/dream.test.js +133 -34
  23. package/dist/test/history.test.js +6 -1
  24. package/dist/test/integration.test.js +84 -34
  25. package/dist/test/{memory.test.js → notes.test.js} +138 -34
  26. package/dist/test/pagination.property.test.js +1 -1
  27. package/package.json +5 -5
  28. package/playbook.md +30 -3
  29. package/src/budget.ts +11 -9
  30. package/src/dream/cli.ts +8 -8
  31. package/src/dream/gates.ts +2 -1
  32. package/src/dream/git.ts +27 -0
  33. package/src/dream/runner.ts +81 -23
  34. package/src/history-tools.ts +5 -5
  35. package/src/history.ts +12 -7
  36. package/src/index.ts +13 -14
  37. package/src/notes/address.ts +33 -0
  38. package/src/{memory → notes}/frontmatter.ts +5 -3
  39. package/src/{notes.ts → notes/model.ts} +2 -2
  40. package/src/{memory → notes}/paths.ts +6 -1
  41. package/src/{memory → notes}/store.ts +47 -77
  42. package/src/notes/tools.ts +132 -0
  43. package/src/prompts.ts +31 -29
  44. package/src/protocol.ts +8 -4
  45. package/src/thresholds.ts +4 -1
  46. package/src/tool-output.ts +4 -1
  47. package/src/warning.ts +3 -3
  48. package/dist/src/dream/apply.js +0 -87
  49. package/dist/src/dream/manifest.js +0 -16
  50. package/dist/src/memory/tools.js +0 -175
  51. package/src/dream/apply.ts +0 -47
  52. package/src/dream/manifest.ts +0 -21
  53. package/src/memory/tools.ts +0 -175
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
4
4
  import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
 
@@ -53,7 +53,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
53
53
  if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
54
54
  const badWindow = unknownWindowId(ctx, params);
55
55
  if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
56
- const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
56
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS));
57
57
  return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
58
58
  },
59
59
  }));
@@ -62,7 +62,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
62
62
  name: "history_read",
63
63
  label: "History read item",
64
64
  description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
65
- parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })), window_id: Type.String() }, { additionalProperties: false }),
65
+ parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })), window_id: Type.String() }, { additionalProperties: false }),
66
66
  async execute(_id, params, _signal, _update, ctx) {
67
67
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
68
68
  if (!item) return output({ error: "unknown item_id or window_id" });
@@ -72,7 +72,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
72
72
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
73
73
  return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
74
74
  }
75
- const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
75
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
76
76
  return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
77
77
  const { content, ...cursor } = window;
78
78
  return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
@@ -93,7 +93,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
93
93
  const queries = searchQueries(params.query);
94
94
  const matching = filteredItems(ctx, params)
95
95
  .filter((item) => queries.some((query) => item.content.includes(query)))
96
- .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
96
+ .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
97
97
  return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
98
98
  },
99
99
  }));
package/src/history.ts CHANGED
@@ -2,6 +2,7 @@ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { SessionReader } from "./session-reader.js";
4
4
  import { RESET_V2 } from "./protocol.js";
5
+ import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
5
6
 
6
7
  type HistoryItem = {
7
8
  windowId: string;
@@ -29,9 +30,9 @@ function isTextContent(part: unknown): part is TextContent {
29
30
  return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
30
31
  }
31
32
 
32
- function contentText(content: string | unknown[]): string {
33
+ export function contentText(content: unknown): string {
33
34
  if (typeof content === "string") return content;
34
- return content.filter(isTextContent).map((part) => part.text).join("\n");
35
+ return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
35
36
  }
36
37
 
37
38
  function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
@@ -102,14 +103,19 @@ export function resetV2WindowId(details: unknown): string | undefined {
102
103
  }
103
104
 
104
105
  /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
105
- function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
106
+ export function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
106
107
  return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
107
108
  }
108
109
 
110
+ /** Mint the durable identity of a session's root history window. */
111
+ export function rootWindowId(sessionId: string): string {
112
+ return `pcw:${sessionId.slice(0, 8)}:root`;
113
+ }
114
+
109
115
  /** Build durable, on-demand history directly from every entry on the current session branch. */
110
116
  export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
111
117
  const sessionId = ctx.sessionManager.getSessionId();
112
- let window: HistoryWindow = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
118
+ let window: HistoryWindow = { windowId: rootWindowId(sessionId), items: [] };
113
119
  const windows = [window];
114
120
  for (const entry of ctx.sessionManager.getBranch()) {
115
121
  if (entry.type === "compaction") {
@@ -153,7 +159,7 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
153
159
  return windows;
154
160
  }
155
161
 
156
- export function visibleItem(item: HistoryItem, maxChars = 1200) {
162
+ export function visibleItem(item: HistoryItem, maxChars = HISTORY_PREVIEW_CHARS) {
157
163
  const characters = Array.from(item.content);
158
164
  const truncated = characters.length > maxChars;
159
165
  return {
@@ -230,6 +236,5 @@ export function currentWindowId(ctx: SessionReader): string {
230
236
  const entry = branch[i];
231
237
  if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
232
238
  }
233
- return `pcw:${sessionId.slice(0, 8)}:root`;
239
+ return rootWindowId(sessionId);
234
240
  }
235
-
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { registerHistoryTools } from "./history-tools.js";
2
- import { registerMemoryTools } from "./memory/tools.js";
3
- import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
2
+ import { registerNotesTools } from "./notes/tools.js";
3
+ import { registerBudget } from "./budget.js";
4
4
  import { output } from "./tool-output.js";
5
- export { deriveThresholds, mergePiContextSettings };
5
+ import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
6
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 } from "./history.js";
8
- import { assertVirtualPath } from "./notes.js";
7
+ import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId, rootWindowId, windowIdOf } from "./history.js";
8
+ import { assertVirtualPath } from "./notes/model.js";
9
9
  import { bootBlock } from "./prompts.js";
10
10
  export { historyFromSession } from "./history.js";
11
- export { notesFromSession } from "./notes.js";
11
+ export { notesFromSession } from "./notes/model.js";
12
12
  import { registerResetLifecycle } from "./reset-lifecycle.js";
13
13
  import { registerWarning } from "./warning.js";
14
14
  import { randomUUID } from "node:crypto";
@@ -25,8 +25,7 @@ export default function piContext(pi: ExtensionAPI) {
25
25
  // The root window has no compaction entry to carry the boot block, so persist
26
26
  // it once as a hidden custom message. Reset windows already carry theirs at
27
27
  // position 0 in the compaction summary, so a resumed session adds nothing.
28
- const sessionId = ctx.sessionManager.getSessionId();
29
- const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
28
+ const rootId = rootWindowId(ctx.sessionManager.getSessionId());
30
29
  if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
31
30
  pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
32
31
  });
@@ -48,7 +47,7 @@ export default function piContext(pi: ExtensionAPI) {
48
47
  });
49
48
 
50
49
  registerHistoryTools(pi);
51
- registerMemoryTools(pi);
50
+ registerNotesTools(pi);
52
51
 
53
52
  pi.registerTool(defineTool({
54
53
  name: "new_context",
@@ -70,15 +69,15 @@ export default function piContext(pi: ExtensionAPI) {
70
69
  },
71
70
  onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
72
71
  buildReset: (event, ctx, explicit) => {
73
- const session8 = ctx.sessionManager.getSessionId().slice(0, 8);
72
+ const sessionId = ctx.sessionManager.getSessionId();
74
73
  // Window IDs are independent of Pi entry IDs. Avoid reusing a window
75
74
  // identity already present on this branch.
76
75
  const windows = historyFromSession(ctx);
77
76
  const usedIds = new Set(windows.map((window) => window.windowId));
78
- let minted = randomUUID().slice(0, 8);
79
- while (usedIds.has(`pcw:${session8}:${minted}`)) minted = randomUUID().slice(0, 8);
80
- const windowId = `pcw:${session8}:${minted}`;
81
- const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${session8}:root`;
77
+ let minted = { id: randomUUID().slice(0, 8) };
78
+ while (usedIds.has(windowIdOf(sessionId, minted))) minted = { id: randomUUID().slice(0, 8) };
79
+ const windowId = windowIdOf(sessionId, minted);
80
+ const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
82
81
  // The reset marker stays as firstKeptEntryId; it no longer names the window.
83
82
  pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
84
83
  const markerId = ctx.sessionManager.getLeafId();
@@ -0,0 +1,33 @@
1
+ import { assertVirtualPath } from "./model.js";
2
+ import type { Scope } from "./paths.js";
3
+
4
+ export type NoteAddress = { scope: Scope; path: string };
5
+
6
+ const ADDRESS_FORMS = "legal prefixes are @project/ and @global/; bare names are the session home";
7
+
8
+ /**
9
+ * Decode the one public note address into its physical home and virtual path. This is a
10
+ * tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
11
+ */
12
+ export function assertAddress(value: unknown): NoteAddress {
13
+ if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
14
+ let scope: Scope = "session";
15
+ let path = value;
16
+ if (value.startsWith("@project/")) {
17
+ scope = "project";
18
+ path = value.slice("@project/".length);
19
+ } else if (value.startsWith("@global/")) {
20
+ scope = "global";
21
+ path = value.slice("@global/".length);
22
+ } else if (value.startsWith("@")) {
23
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
24
+ }
25
+ if (path.includes("@")) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
26
+ assertVirtualPath(path);
27
+ return { scope, path };
28
+ }
29
+
30
+ /** Render a virtual path in its one unambiguous public address form. */
31
+ export function addressFor(scope: Scope, path: string): string {
32
+ return scope === "session" ? path : `@${scope}/${path}`;
33
+ }
@@ -1,4 +1,4 @@
1
- import { localIso } from "../notes.js";
1
+ import { localIso } from "./model.js";
2
2
  import type { Scope } from "./paths.js";
3
3
 
4
4
  export type NoteStatus = "active" | "superseded" | "pending" | "archived";
@@ -30,7 +30,7 @@ const ORIGINS: readonly Origin[] = ["user", "self", "external"];
30
30
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
31
31
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
32
32
  /** Emission order, exactly the Design's key list. */
33
- const KNOWN_KEYS = ["scope", "origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
33
+ const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
34
34
 
35
35
  export function isScope(value: unknown): value is Scope {
36
36
  return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
@@ -140,7 +140,9 @@ export function serializeNote(meta: NoteMeta, body: string): string {
140
140
  else lines.push(`${key}: ${yamlScalar(value)}`);
141
141
  }
142
142
  for (const key of Object.keys(meta)) {
143
- if ((KNOWN_KEYS as readonly string[]).includes(key)) continue;
143
+ // scope is a legacy on-disk field. Store callers derive it from the home's location,
144
+ // but serialization intentionally drops it on the next write.
145
+ if (key === "scope" || (KNOWN_KEYS as readonly string[]).includes(key)) continue;
144
146
  if (meta[key] === undefined) continue;
145
147
  lines.push(`${key}: ${yamlScalar(meta[key])}`);
146
148
  }
@@ -1,5 +1,5 @@
1
- import type { SessionReader } from "./session-reader.js";
2
- import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
1
+ import type { SessionReader } from "../session-reader.js";
2
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
3
3
 
4
4
  export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
5
5
  export type NoteOperation = {
@@ -12,6 +12,11 @@ export function notesRoot(): string {
12
12
  return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
13
13
  }
14
14
 
15
+ /** Absolute directory holding the per-session note homes. */
16
+ export function sessionHomesRoot(home = notesRoot()): string {
17
+ return join(home, "pi", "session");
18
+ }
19
+
15
20
  /**
16
21
  * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
17
22
  * No git root yields undefined, which projectKey then replaces with the cwd itself.
@@ -43,7 +48,7 @@ function sessionId(ctx: ExtensionContext): string {
43
48
  export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
44
49
  if (scope === "global") return join(notesRoot(), "global");
45
50
  if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
46
- return join(notesRoot(), "pi", "session", sessionId(ctx));
51
+ return join(sessionHomesRoot(), sessionId(ctx));
47
52
  }
48
53
 
49
54
  /**
@@ -3,10 +3,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
3
3
  import { dirname } from "node:path";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { generateDiffString } from "@earendil-works/pi-coding-agent";
6
- import { assertGlobPattern, assertVirtualPath, globToRegExp } from "../notes.js";
6
+ import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
7
7
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
8
8
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
9
+ import { addressFor } from "./address.js";
9
10
  import { physicalPath, scopeDir, type Scope } from "./paths.js";
11
+ import { earliestMatchOffsetChars } from "../tool-output.js";
10
12
 
11
13
  export type { NoteMeta, Origin, Scope };
12
14
 
@@ -26,9 +28,9 @@ export class NoteError extends Error {
26
28
  }
27
29
  }
28
30
 
29
- export type NoteRow = { path: string; meta: NoteMeta; sizeBytes: number };
31
+ export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteMeta; body: string; sizeBytes: number };
30
32
  export type NoteMatch = { line: number; text: string; offsetChars: number };
31
- export type NoteSearchRow = { path: string; scope: Scope; meta: NoteMeta; matches: NoteMatch[] };
33
+ export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
32
34
 
33
35
  const SCOPE_ORDER: readonly Scope[] = ["session", "project", "global"];
34
36
 
@@ -42,22 +44,6 @@ function assertOrigin(value: unknown): Origin {
42
44
  return value;
43
45
  }
44
46
 
45
- function scopeList(scope?: unknown): Scope[] {
46
- if (scope === undefined || scope === null) return [...SCOPE_ORDER];
47
- return [assertScope(scope)];
48
- }
49
-
50
- type Resolved = { scope: Scope; path: string; raw: string };
51
-
52
- /** First existing file by precedence session → project → global, or only `scope` when given. */
53
- function resolve(ctx: ExtensionContext, vpath: string, scope?: unknown): Resolved | undefined {
54
- for (const candidate of scopeList(scope)) {
55
- const path = physicalPath(candidate, vpath, ctx);
56
- if (existsSync(path)) return { scope: candidate, path, raw: readFileSync(path, "utf8") };
57
- }
58
- return undefined;
59
- }
60
-
61
47
  /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
62
48
  function walkMarkdown(dir: string, base = dir): string[] {
63
49
  let entries: Dirent[];
@@ -160,7 +146,7 @@ export function writeNote(ctx: ExtensionContext, vpath: string, body: string, op
160
146
  }
161
147
 
162
148
  export type EditOperation = { oldText: string; newText: string };
163
- export type EditOptions = { scope?: Scope; origin?: Origin; stale?: boolean; replaceAll?: boolean };
149
+ export type EditOptions = { origin?: Origin; stale?: boolean; replaceAll?: boolean };
164
150
 
165
151
  /** Dream harness mutation: metadata changes still use the store's atomic writer. */
166
152
  export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void): { meta: NoteMeta; body: string } {
@@ -177,17 +163,19 @@ export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scop
177
163
  return { meta, body: parsed.body };
178
164
  }
179
165
 
180
- /** Apply body-only edits against one snapshot, then optionally move via the scope/origin/stale setters. */
181
- export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
166
+ /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
167
+ export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
182
168
  assertVirtualPath(vpath);
183
169
  assertWritablePath(vpath);
184
170
  const operations = edits ?? [];
185
- if (operations.length === 0 && opts.scope === undefined && opts.origin === undefined && opts.stale === undefined) {
186
- throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of scope, origin, stale");
171
+ if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
172
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
187
173
  }
188
- const found = resolve(ctx, vpath);
189
- if (!found) throw new NoteError("not_found", "note not found");
190
- const { meta, body } = parseNote(found.raw);
174
+ const path = physicalPath(scope, vpath, ctx);
175
+ if (!existsSync(path)) throw new NoteError("not_found", "note not found");
176
+ const raw = readFileSync(path, "utf8");
177
+ const { meta, body } = parseNote(raw);
178
+ meta.scope = scope;
191
179
  // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
192
180
  const beforeMeta: NoteMeta = { ...meta };
193
181
  // Every edit runs against this one snapshot; nothing is written until all of them succeed,
@@ -203,79 +191,65 @@ export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperat
203
191
  if (lines.length > 1 && !opts.replaceAll) {
204
192
  throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { line_numbers: lines, edit_index: index });
205
193
  }
206
- next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
194
+ // Single replacement is positional splicing, never String.replace: user text must be
195
+ // inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
196
+ if (opts.replaceAll) {
197
+ next = next.split(oldText).join(newText);
198
+ } else {
199
+ const matchIndex = next.indexOf(oldText);
200
+ next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
201
+ }
207
202
  });
208
- const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
209
203
  if (opts.origin !== undefined) meta.origin = assertOrigin(opts.origin);
210
204
  if (opts.stale !== undefined) meta.stale = opts.stale;
211
- meta.scope = destScope;
212
205
  meta.updated_at = Date.now();
213
- const dest = physicalPath(destScope, vpath, ctx);
214
- const moving = dest !== found.path;
215
- if (moving && existsSync(dest)) {
216
- throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
217
- }
218
206
  const serialized = serializeNote(meta, next);
219
207
  assertSerializedSize(serialized);
220
208
  // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
221
- // update, one combined file diff when both moved.
209
+ // update, one combined file diff when both change.
222
210
  const bodyChanged = body !== next;
223
- const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
211
+ const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
224
212
  const diff = bodyChanged && metadataChanged
225
- ? generateDiffString(found.raw, serialized).diff
213
+ ? generateDiffString(raw, serialized).diff
226
214
  : bodyChanged
227
215
  ? generateDiffString(body, next).diff
228
216
  : metadataChanged
229
217
  ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
230
218
  : "";
231
- atomicWrite(dest, serialized);
232
- if (moving) rmSync(found.path);
233
- return { meta, applied: operations.length, resolved_scope: found.scope, diff };
219
+ atomicWrite(path, serialized);
220
+ return { meta, applied: operations.length, resolved_scope: scope, diff };
234
221
  }
235
222
 
236
223
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
237
- export function readNote(ctx: ExtensionContext, vpath: string, opts: { scope?: Scope } = {}): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
224
+ export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
238
225
  assertVirtualPath(vpath);
239
- const found = resolve(ctx, vpath, opts.scope);
240
- if (!found) return undefined;
226
+ const path = physicalPath(scope, vpath, ctx);
227
+ if (!existsSync(path)) return undefined;
241
228
  const now = Date.now();
242
- const { meta, body } = parseNote(found.raw, now);
243
- meta.scope = found.scope;
229
+ const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
230
+ meta.scope = scope;
244
231
  // Only the two access keys move; updated_at and every other key keep their bytes.
245
232
  meta.last_accessed = now;
246
233
  meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
247
- atomicWrite(found.path, serializeNote(meta, body));
248
- return { meta, body, resolvedScope: found.scope };
249
- }
250
-
251
- /** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
252
- export function resolveNoteScope(ctx: ExtensionContext, vpath: string, scope?: Scope): { scope: Scope; path: string } | undefined {
253
- const found = resolve(ctx, vpath, scope);
254
- return found ? { scope: found.scope, path: found.path } : undefined;
255
- }
256
-
257
- /** Read a note's meta and body without the read side effect (used by the boot index). */
258
- export function peekNote(ctx: ExtensionContext, scope: Scope, vpath: string): { meta: NoteMeta; body: string } {
259
- const path = physicalPath(scope, vpath, ctx);
260
- const { meta, body } = parseNote(readFileSync(path, "utf8"));
261
- meta.scope = scope;
262
- return { meta, body };
234
+ atomicWrite(path, serializeNote(meta, body));
235
+ return { meta, body, resolvedScope: scope };
263
236
  }
264
237
 
265
- /** Merged rows across scopes, most recently updated first (path then scope break ties). */
238
+ /** Merged rows across homes, most recently updated first (address breaks ties). */
266
239
  export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
267
240
  const matcher = matcherFor(opts.pattern);
268
241
  const rows: NoteRow[] = [];
269
- for (const scope of scopeList(opts.scope)) {
242
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
270
243
  const root = scopeDir(scope, ctx);
271
244
  for (const path of walkMarkdown(root)) {
272
- if (matcher && !matcher.test(path)) continue;
245
+ const address = addressFor(scope, path);
246
+ if (matcher && !matcher.test(address)) continue;
273
247
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
274
248
  meta.scope = scope;
275
- rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
249
+ rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
276
250
  }
277
251
  }
278
- rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.path.localeCompare(b.path) || a.meta.scope.localeCompare(b.meta.scope));
252
+ rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
279
253
  return rows;
280
254
  }
281
255
 
@@ -283,28 +257,24 @@ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?
283
257
  export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
284
258
  const matcher = matcherFor(opts.pattern);
285
259
  const rows: NoteSearchRow[] = [];
286
- for (const scope of scopeList(opts.scope)) {
260
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
287
261
  const root = scopeDir(scope, ctx);
288
262
  for (const path of walkMarkdown(root)) {
289
- if (matcher && !matcher.test(path)) continue;
263
+ const address = addressFor(scope, path);
264
+ if (matcher && !matcher.test(address)) continue;
290
265
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
291
266
  meta.scope = scope;
292
267
  let baseChars = 0;
293
268
  const matches: NoteMatch[] = [];
294
269
  for (const [index, line] of body.split("\n").entries()) {
295
270
  if (queries.some((query) => line.includes(query))) {
296
- let earliest = -1;
297
- for (const query of queries) {
298
- const found = line.indexOf(query);
299
- if (found >= 0 && (earliest < 0 || found < earliest)) earliest = found;
300
- }
301
- matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
271
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
302
272
  }
303
273
  baseChars += Array.from(line).length + 1;
304
274
  }
305
- if (matches.length > 0) rows.push({ path, scope, meta, matches });
275
+ if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
306
276
  }
307
277
  }
308
- rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
278
+ rows.sort((a, b) => a.address.localeCompare(b.address));
309
279
  return rows;
310
280
  }
@@ -0,0 +1,132 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { localIso } from "./model.js";
4
+ import { characterWindowHeader, DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
5
+ import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
+ import { assertAddress } from "./address.js";
7
+ import { serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
8
+ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
9
+
10
+ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
11
+ description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
12
+ }));
13
+ const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@global/<vpath>` for the global home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@global/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
14
+
15
+ function wireMeta(meta: NoteMeta): Record<string, unknown> {
16
+ return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
17
+ }
18
+
19
+ function failure(error: unknown) {
20
+ if (error instanceof NoteError) {
21
+ const payload: Record<string, unknown> = { error: error.message };
22
+ if (error.line_numbers) payload.line_numbers = error.line_numbers;
23
+ if (error.edit_index !== undefined) payload.edit_index = error.edit_index;
24
+ return output(payload);
25
+ }
26
+ throw error;
27
+ }
28
+
29
+ export function registerNotesTools(pi: ExtensionAPI) {
30
+ pi.registerTool(defineTool({
31
+ name: "notes_write", label: "Notes write",
32
+ description: `Create or replace a note as a real markdown file, and name it for what it holds: a fresh window sees only an index entry, never the note itself. ${ADDRESS_DESCRIPTION} Keep notes small and split by topic — by what the note is about, never by who said it (authorship is origin's job); a rewrite replaces the body whole while preserving created_at and every other frontmatter key. stale: true marks the note closed so it leaves the boot index but stays readable and searchable.`,
33
+ parameters: Type.Object({ address: Type.String(), content: Type.String(), origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
34
+ async execute(_id, params, _signal, _update, ctx) {
35
+ const content = params.content;
36
+ try {
37
+ const destination = assertAddress(params.address);
38
+ const { meta } = writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
39
+ return output({ address: params.address, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
40
+ } catch (error) { return failure(error); }
41
+ },
42
+ }));
43
+
44
+ pi.registerTool(defineTool({
45
+ name: "notes_edit", label: "Notes edit",
46
+ description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries resolved_scope and a diff of what changed.`,
47
+ parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
48
+ async execute(_id, params, _signal, _update, ctx) {
49
+ try {
50
+ const destination = assertAddress(params.address);
51
+ const { meta, applied, resolved_scope, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
52
+ return output({ address: params.address, applied, resolved_scope, diff, meta: wireMeta(meta) });
53
+ } catch (error) { return failure(error); }
54
+ },
55
+ }));
56
+
57
+ pi.registerTool(defineTool({
58
+ name: "notes_read", label: "Notes read",
59
+ description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the address, the resolved offset, the delivered char range, and the resume cursor.`,
60
+ parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
61
+ async execute(_id, params, _signal, _update, ctx) {
62
+ let note: ReturnType<typeof readNote>;
63
+ try {
64
+ const destination = assertAddress(params.address);
65
+ note = readNote(ctx, destination.path, destination.scope);
66
+ } catch (error) { return failure(error); }
67
+ if (!note) return output({ error: "note not found", address: params.address });
68
+ const text = serializeNote(note.meta, note.body);
69
+ const totalChars = Array.from(text).length;
70
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
71
+ const created_at = localIso(note.meta.created_at);
72
+ const updated_at = localIso(note.meta.updated_at);
73
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
74
+ return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
75
+ const { content, ...rest } = window;
76
+ return outputRaw(characterWindowHeader(params.address, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { address: params.address, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
77
+ }, (result) => withinTextBudget(result.content[0].text));
78
+ },
79
+ }));
80
+
81
+ pi.registerTool(defineTool({
82
+ name: "notes_list", label: "Notes list",
83
+ description: `List note files as rows carrying address, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
84
+ parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
85
+ async execute(_id, params, _signal, _update, ctx) {
86
+ let rows: ReturnType<typeof listNotes>;
87
+ try { rows = listNotes(ctx, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
88
+ const files: Array<{ address: string; scope: string; origin: Origin; status: string; stale: boolean; size_bytes: number; created_at: string; updated_at: string; address_truncated?: boolean }> = rows.map((row) => ({ address: row.address, scope: row.scope, origin: row.meta.origin, status: row.meta.status, stale: row.meta.stale, size_bytes: row.sizeBytes, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at) }));
89
+ return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
90
+ if (fits(file)) return file;
91
+ const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
92
+ return { ...file, address, address_truncated: true };
93
+ }));
94
+ },
95
+ }));
96
+
97
+ pi.registerTool(defineTool({
98
+ name: "notes_search", label: "Notes search",
99
+ description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address and derived scope. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).`,
100
+ parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
101
+ async execute(_id, params, _signal, _update, ctx) {
102
+ const queries = searchQueries(params.query);
103
+ let rows: ReturnType<typeof searchNotes>;
104
+ try { rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
105
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
106
+ const result: Array<{ address: string; scope: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; address_truncated?: boolean }> = rows.map((row) => {
107
+ const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
108
+ return { address: row.address, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
109
+ });
110
+ const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
111
+ if (fits(file)) return file;
112
+ const matches = file.matches;
113
+ let low = 0;
114
+ let high = matches.length;
115
+ while (low < high) {
116
+ const mid = Math.ceil((low + high) / 2);
117
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
118
+ else high = mid - 1;
119
+ }
120
+ if (low >= 1) return { ...file, matches: matches.slice(0, low) };
121
+ const first = matches[0]!;
122
+ const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
123
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
124
+ const prefix = fitted(text);
125
+ if (fits(prefix)) return prefix;
126
+ const address = middleTruncate(prefix.address, (candidate) => fits({ ...prefix, address: candidate, address_truncated: true }));
127
+ return { ...prefix, address, address_truncated: true };
128
+ };
129
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
130
+ },
131
+ }));
132
+ }