@astrosheep/pi-context 0.20.0 → 0.22.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 (56) hide show
  1. package/README.md +22 -1
  2. package/dist/src/budget.js +10 -8
  3. package/dist/src/dream/cli.js +108 -24
  4. package/dist/src/dream/gates.js +13 -8
  5. package/dist/src/dream/git.js +71 -0
  6. package/dist/src/dream/lock.js +78 -37
  7. package/dist/src/dream/runner.js +90 -21
  8. package/dist/src/history-tools.js +5 -5
  9. package/dist/src/history.js +11 -6
  10. package/dist/src/index.js +14 -15
  11. package/dist/src/notes/address.js +31 -0
  12. package/dist/src/{memory → notes}/frontmatter.js +7 -5
  13. package/dist/src/{notes.js → notes/model.js} +1 -1
  14. package/dist/src/{memory → notes}/paths.js +7 -3
  15. package/dist/src/{memory → notes}/store.js +47 -74
  16. package/dist/src/notes/tools.js +153 -0
  17. package/dist/src/prompts.js +38 -29
  18. package/dist/src/protocol.js +9 -4
  19. package/dist/src/thresholds.js +33 -3
  20. package/dist/src/tool-output.js +4 -1
  21. package/dist/src/warning.js +3 -3
  22. package/dist/test/agent-loop.test.js +6 -4
  23. package/dist/test/coherence.test.js +5 -1
  24. package/dist/test/dream.test.js +419 -35
  25. package/dist/test/history.test.js +6 -1
  26. package/dist/test/integration.test.js +107 -47
  27. package/dist/test/{memory.test.js → notes.test.js} +154 -50
  28. package/dist/test/pagination.property.test.js +1 -1
  29. package/package.json +5 -5
  30. package/playbook.md +30 -3
  31. package/src/budget.ts +11 -9
  32. package/src/dream/cli.ts +95 -17
  33. package/src/dream/gates.ts +14 -7
  34. package/src/dream/git.ts +73 -0
  35. package/src/dream/lock.ts +67 -24
  36. package/src/dream/runner.ts +87 -20
  37. package/src/history-tools.ts +5 -5
  38. package/src/history.ts +12 -7
  39. package/src/index.ts +13 -14
  40. package/src/notes/address.ts +33 -0
  41. package/src/{memory → notes}/frontmatter.ts +7 -5
  42. package/src/{notes.ts → notes/model.ts} +2 -2
  43. package/src/{memory → notes}/paths.ts +8 -3
  44. package/src/{memory → notes}/store.ts +49 -79
  45. package/src/notes/tools.ts +132 -0
  46. package/src/prompts.ts +39 -29
  47. package/src/protocol.ts +9 -4
  48. package/src/thresholds.ts +38 -6
  49. package/src/tool-output.ts +4 -1
  50. package/src/warning.ts +3 -3
  51. package/dist/src/dream/apply.js +0 -87
  52. package/dist/src/dream/manifest.js +0 -16
  53. package/dist/src/memory/tools.js +0 -175
  54. package/src/dream/apply.ts +0 -47
  55. package/src/dream/manifest.ts +0 -21
  56. package/src/memory/tools.ts +0 -175
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } 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
  /**
@@ -57,7 +57,7 @@ export function registerHistoryTools(pi) {
57
57
  const badWindow = unknownWindowId(ctx, params);
58
58
  if (badWindow)
59
59
  return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
60
- const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
60
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS));
61
61
  return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
62
62
  },
63
63
  }));
@@ -65,7 +65,7 @@ export function registerHistoryTools(pi) {
65
65
  name: "history_read",
66
66
  label: "History read item",
67
67
  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).",
68
- 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 }),
68
+ 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 }),
69
69
  async execute(_id, params, _signal, _update, ctx) {
70
70
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
71
71
  if (!item)
@@ -76,7 +76,7 @@ export function registerHistoryTools(pi) {
76
76
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
77
77
  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 });
78
78
  }
79
- const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
79
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
80
80
  return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
81
81
  const { content, ...cursor } = window;
82
82
  return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
@@ -98,7 +98,7 @@ export function registerHistoryTools(pi) {
98
98
  const queries = searchQueries(params.query);
99
99
  const matching = filteredItems(ctx, params)
100
100
  .filter((item) => queries.some((query) => item.content.includes(query)))
101
- .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
101
+ .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
102
102
  return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
103
103
  },
104
104
  }));
@@ -1,11 +1,12 @@
1
1
  import { RESET_V2 } from "./protocol.js";
2
+ import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
2
3
  function isTextContent(part) {
3
4
  return typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string";
4
5
  }
5
- function contentText(content) {
6
+ export function contentText(content) {
6
7
  if (typeof content === "string")
7
8
  return content;
8
- return content.filter(isTextContent).map((part) => part.text).join("\n");
9
+ return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
9
10
  }
10
11
  function mapRole(role) {
11
12
  if (role === "user" || role === "assistant")
@@ -78,13 +79,17 @@ export function resetV2WindowId(details) {
78
79
  return candidate.windowId;
79
80
  }
80
81
  /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
81
- function windowIdOf(sessionId, entry) {
82
+ export function windowIdOf(sessionId, entry) {
82
83
  return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
83
84
  }
85
+ /** Mint the durable identity of a session's root history window. */
86
+ export function rootWindowId(sessionId) {
87
+ return `pcw:${sessionId.slice(0, 8)}:root`;
88
+ }
84
89
  /** Build durable, on-demand history directly from every entry on the current session branch. */
85
90
  export function historyFromSession(ctx) {
86
91
  const sessionId = ctx.sessionManager.getSessionId();
87
- let window = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
92
+ let window = { windowId: rootWindowId(sessionId), items: [] };
88
93
  const windows = [window];
89
94
  for (const entry of ctx.sessionManager.getBranch()) {
90
95
  if (entry.type === "compaction") {
@@ -128,7 +133,7 @@ export function historyFromSession(ctx) {
128
133
  }
129
134
  return windows;
130
135
  }
131
- export function visibleItem(item, maxChars = 1200) {
136
+ export function visibleItem(item, maxChars = HISTORY_PREVIEW_CHARS) {
132
137
  const characters = Array.from(item.content);
133
138
  const truncated = characters.length > maxChars;
134
139
  return {
@@ -206,5 +211,5 @@ export function currentWindowId(ctx) {
206
211
  if (entry?.type === "compaction")
207
212
  return windowIdOf(sessionId, entry);
208
213
  }
209
- return `pcw:${sessionId.slice(0, 8)}:root`;
214
+ return rootWindowId(sessionId);
210
215
  }
package/dist/src/index.js 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";
@@ -24,8 +24,7 @@ export default function piContext(pi) {
24
24
  // The root window has no compaction entry to carry the boot block, so persist
25
25
  // it once as a hidden custom message. Reset windows already carry theirs at
26
26
  // position 0 in the compaction summary, so a resumed session adds nothing.
27
- const sessionId = ctx.sessionManager.getSessionId();
28
- const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
27
+ const rootId = rootWindowId(ctx.sessionManager.getSessionId());
29
28
  if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE))
30
29
  return;
31
30
  pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
@@ -49,7 +48,7 @@ export default function piContext(pi) {
49
48
  },
50
49
  });
51
50
  registerHistoryTools(pi);
52
- registerMemoryTools(pi);
51
+ registerNotesTools(pi);
53
52
  pi.registerTool(defineTool({
54
53
  name: "new_context",
55
54
  label: "New context",
@@ -70,16 +69,16 @@ export default function piContext(pi) {
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}`))
80
- minted = randomUUID().slice(0, 8);
81
- const windowId = `pcw:${session8}:${minted}`;
82
- 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)))
79
+ minted = { id: randomUUID().slice(0, 8) };
80
+ const windowId = windowIdOf(sessionId, minted);
81
+ const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
83
82
  // The reset marker stays as firstKeptEntryId; it no longer names the window.
84
83
  pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
85
84
  const markerId = ctx.sessionManager.getLeafId();
@@ -0,0 +1,31 @@
1
+ import { assertVirtualPath } from "./model.js";
2
+ const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
3
+ /**
4
+ * Decode the one public note address into its physical home and virtual path. This is a
5
+ * tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
6
+ */
7
+ export function assertAddress(value) {
8
+ if (typeof value !== "string")
9
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
10
+ let scope = "session";
11
+ let path = value;
12
+ if (value.startsWith("@project/")) {
13
+ scope = "project";
14
+ path = value.slice("@project/".length);
15
+ }
16
+ else if (value.startsWith("@personal/")) {
17
+ scope = "personal";
18
+ path = value.slice("@personal/".length);
19
+ }
20
+ else if (value.startsWith("@")) {
21
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
22
+ }
23
+ if (path.includes("@"))
24
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
25
+ assertVirtualPath(path);
26
+ return { scope, path };
27
+ }
28
+ /** Render a virtual path in its one unambiguous public address form. */
29
+ export function addressFor(scope, path) {
30
+ return scope === "session" ? path : `@${scope}/${path}`;
31
+ }
@@ -1,10 +1,10 @@
1
- import { localIso } from "../notes.js";
2
- const SCOPES = ["session", "project", "global"];
1
+ import { localIso } from "./model.js";
2
+ const SCOPES = ["session", "project", "personal"];
3
3
  const ORIGINS = ["user", "self", "external"];
4
4
  const STATUSES = ["active", "superseded", "pending", "archived"];
5
5
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
6
6
  /** Emission order, exactly the Design's key list. */
7
- const KNOWN_KEYS = ["scope", "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", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
8
8
  export function isScope(value) {
9
9
  return typeof value === "string" && SCOPES.includes(value);
10
10
  }
@@ -89,7 +89,7 @@ function parseFrontmatter(raw) {
89
89
  export function parseNote(raw, now = Date.now()) {
90
90
  const { fields, body } = parseFrontmatter(raw);
91
91
  const meta = { ...fields };
92
- meta.scope = isScope(meta.scope) ? meta.scope : "global";
92
+ meta.scope = isScope(meta.scope) ? meta.scope : "personal";
93
93
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
94
94
  meta.status = isStatus(meta.status) ? meta.status : "active";
95
95
  meta.stale = meta.stale === true;
@@ -120,7 +120,9 @@ export function serializeNote(meta, body) {
120
120
  lines.push(`${key}: ${yamlScalar(value)}`);
121
121
  }
122
122
  for (const key of Object.keys(meta)) {
123
- if (KNOWN_KEYS.includes(key))
123
+ // scope is a legacy on-disk field. Store callers derive it from the home's location,
124
+ // but serialization intentionally drops it on the next write.
125
+ if (key === "scope" || KNOWN_KEYS.includes(key))
124
126
  continue;
125
127
  if (meta[key] === undefined)
126
128
  continue;
@@ -1,4 +1,4 @@
1
- import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
1
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
2
2
  export function assertVirtualPath(value) {
3
3
  if (typeof value !== "string" || value.length === 0)
4
4
  throw new Error("path must be a non-empty virtual relative path");
@@ -7,6 +7,10 @@ export function notesRoot() {
7
7
  const override = process.env.PI_NOTES_HOME;
8
8
  return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
9
9
  }
10
+ /** Absolute directory holding the per-session note homes. */
11
+ export function sessionHomesRoot(home = notesRoot()) {
12
+ return join(home, "pi", "session");
13
+ }
10
14
  /**
11
15
  * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
12
16
  * No git root yields undefined, which projectKey then replaces with the cwd itself.
@@ -35,11 +39,11 @@ function sessionId(ctx) {
35
39
  }
36
40
  /** Absolute directory holding every note of one scope. */
37
41
  export function scopeDir(scope, ctx) {
38
- if (scope === "global")
39
- return join(notesRoot(), "global");
42
+ if (scope === "personal")
43
+ return join(notesRoot(), "personal");
40
44
  if (scope === "project")
41
45
  return join(notesRoot(), "project", projectKey(ctx.cwd));
42
- return join(notesRoot(), "pi", "session", sessionId(ctx));
46
+ return join(sessionHomesRoot(), sessionId(ctx));
43
47
  }
44
48
  /**
45
49
  * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
@@ -2,10 +2,12 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { generateDiffString } from "@earendil-works/pi-coding-agent";
5
- import { assertGlobPattern, assertVirtualPath, globToRegExp } from "../notes.js";
5
+ import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
6
6
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
7
7
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
+ import { addressFor } from "./address.js";
8
9
  import { physicalPath, scopeDir } from "./paths.js";
10
+ import { earliestMatchOffsetChars } from "../tool-output.js";
9
11
  /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
10
12
  export class NoteError extends Error {
11
13
  code;
@@ -19,10 +21,10 @@ export class NoteError extends Error {
19
21
  this.edit_index = extra.edit_index;
20
22
  }
21
23
  }
22
- const SCOPE_ORDER = ["session", "project", "global"];
24
+ const SCOPE_ORDER = ["session", "project", "personal"];
23
25
  function assertScope(value) {
24
26
  if (!isScope(value))
25
- throw new NoteError("invalid_scope", `scope must be one of session, project, global (got ${JSON.stringify(value)})`);
27
+ throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
26
28
  return value;
27
29
  }
28
30
  function assertOrigin(value) {
@@ -30,20 +32,6 @@ function assertOrigin(value) {
30
32
  throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
31
33
  return value;
32
34
  }
33
- function scopeList(scope) {
34
- if (scope === undefined || scope === null)
35
- return [...SCOPE_ORDER];
36
- return [assertScope(scope)];
37
- }
38
- /** First existing file by precedence session → project → global, or only `scope` when given. */
39
- function resolve(ctx, vpath, scope) {
40
- for (const candidate of scopeList(scope)) {
41
- const path = physicalPath(candidate, vpath, ctx);
42
- if (existsSync(path))
43
- return { scope: candidate, path, raw: readFileSync(path, "utf8") };
44
- }
45
- return undefined;
46
- }
47
35
  /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
48
36
  function walkMarkdown(dir, base = dir) {
49
37
  let entries;
@@ -157,18 +145,20 @@ export function updateNoteMeta(ctx, vpath, scope, mutate) {
157
145
  atomicWrite(path, serialized);
158
146
  return { meta, body: parsed.body };
159
147
  }
160
- /** Apply body-only edits against one snapshot, then optionally move via the scope/origin/stale setters. */
161
- export function editNote(ctx, vpath, edits, opts = {}) {
148
+ /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
149
+ export function editNote(ctx, vpath, scope, edits, opts = {}) {
162
150
  assertVirtualPath(vpath);
163
151
  assertWritablePath(vpath);
164
152
  const operations = edits ?? [];
165
- if (operations.length === 0 && opts.scope === undefined && opts.origin === undefined && opts.stale === undefined) {
166
- throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of scope, origin, stale");
153
+ if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
154
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
167
155
  }
168
- const found = resolve(ctx, vpath);
169
- if (!found)
156
+ const path = physicalPath(scope, vpath, ctx);
157
+ if (!existsSync(path))
170
158
  throw new NoteError("not_found", "note not found");
171
- const { meta, body } = parseNote(found.raw);
159
+ const raw = readFileSync(path, "utf8");
160
+ const { meta, body } = parseNote(raw);
161
+ meta.scope = scope;
172
162
  // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
173
163
  const beforeMeta = { ...meta };
174
164
  // Every edit runs against this one snapshot; nothing is written until all of them succeed,
@@ -187,90 +177,79 @@ export function editNote(ctx, vpath, edits, opts = {}) {
187
177
  if (lines.length > 1 && !opts.replaceAll) {
188
178
  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 });
189
179
  }
190
- next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
180
+ // Single replacement is positional splicing, never String.replace: user text must be
181
+ // inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
182
+ if (opts.replaceAll) {
183
+ next = next.split(oldText).join(newText);
184
+ }
185
+ else {
186
+ const matchIndex = next.indexOf(oldText);
187
+ next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
188
+ }
191
189
  });
192
- const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
193
190
  if (opts.origin !== undefined)
194
191
  meta.origin = assertOrigin(opts.origin);
195
192
  if (opts.stale !== undefined)
196
193
  meta.stale = opts.stale;
197
- meta.scope = destScope;
198
194
  meta.updated_at = Date.now();
199
- const dest = physicalPath(destScope, vpath, ctx);
200
- const moving = dest !== found.path;
201
- if (moving && existsSync(dest)) {
202
- throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
203
- }
204
195
  const serialized = serializeNote(meta, next);
205
196
  assertSerializedSize(serialized);
206
197
  // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
207
- // update, one combined file diff when both moved.
198
+ // update, one combined file diff when both change.
208
199
  const bodyChanged = body !== next;
209
- const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
200
+ const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
210
201
  const diff = bodyChanged && metadataChanged
211
- ? generateDiffString(found.raw, serialized).diff
202
+ ? generateDiffString(raw, serialized).diff
212
203
  : bodyChanged
213
204
  ? generateDiffString(body, next).diff
214
205
  : metadataChanged
215
206
  ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
216
207
  : "";
217
- atomicWrite(dest, serialized);
218
- if (moving)
219
- rmSync(found.path);
220
- return { meta, applied: operations.length, resolved_scope: found.scope, diff };
208
+ atomicWrite(path, serialized);
209
+ return { meta, applied: operations.length, resolved_scope: scope, diff };
221
210
  }
222
211
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
223
- export function readNote(ctx, vpath, opts = {}) {
212
+ export function readNote(ctx, vpath, scope) {
224
213
  assertVirtualPath(vpath);
225
- const found = resolve(ctx, vpath, opts.scope);
226
- if (!found)
214
+ const path = physicalPath(scope, vpath, ctx);
215
+ if (!existsSync(path))
227
216
  return undefined;
228
217
  const now = Date.now();
229
- const { meta, body } = parseNote(found.raw, now);
230
- meta.scope = found.scope;
218
+ const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
219
+ meta.scope = scope;
231
220
  // Only the two access keys move; updated_at and every other key keep their bytes.
232
221
  meta.last_accessed = now;
233
222
  meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
234
- atomicWrite(found.path, serializeNote(meta, body));
235
- return { meta, body, resolvedScope: found.scope };
236
- }
237
- /** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
238
- export function resolveNoteScope(ctx, vpath, scope) {
239
- const found = resolve(ctx, vpath, scope);
240
- return found ? { scope: found.scope, path: found.path } : undefined;
241
- }
242
- /** Read a note's meta and body without the read side effect (used by the boot index). */
243
- export function peekNote(ctx, scope, vpath) {
244
- const path = physicalPath(scope, vpath, ctx);
245
- const { meta, body } = parseNote(readFileSync(path, "utf8"));
246
- meta.scope = scope;
247
- return { meta, body };
223
+ atomicWrite(path, serializeNote(meta, body));
224
+ return { meta, body, resolvedScope: scope };
248
225
  }
249
- /** Merged rows across scopes, most recently updated first (path then scope break ties). */
226
+ /** Merged rows across homes, most recently updated first (address breaks ties). */
250
227
  export function listNotes(ctx, opts = {}) {
251
228
  const matcher = matcherFor(opts.pattern);
252
229
  const rows = [];
253
- for (const scope of scopeList(opts.scope)) {
230
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
254
231
  const root = scopeDir(scope, ctx);
255
232
  for (const path of walkMarkdown(root)) {
256
- if (matcher && !matcher.test(path))
233
+ const address = addressFor(scope, path);
234
+ if (matcher && !matcher.test(address))
257
235
  continue;
258
236
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
259
237
  meta.scope = scope;
260
- rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
238
+ rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
261
239
  }
262
240
  }
263
- rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.path.localeCompare(b.path) || a.meta.scope.localeCompare(b.meta.scope));
241
+ rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
264
242
  return rows;
265
243
  }
266
244
  /** Case-sensitive literal substring search over note bodies, with a match address per line. */
267
245
  export function searchNotes(ctx, queries, opts = {}) {
268
246
  const matcher = matcherFor(opts.pattern);
269
247
  const rows = [];
270
- for (const scope of scopeList(opts.scope)) {
248
+ for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
271
249
  const root = scopeDir(scope, ctx);
272
250
  for (const path of walkMarkdown(root)) {
273
- if (matcher && !matcher.test(path))
251
+ const address = addressFor(scope, path);
252
+ if (matcher && !matcher.test(address))
274
253
  continue;
275
254
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
276
255
  meta.scope = scope;
@@ -278,20 +257,14 @@ export function searchNotes(ctx, queries, opts = {}) {
278
257
  const matches = [];
279
258
  for (const [index, line] of body.split("\n").entries()) {
280
259
  if (queries.some((query) => line.includes(query))) {
281
- let earliest = -1;
282
- for (const query of queries) {
283
- const found = line.indexOf(query);
284
- if (found >= 0 && (earliest < 0 || found < earliest))
285
- earliest = found;
286
- }
287
- matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
260
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
288
261
  }
289
262
  baseChars += Array.from(line).length + 1;
290
263
  }
291
264
  if (matches.length > 0)
292
- rows.push({ path, scope, meta, matches });
265
+ rows.push({ address, path, scope, meta, matches });
293
266
  }
294
267
  }
295
- rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
268
+ rows.sort((a, b) => a.address.localeCompare(b.address));
296
269
  return rows;
297
270
  }
@@ -0,0 +1,153 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } 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 } from "./frontmatter.js";
8
+ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
9
+ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
10
+ 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.",
11
+ }));
12
+ const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
13
+ function wireMeta(meta) {
14
+ return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
15
+ }
16
+ function failure(error) {
17
+ if (error instanceof NoteError) {
18
+ const payload = { error: error.message };
19
+ if (error.line_numbers)
20
+ payload.line_numbers = error.line_numbers;
21
+ if (error.edit_index !== undefined)
22
+ payload.edit_index = error.edit_index;
23
+ return output(payload);
24
+ }
25
+ throw error;
26
+ }
27
+ export function registerNotesTools(pi) {
28
+ pi.registerTool(defineTool({
29
+ name: "notes_write", label: "Notes write",
30
+ 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.`,
31
+ parameters: Type.Object({ address: Type.String(), content: Type.String(), origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
32
+ async execute(_id, params, _signal, _update, ctx) {
33
+ const content = params.content;
34
+ try {
35
+ const destination = assertAddress(params.address);
36
+ const { meta } = writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self"), stale: params.stale });
37
+ return output({ address: params.address, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
38
+ }
39
+ catch (error) {
40
+ 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, stale: params.stale, replaceAll: params.replace_all });
52
+ return output({ address: params.address, applied, resolved_scope, diff, meta: wireMeta(meta) });
53
+ }
54
+ catch (error) {
55
+ return failure(error);
56
+ }
57
+ },
58
+ }));
59
+ pi.registerTool(defineTool({
60
+ name: "notes_read", label: "Notes read",
61
+ 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.`,
62
+ 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 }),
63
+ async execute(_id, params, _signal, _update, ctx) {
64
+ let note;
65
+ try {
66
+ const destination = assertAddress(params.address);
67
+ note = readNote(ctx, destination.path, destination.scope);
68
+ }
69
+ catch (error) {
70
+ return failure(error);
71
+ }
72
+ if (!note)
73
+ return output({ error: "note not found", address: params.address });
74
+ const text = serializeNote(note.meta, note.body);
75
+ const totalChars = Array.from(text).length;
76
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars)
77
+ 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 });
78
+ const created_at = localIso(note.meta.created_at);
79
+ const updated_at = localIso(note.meta.updated_at);
80
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
81
+ return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
82
+ const { content, ...rest } = window;
83
+ 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 });
84
+ }, (result) => withinTextBudget(result.content[0].text));
85
+ },
86
+ }));
87
+ pi.registerTool(defineTool({
88
+ name: "notes_list", label: "Notes list",
89
+ 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.`,
90
+ parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
91
+ async execute(_id, params, _signal, _update, ctx) {
92
+ let rows;
93
+ try {
94
+ rows = listNotes(ctx, { pattern: params.pattern ?? undefined });
95
+ }
96
+ catch (error) {
97
+ return failure(error);
98
+ }
99
+ const files = 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) }));
100
+ return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
101
+ if (fits(file))
102
+ return file;
103
+ const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
104
+ return { ...file, address, address_truncated: true };
105
+ }));
106
+ },
107
+ }));
108
+ pi.registerTool(defineTool({
109
+ name: "notes_search", label: "Notes search",
110
+ 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).`,
111
+ parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
112
+ async execute(_id, params, _signal, _update, ctx) {
113
+ const queries = searchQueries(params.query);
114
+ let rows;
115
+ try {
116
+ rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined });
117
+ }
118
+ catch (error) {
119
+ return failure(error);
120
+ }
121
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
122
+ const result = rows.map((row) => {
123
+ 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 }));
124
+ 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) };
125
+ });
126
+ const fitFile = (file, fits) => {
127
+ if (fits(file))
128
+ return file;
129
+ const matches = file.matches;
130
+ let low = 0;
131
+ let high = matches.length;
132
+ while (low < high) {
133
+ const mid = Math.ceil((low + high) / 2);
134
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) }))
135
+ low = mid;
136
+ else
137
+ high = mid - 1;
138
+ }
139
+ if (low >= 1)
140
+ return { ...file, matches: matches.slice(0, low) };
141
+ const first = matches[0];
142
+ const fitted = (text) => ({ ...file, matches: [{ ...first, text, truncated: true }] });
143
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
144
+ const prefix = fitted(text);
145
+ if (fits(prefix))
146
+ return prefix;
147
+ const address = middleTruncate(prefix.address, (candidate) => fits({ ...prefix, address: candidate, address_truncated: true }));
148
+ return { ...prefix, address, address_truncated: true };
149
+ };
150
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
151
+ },
152
+ }));
153
+ }