@astrosheep/pi-context 0.18.0 → 0.19.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.
package/README.md CHANGED
@@ -20,19 +20,19 @@ pi -e npm:@astrosheep/pi-context
20
20
  - **A boot block at every window head** — static once-per-window content (cache-stable) carrying the window identity, the recent-notes index, and a short protocol that teaches the model how to recover: notes for its own bookkeeping, history tools for everything before the reset.
21
21
  - **Low-budget guidance** — one persisted early warning per window when the estimated remaining budget crosses the reminder line, so the model checkpoints before the lights go out.
22
22
  - **`get_context_remaining`** — the live, reserve-adjusted estimate of the context budget left before Pi's compaction reserve.
23
- - **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace:
23
+ - **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace; notes are real markdown files under `~/.agents/notes` (`global/`, `project/`, `pi/session/`):
24
24
 
25
25
  | Codex action | Pi tool |
26
26
  | --- | --- |
27
- | `history.list_windows` | `history_list_windows` |
28
- | `history.list_items` | `history_list_items` |
29
- | `history.read_item` | `history_read_item` |
30
- | `history.search_contents` | `history_search_contents` |
31
- | `notes.list_files` | `notes_list_files` |
32
- | `notes.read_file` | `notes_read_file` |
33
- | `notes.search_contents` | `notes_search_contents` |
34
- | `notes.append_to_file` | `notes_append_to_file` |
35
- | `notes.write_file` | `notes_write_file` |
27
+ | `history.list_windows` | `history_windows` |
28
+ | `history.list_items` | `history_list` |
29
+ | `history.read_item` | `history_read` |
30
+ | `history.search_contents` | `history_search` |
31
+ | `notes.write` | `notes_write` |
32
+ | `notes.edit` | `notes_edit` |
33
+ | `notes.read` | `notes_read` |
34
+ | `notes.list` | `notes_list` |
35
+ | `notes.search` | `notes_search` |
36
36
 
37
37
  The tool descriptions the model sees are the behavioral documentation: search is case-sensitive literal substring; reads are character windows whose cursors reconstruct the original exactly; anything a response does not deliver is named by an explicit field.
38
38
 
@@ -13,7 +13,7 @@
13
13
  | Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
14
14
  | Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
15
15
 
16
- The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+8192 tokens remaining. After it, the model either ends the window itself with `new_context` or rides into Pi's automatic compaction, which resets on the spot with no turn.
16
+ The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+12288 tokens remaining. After it, the model either ends the window itself with `new_context` or rides into Pi's automatic compaction, which resets on the spot with no turn.
17
17
 
18
18
  The completion callback is the scheduling boundary: `session_compact` fires before Pi clears manual compaction state. Sending a prompt inside that hook is too early. An explicit reset uses the manual `ctx.compact` route and therefore needs this completion logic; an automatic compaction is already the reset and resumes through Pi's own caller.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@ function truncateHistoryItem<T extends { truncated_content: string; truncated: b
31
31
 
32
32
  export function registerHistoryTools(pi: ExtensionAPI) {
33
33
  pi.registerTool(defineTool({
34
- name: "history_list_windows",
34
+ name: "history_windows",
35
35
  label: "History list windows",
36
36
  description: "List durable Pi session-history windows.",
37
37
  parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst() }, { additionalProperties: false }),
@@ -44,9 +44,9 @@ export function registerHistoryTools(pi: ExtensionAPI) {
44
44
  }));
45
45
 
46
46
  pi.registerTool(defineTool({
47
- name: "history_list_items",
47
+ name: "history_list",
48
48
  label: "History list items",
49
- description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read_item.",
49
+ description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
50
50
  parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
51
51
  async execute(_id, params, _signal, _update, ctx) {
52
52
  const invalid = vacuousRoleToolCombo(params);
@@ -59,7 +59,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
59
59
  }));
60
60
 
61
61
  pi.registerTool(defineTool({
62
- name: "history_read_item",
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
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 }),
@@ -81,9 +81,9 @@ export function registerHistoryTools(pi: ExtensionAPI) {
81
81
  }));
82
82
 
83
83
  pi.registerTool(defineTool({
84
- name: "history_search_contents",
84
+ name: "history_search",
85
85
  label: "History search",
86
- description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read_item at match_offset_chars.",
86
+ description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read at match_offset_chars.",
87
87
  parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
88
88
  async execute(_id, params, _signal, _update, ctx) {
89
89
  const invalid = vacuousRoleToolCombo(params);
package/src/history.ts CHANGED
@@ -72,7 +72,7 @@ function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "output
72
72
  * An assistant turn's tool calls, projected as their own items: calls wear their own role so the
73
73
  * authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
74
74
  * searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
75
- * opaque; history_read_item resolves them like any other item.
75
+ * opaque; history_read resolves them like any other item.
76
76
  */
77
77
  function toolCallItems(windowId: string, entry: { id: string; timestamp?: string }, message: AgentMessage): HistoryItem[] {
78
78
  if (message.role !== "assistant" || !Array.isArray(message.content)) return [];
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { registerHistoryTools } from "./history-tools.js";
2
- import { registerNoteTools } from "./note-tools.js";
2
+ import { registerMemoryTools } from "./memory/tools.js";
3
3
  import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
4
4
  import { output } from "./tool-output.js";
5
5
  export { deriveThresholds, mergePiContextSettings };
@@ -48,7 +48,7 @@ export default function piContext(pi: ExtensionAPI) {
48
48
  });
49
49
 
50
50
  registerHistoryTools(pi);
51
- registerNoteTools(pi);
51
+ registerMemoryTools(pi);
52
52
 
53
53
  pi.registerTool(defineTool({
54
54
  name: "new_context",
@@ -0,0 +1,153 @@
1
+ import { localIso } from "../notes.js";
2
+ import type { Scope } from "./paths.js";
3
+
4
+ export type NoteStatus = "active" | "superseded" | "pending" | "archived";
5
+ export type Origin = "user" | "self" | "external";
6
+
7
+ /**
8
+ * Harness-owned note metadata. The eight required keys are always written; the four optional
9
+ * sleep-shift keys survive this layer untouched when present. `[key: string]: unknown` carries
10
+ * any frontmatter key this layer does not know, preserved verbatim across rewrites.
11
+ */
12
+ export type NoteMeta = {
13
+ scope: Scope;
14
+ origin: Origin;
15
+ status: NoteStatus;
16
+ stale: boolean;
17
+ created_at: number;
18
+ updated_at: number;
19
+ last_accessed: number;
20
+ access_count: number;
21
+ source_window?: string;
22
+ supersedes?: string;
23
+ recurrence_count?: number;
24
+ recurrence_windows?: string[];
25
+ [key: string]: unknown;
26
+ };
27
+
28
+ const SCOPES: readonly Scope[] = ["session", "project", "global"];
29
+ const ORIGINS: readonly Origin[] = ["user", "self", "external"];
30
+ const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
31
+ const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
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;
34
+
35
+ export function isScope(value: unknown): value is Scope {
36
+ return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
37
+ }
38
+
39
+ export function isOrigin(value: unknown): value is Origin {
40
+ return typeof value === "string" && (ORIGINS as readonly string[]).includes(value);
41
+ }
42
+
43
+ function isStatus(value: unknown): value is NoteStatus {
44
+ return typeof value === "string" && (STATUSES as readonly string[]).includes(value);
45
+ }
46
+
47
+ function toEpoch(value: unknown, fallback: number): number {
48
+ if (typeof value === "number" && Number.isFinite(value)) return value;
49
+ if (typeof value === "string") {
50
+ const parsed = Date.parse(value);
51
+ if (Number.isFinite(parsed)) return parsed;
52
+ }
53
+ return fallback;
54
+ }
55
+
56
+ /** A frontmatter scalar encoded as JSON, falling back to a plain string for hand-written YAML. */
57
+ function parseScalar(text: string): unknown {
58
+ const trimmed = text.trim();
59
+ if (trimmed === "") return "";
60
+ try {
61
+ return JSON.parse(trimmed);
62
+ } catch {
63
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1);
64
+ return trimmed;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Split raw file text into its frontmatter fields and body. When the file does not open with
70
+ * a closed `---` block, the whole text is body and the field map is empty.
71
+ */
72
+ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body: string } {
73
+ const stripped = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
74
+ const lines = stripped.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
75
+ if (lines[0]?.trim() !== "---") return { fields: {}, body: raw };
76
+ let close = -1;
77
+ for (let index = 1; index < lines.length; index++) {
78
+ if (lines[index]?.trim() === "---") {
79
+ close = index;
80
+ break;
81
+ }
82
+ }
83
+ if (close === -1) return { fields: {}, body: raw };
84
+ const fields: Record<string, unknown> = {};
85
+ for (let index = 1; index < close; index++) {
86
+ const line = lines[index]!;
87
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
88
+ if (!match) continue;
89
+ const key = match[1]!;
90
+ const rest = match[2]!;
91
+ if (rest.trim() === "") {
92
+ // A bare key opens a block sequence of `- item` lines, the only multi-line shape we parse.
93
+ const items: unknown[] = [];
94
+ while (index + 1 < close && /^\s*-\s+/.test(lines[index + 1]!)) {
95
+ index++;
96
+ items.push(parseScalar(lines[index]!.replace(/^\s*-\s+/, "")));
97
+ }
98
+ fields[key] = items;
99
+ } else {
100
+ fields[key] = parseScalar(rest);
101
+ }
102
+ }
103
+ const rest = lines.slice(close + 1);
104
+ if (rest[0] === "") rest.shift();
105
+ return { fields, body: rest.join("\n") };
106
+ }
107
+
108
+ /**
109
+ * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
110
+ * access_count 0, timestamps now); unknown keys are carried through untouched.
111
+ */
112
+ export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
113
+ const { fields, body } = parseFrontmatter(raw);
114
+ const meta = { ...fields } as Record<string, unknown>;
115
+ meta.scope = isScope(meta.scope) ? meta.scope : "global";
116
+ meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
117
+ meta.status = isStatus(meta.status) ? meta.status : "active";
118
+ meta.stale = meta.stale === true;
119
+ for (const key of TIMESTAMP_KEYS) meta[key] = toEpoch(meta[key], now);
120
+ meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
121
+ return { meta: meta as NoteMeta, body };
122
+ }
123
+
124
+ /** Emit a YAML scalar: bare for safe strings and JSON literals, JSON-quoted otherwise. */
125
+ function yamlScalar(value: unknown): string {
126
+ if (typeof value === "string") {
127
+ const reserved = new Set(["true", "false", "null", "yes", "no", "on", "off", "~"]);
128
+ if (/^[A-Za-z0-9_.+\-:/]+$/.test(value) && !reserved.has(value.toLowerCase())) return value;
129
+ }
130
+ return JSON.stringify(value);
131
+ }
132
+
133
+ /** Serialize frontmatter + blank line + body. Known keys emit in Design order, extras after. */
134
+ export function serializeNote(meta: NoteMeta, body: string): string {
135
+ const lines: string[] = [];
136
+ for (const key of KNOWN_KEYS) {
137
+ const value = meta[key];
138
+ if (value === undefined) continue;
139
+ if ((TIMESTAMP_KEYS as readonly string[]).includes(key)) lines.push(`${key}: ${yamlScalar(localIso(value as number))}`);
140
+ else lines.push(`${key}: ${yamlScalar(value)}`);
141
+ }
142
+ for (const key of Object.keys(meta)) {
143
+ if ((KNOWN_KEYS as readonly string[]).includes(key)) continue;
144
+ if (meta[key] === undefined) continue;
145
+ lines.push(`${key}: ${yamlScalar(meta[key])}`);
146
+ }
147
+ return `---\n${lines.join("\n")}\n---\n\n${body}`;
148
+ }
149
+
150
+ /** Strip a leading frontmatter block from user content, so a note body is pure content. */
151
+ export function stripLeadingFrontmatter(content: string): string {
152
+ return parseFrontmatter(content).body;
153
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } 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";
6
+
7
+ export type Scope = "session" | "project" | "global";
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
+ /**
16
+ * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
17
+ * No git root yields undefined, which projectKey then replaces with the cwd itself.
18
+ */
19
+ function gitRoot(cwd: string): string | undefined {
20
+ let dir = resolve(cwd);
21
+ for (;;) {
22
+ if (existsSync(join(dir, ".git"))) return dir;
23
+ const parent = dirname(dir);
24
+ if (parent === dir) return undefined;
25
+ dir = parent;
26
+ }
27
+ }
28
+
29
+ /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
30
+ export function projectKey(cwd: string): string {
31
+ const absolute = resolve(cwd);
32
+ const root = gitRoot(absolute) ?? absolute;
33
+ const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
34
+ return `${basename(root)}-${digest}`;
35
+ }
36
+
37
+ /** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
38
+ function sessionId(ctx: ExtensionContext): string {
39
+ return ctx.sessionManager.getSessionId();
40
+ }
41
+
42
+ /** Absolute directory holding every note of one scope. */
43
+ export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
44
+ if (scope === "global") return join(notesRoot(), "global");
45
+ if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
46
+ return join(notesRoot(), "pi", "session", sessionId(ctx));
47
+ }
48
+
49
+ /**
50
+ * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
51
+ * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
52
+ */
53
+ export function noteFileName(vpath: string): string {
54
+ return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
55
+ }
56
+
57
+ /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
58
+ export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext): string {
59
+ return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
60
+ }
@@ -0,0 +1,295 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, type Dirent } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { generateDiffString } from "@earendil-works/pi-coding-agent";
6
+ import { assertGlobPattern, assertVirtualPath, globToRegExp } from "../notes.js";
7
+ import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
8
+ import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
9
+ import { physicalPath, scopeDir, type Scope } from "./paths.js";
10
+
11
+ export type { NoteMeta, Origin, Scope };
12
+
13
+ export type NoteErrorCode = "not_found" | "ambiguous_edit" | "no_match" | "nothing_to_do" | "target_exists" | "too_large" | "invalid_scope" | "invalid_origin";
14
+
15
+ /** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
16
+ export class NoteError extends Error {
17
+ readonly code: NoteErrorCode;
18
+ readonly line_numbers?: number[];
19
+ readonly edit_index?: number;
20
+ constructor(code: NoteErrorCode, message: string, extra: { line_numbers?: number[]; edit_index?: number } = {}) {
21
+ super(message);
22
+ this.name = "NoteError";
23
+ this.code = code;
24
+ this.line_numbers = extra.line_numbers;
25
+ this.edit_index = extra.edit_index;
26
+ }
27
+ }
28
+
29
+ export type NoteRow = { path: string; meta: NoteMeta; sizeBytes: number };
30
+ export type NoteMatch = { line: number; text: string; offsetChars: number };
31
+ export type NoteSearchRow = { path: string; scope: Scope; meta: NoteMeta; matches: NoteMatch[] };
32
+
33
+ const SCOPE_ORDER: readonly Scope[] = ["session", "project", "global"];
34
+
35
+ function assertScope(value: unknown): Scope {
36
+ if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, global (got ${JSON.stringify(value)})`);
37
+ return value;
38
+ }
39
+
40
+ function assertOrigin(value: unknown): Origin {
41
+ if (!isOrigin(value)) throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
42
+ return value;
43
+ }
44
+
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
+ /** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
62
+ function walkMarkdown(dir: string, base = dir): string[] {
63
+ let entries: Dirent[];
64
+ try {
65
+ entries = readdirSync(dir, { withFileTypes: true });
66
+ } catch {
67
+ return [];
68
+ }
69
+ const paths: string[] = [];
70
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
71
+ const child = `${dir}/${entry.name}`;
72
+ if (entry.isDirectory()) paths.push(...walkMarkdown(child, base));
73
+ else if (entry.isFile() && entry.name.endsWith(".md")) paths.push(child.slice(base.length + 1).split("\\").join("/"));
74
+ }
75
+ return paths;
76
+ }
77
+
78
+ function matcherFor(pattern: unknown): RegExp | undefined {
79
+ const normalized = assertGlobPattern(pattern);
80
+ return normalized === undefined ? undefined : globToRegExp(normalized);
81
+ }
82
+
83
+ /**
84
+ * Every mutation lands through a tmp file renamed into place in the same directory, so a crash
85
+ * never leaves a torn note. No cross-process locking: out of scope by decision.
86
+ */
87
+ function atomicWrite(path: string, content: string): void {
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
90
+ try {
91
+ writeFileSync(tmp, content);
92
+ renameSync(tmp, path);
93
+ } catch (error) {
94
+ rmSync(tmp, { force: true });
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ /** Write-time vpath guard: the byte cap is a tool-boundary rule, never a jail rule. */
100
+ function assertWritablePath(vpath: string): void {
101
+ const bytes = Buffer.byteLength(vpath, "utf8");
102
+ if (bytes > MAX_NOTE_PATH_BYTES) throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
103
+ }
104
+
105
+ /** Serialized-size guard applied after the frontmatter is merged, before any bytes are written. */
106
+ function assertSerializedSize(content: string): void {
107
+ const bytes = Buffer.byteLength(content, "utf8");
108
+ if (bytes > MAX_NOTE_BYTES) throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
109
+ }
110
+
111
+ /** Frontmatter block only (the body separator stripped), for the metadata-only diff. */
112
+ function frontmatterOf(meta: NoteMeta): string {
113
+ return serializeNote(meta, "").slice(0, -2);
114
+ }
115
+
116
+ /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
117
+ function matchLineNumbers(body: string, needle: string): number[] {
118
+ const lines: number[] = [];
119
+ let cursor = 0;
120
+ for (;;) {
121
+ const index = body.indexOf(needle, cursor);
122
+ if (index === -1) break;
123
+ lines.push(body.slice(0, index).split("\n").length);
124
+ cursor = index + Math.max(needle.length, 1);
125
+ }
126
+ return lines;
127
+ }
128
+
129
+ export type WriteOptions = { scope: Scope; origin: Origin; stale?: boolean };
130
+
131
+ /** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
132
+ export function writeNote(ctx: ExtensionContext, vpath: string, body: string, opts: WriteOptions): { meta: NoteMeta } {
133
+ assertVirtualPath(vpath);
134
+ assertWritablePath(vpath);
135
+ const scope = assertScope(opts.scope);
136
+ const origin = assertOrigin(opts.origin);
137
+ const path = physicalPath(scope, vpath, ctx);
138
+ const now = Date.now();
139
+ const cleanBody = stripLeadingFrontmatter(body);
140
+ const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
141
+ const meta: NoteMeta = existing ?? {
142
+ scope,
143
+ origin,
144
+ status: "active",
145
+ stale: false,
146
+ created_at: now,
147
+ updated_at: now,
148
+ last_accessed: now,
149
+ access_count: 0,
150
+ };
151
+ meta.scope = scope;
152
+ meta.origin = origin;
153
+ meta.status = "active";
154
+ meta.stale = opts.stale ?? false;
155
+ meta.updated_at = now;
156
+ const serialized = serializeNote(meta, cleanBody);
157
+ assertSerializedSize(serialized);
158
+ atomicWrite(path, serialized);
159
+ return { meta };
160
+ }
161
+
162
+ export type EditOperation = { oldText: string; newText: string };
163
+ export type EditOptions = { scope?: Scope; origin?: Origin; stale?: boolean; replaceAll?: boolean };
164
+
165
+ /** Apply body-only edits against one snapshot, then optionally move via the scope/origin/stale setters. */
166
+ export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
167
+ assertVirtualPath(vpath);
168
+ assertWritablePath(vpath);
169
+ const operations = edits ?? [];
170
+ if (operations.length === 0 && opts.scope === undefined && opts.origin === undefined && opts.stale === undefined) {
171
+ throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of scope, origin, stale");
172
+ }
173
+ const found = resolve(ctx, vpath);
174
+ if (!found) throw new NoteError("not_found", "note not found");
175
+ const { meta, body } = parseNote(found.raw);
176
+ // Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
177
+ const beforeMeta: NoteMeta = { ...meta };
178
+ // Every edit runs against this one snapshot; nothing is written until all of them succeed,
179
+ // so a failing edit leaves the file byte-identical (frontmatter included).
180
+ let next = body;
181
+ operations.forEach((edit, index) => {
182
+ const oldText = edit?.oldText;
183
+ const newText = edit?.newText;
184
+ if (typeof oldText !== "string" || oldText.length === 0) throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
185
+ if (typeof newText !== "string") throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
186
+ const lines = matchLineNumbers(next, oldText);
187
+ if (lines.length === 0) throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
188
+ if (lines.length > 1 && !opts.replaceAll) {
189
+ 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 });
190
+ }
191
+ next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
192
+ });
193
+ const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
194
+ if (opts.origin !== undefined) meta.origin = assertOrigin(opts.origin);
195
+ if (opts.stale !== undefined) meta.stale = opts.stale;
196
+ meta.scope = destScope;
197
+ meta.updated_at = Date.now();
198
+ const dest = physicalPath(destScope, vpath, ctx);
199
+ const moving = dest !== found.path;
200
+ if (moving && existsSync(dest)) {
201
+ throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
202
+ }
203
+ const serialized = serializeNote(meta, next);
204
+ assertSerializedSize(serialized);
205
+ // pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
206
+ // update, one combined file diff when both moved.
207
+ const bodyChanged = body !== next;
208
+ const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
209
+ const diff = bodyChanged && metadataChanged
210
+ ? generateDiffString(found.raw, serialized).diff
211
+ : bodyChanged
212
+ ? generateDiffString(body, next).diff
213
+ : metadataChanged
214
+ ? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
215
+ : "";
216
+ atomicWrite(dest, serialized);
217
+ if (moving) rmSync(found.path);
218
+ return { meta, applied: operations.length, resolved_scope: found.scope, diff };
219
+ }
220
+
221
+ /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
222
+ export function readNote(ctx: ExtensionContext, vpath: string, opts: { scope?: Scope } = {}): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
223
+ assertVirtualPath(vpath);
224
+ const found = resolve(ctx, vpath, opts.scope);
225
+ if (!found) return undefined;
226
+ const now = Date.now();
227
+ const { meta, body } = parseNote(found.raw, now);
228
+ meta.scope = found.scope;
229
+ // Only the two access keys move; updated_at and every other key keep their bytes.
230
+ meta.last_accessed = now;
231
+ meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
232
+ atomicWrite(found.path, serializeNote(meta, body));
233
+ return { meta, body, resolvedScope: found.scope };
234
+ }
235
+
236
+ /** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
237
+ export function resolveNoteScope(ctx: ExtensionContext, vpath: string, scope?: Scope): { scope: Scope; path: string } | undefined {
238
+ const found = resolve(ctx, vpath, scope);
239
+ return found ? { scope: found.scope, path: found.path } : undefined;
240
+ }
241
+
242
+ /** Read a note's meta and body without the read side effect (used by the boot index). */
243
+ export function peekNote(ctx: ExtensionContext, scope: Scope, vpath: string): { meta: NoteMeta; body: string } {
244
+ const path = physicalPath(scope, vpath, ctx);
245
+ const { meta, body } = parseNote(readFileSync(path, "utf8"));
246
+ meta.scope = scope;
247
+ return { meta, body };
248
+ }
249
+
250
+ /** Merged rows across scopes, most recently updated first (path then scope break ties). */
251
+ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
252
+ const matcher = matcherFor(opts.pattern);
253
+ const rows: NoteRow[] = [];
254
+ for (const scope of scopeList(opts.scope)) {
255
+ const root = scopeDir(scope, ctx);
256
+ for (const path of walkMarkdown(root)) {
257
+ if (matcher && !matcher.test(path)) continue;
258
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
259
+ meta.scope = scope;
260
+ rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
261
+ }
262
+ }
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));
264
+ return rows;
265
+ }
266
+
267
+ /** Case-sensitive literal substring search over note bodies, with a match address per line. */
268
+ export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
269
+ const matcher = matcherFor(opts.pattern);
270
+ const rows: NoteSearchRow[] = [];
271
+ for (const scope of scopeList(opts.scope)) {
272
+ const root = scopeDir(scope, ctx);
273
+ for (const path of walkMarkdown(root)) {
274
+ if (matcher && !matcher.test(path)) continue;
275
+ const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
276
+ meta.scope = scope;
277
+ let baseChars = 0;
278
+ const matches: NoteMatch[] = [];
279
+ for (const [index, line] of body.split("\n").entries()) {
280
+ 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)) earliest = found;
285
+ }
286
+ matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
287
+ }
288
+ baseChars += Array.from(line).length + 1;
289
+ }
290
+ if (matches.length > 0) rows.push({ path, scope, meta, matches });
291
+ }
292
+ }
293
+ rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
294
+ return rows;
295
+ }
@@ -0,0 +1,166 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { localIso } from "../notes.js";
4
+ import { characterWindowHeader, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
5
+ import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
+ import { serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
7
+ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
8
+ import type { Scope } from "./paths.js";
9
+
10
+ const SCOPE = Type.Optional(Type.Union([Type.Literal("session"), Type.Literal("project"), Type.Literal("global")]));
11
+ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")]));
12
+
13
+ /** Render epoch-ms metadata as the same local ISO timestamps the frontmatter carries. */
14
+ function wireMeta(meta: NoteMeta): Record<string, unknown> {
15
+ return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
16
+ }
17
+
18
+ /** Turn a typed store refusal into the pinned error arm; unknown errors stay thrown. */
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 registerMemoryTools(pi: ExtensionAPI) {
30
+ pi.registerTool(defineTool({
31
+ name: "notes_write",
32
+ label: "Notes write",
33
+ description: "Create or replace a note as a real markdown file under the session, project, or global note root. Keep notes small and split by topic; 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.",
34
+ parameters: Type.Object({ path: Type.String(), content: Type.String(), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
35
+ // A batch containing write or edit runs one call at a time, so note read-modify-write cannot race.
36
+ executionMode: "sequential",
37
+ async execute(_id, params, _signal, _update, ctx) {
38
+ const content = params.content;
39
+ try {
40
+ const { meta } = writeNote(ctx, params.path, content, { scope: (params.scope ?? "session") as Scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
41
+ return output({ path: params.path, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
42
+ } catch (error) {
43
+ return failure(error);
44
+ }
45
+ },
46
+ }));
47
+
48
+ pi.registerTool(defineTool({
49
+ name: "notes_edit",
50
+ label: "Notes edit",
51
+ description: "Edit a note body by exact-text replacement; frontmatter is never editable this way. 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 scope/origin/stale. scope/origin/stale are setters: scope moves the file, refusing when the target already exists. The success return carries resolved_scope and a diff of what changed.",
52
+ parameters: Type.Object({ path: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
53
+ executionMode: "sequential",
54
+ async execute(_id, params, _signal, _update, ctx) {
55
+ try {
56
+ const { meta, applied, resolved_scope, diff } = editNote(ctx, params.path, params.edits, { scope: params.scope as Scope | undefined, origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
57
+ return output({ path: params.path, applied, resolved_scope, diff, meta: wireMeta(meta) });
58
+ } catch (error) {
59
+ return failure(error);
60
+ }
61
+ },
62
+ }));
63
+
64
+ pi.registerTool(defineTool({
65
+ name: "notes_read",
66
+ label: "Notes read",
67
+ description: "Read a character window of a note file, frontmatter included: 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 12000, max 50000). 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 file, the resolved offset, the delivered char range, and the resume cursor.",
68
+ parameters: Type.Object({ path: Type.String(), scope: SCOPE, 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: 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." })) }, { additionalProperties: false }),
69
+ async execute(_id, params, _signal, _update, ctx) {
70
+ let note: ReturnType<typeof readNote>;
71
+ try {
72
+ note = readNote(ctx, params.path, { scope: params.scope as Scope | undefined });
73
+ } catch (error) {
74
+ return failure(error);
75
+ }
76
+ if (!note) return output({ error: "note not found", path: params.path });
77
+ const text = serializeNote(note.meta, note.body);
78
+ const totalChars = Array.from(text).length;
79
+ // A positive offset past the end is an addressing error, not an empty page.
80
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
81
+ 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)`, path: params.path, offset_chars: params.offset_chars, total_chars: totalChars });
82
+ }
83
+ const created_at = localIso(note.meta.created_at);
84
+ const updated_at = localIso(note.meta.updated_at);
85
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
86
+ return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
87
+ const { content, ...rest } = window;
88
+ return outputRaw(characterWindowHeader(params.path, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { path: params.path, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
89
+ }, (result) => withinTextBudget(result.content[0].text));
90
+ },
91
+ }));
92
+
93
+ pi.registerTool(defineTool({
94
+ name: "notes_list",
95
+ label: "Notes list",
96
+ description: "List note files as rows carrying path, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. Without scope, all three scopes are merged; a glob pattern (* within a path segment, ** across segments) filters the virtual paths.",
97
+ parameters: Type.Object({ scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
98
+ async execute(_id, params, _signal, _update, ctx) {
99
+ let rows: ReturnType<typeof listNotes>;
100
+ try {
101
+ rows = listNotes(ctx, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
102
+ } catch (error) {
103
+ return failure(error);
104
+ }
105
+ const files: Array<{ path: string; scope: Scope; origin: Origin; status: string; stale: boolean; size_bytes: number; created_at: string; updated_at: string; path_truncated?: boolean }> = rows.map((row) => ({
106
+ path: row.path,
107
+ scope: row.meta.scope,
108
+ origin: row.meta.origin,
109
+ status: row.meta.status,
110
+ stale: row.meta.stale,
111
+ size_bytes: row.sizeBytes,
112
+ created_at: localIso(row.meta.created_at),
113
+ updated_at: localIso(row.meta.updated_at),
114
+ }));
115
+ return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
116
+ if (fits(file)) return file;
117
+ const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
118
+ return { ...file, path, path_truncated: true };
119
+ }));
120
+ },
121
+ }));
122
+
123
+ pi.registerTool(defineTool({
124
+ name: "notes_search",
125
+ label: "Notes search",
126
+ description: "Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. Without scope, all three scopes are merged and every entry carries its scope. 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).",
127
+ parameters: Type.Object({ query: searchQuery(), scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
128
+ async execute(_id, params, _signal, _update, ctx) {
129
+ const queries = searchQueries(params.query);
130
+ let rows: ReturnType<typeof searchNotes>;
131
+ try {
132
+ rows = searchNotes(ctx, queries, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
133
+ } catch (error) {
134
+ return failure(error);
135
+ }
136
+ const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
137
+ const result: Array<{ path: string; scope: Scope; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = rows.map((row) => {
138
+ 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 }));
139
+ return { path: row.path, 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) };
140
+ });
141
+ // Trailing matches are dropped to fit the budget, named by matches_total; a single
142
+ // over-budget line is delivered as a flagged prefix; only a pathological path is
143
+ // middle-truncated, and then only with a visible path_truncated flag.
144
+ const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
145
+ if (fits(file)) return file;
146
+ const matches = file.matches;
147
+ let low = 0;
148
+ let high = matches.length;
149
+ while (low < high) {
150
+ const mid = Math.ceil((low + high) / 2);
151
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
152
+ else high = mid - 1;
153
+ }
154
+ if (low >= 1) return { ...file, matches: matches.slice(0, low) };
155
+ const first = matches[0]!;
156
+ const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
157
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
158
+ const prefix: (typeof result)[number] = fitted(text);
159
+ if (fits(prefix)) return prefix;
160
+ const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
161
+ return { ...prefix, path, path_truncated: true };
162
+ };
163
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
164
+ },
165
+ }));
166
+ }
package/src/prompts.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { historyFromSession } from "./history.js";
3
- import { notesFromSession, localIso } from "./notes.js";
3
+ import { localIso } from "./notes.js";
4
+ import { listNotes, peekNote, resolveNoteScope } from "./memory/store.js";
4
5
  import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, NOTE_PREVIEW_CHARS, NOTE_PREVIEW_HEAD_CHARS, NOTE_PREVIEW_TAIL_CHARS, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
5
6
 
6
7
  /** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
@@ -23,23 +24,33 @@ function identityBlock(agentName: string, firstWindowId: string, currentWindowId
23
24
  * as tail content. Stale notes are excluded entirely; empty when no fresh notes remain.
24
25
  */
25
26
  function notesIndex(ctx: ExtensionContext): string {
26
- const recentNotes = [...notesFromSession(ctx)]
27
- .filter(([, file]) => !file.stale)
28
- .sort((a, b) => b[1].updatedAt - a[1].updatedAt)
29
- .slice(0, 3);
30
- if (recentNotes.length === 0) return "";
31
- const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (up to 3, most recent first):`];
32
- for (const [path, file] of recentNotes) {
33
- lines.push(`- ${path} (${file.text.split("\n").length} lines, ${Buffer.byteLength(file.text, "utf8")} UTF-8 bytes, updated ${localIso(file.updatedAt)})`);
34
- const chars = Array.from(file.text);
35
- // Short notes stay whole; long notes keep both ends. head + tail <= NOTE_PREVIEW_CHARS < chars.length,
36
- // so the slices are disjoint and no character is shown twice.
37
- const preview = chars.length <= NOTE_PREVIEW_CHARS
38
- ? file.text
39
- : `${chars.slice(0, NOTE_PREVIEW_HEAD_CHARS).join("")}…${chars.slice(chars.length - NOTE_PREVIEW_TAIL_CHARS).join("")}`;
40
- lines.push(preview.split("\n").map((line) => ` ${line}`).join("\n"));
27
+ const sections: string[] = [];
28
+ // TOC residency ("地图在场"): the map, when present, is injected whole ahead of the list.
29
+ const toc = resolveNoteScope(ctx, "TOC.md");
30
+ if (toc) {
31
+ const body = peekNote(ctx, toc.scope, "TOC.md").body;
32
+ if (body.length > 0) sections.push(body);
41
33
  }
42
- return lines.join("\n");
34
+ // listNotes is already most-recently-updated first; stale notes never reach the index.
35
+ const recentNotes = listNotes(ctx, {})
36
+ .filter((row) => !row.meta.stale)
37
+ .slice(0, 5);
38
+ if (recentNotes.length > 0) {
39
+ const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (up to 5, most recent first):`];
40
+ for (const row of recentNotes) {
41
+ const body = peekNote(ctx, row.meta.scope, row.path).body;
42
+ lines.push(`- ${row.path} (${body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${localIso(row.meta.updated_at)})`);
43
+ const chars = Array.from(body);
44
+ // Short notes stay whole; long notes keep both ends. head + tail <= NOTE_PREVIEW_CHARS < chars.length,
45
+ // so the slices are disjoint and no character is shown twice.
46
+ const preview = chars.length <= NOTE_PREVIEW_CHARS
47
+ ? body
48
+ : `${chars.slice(0, NOTE_PREVIEW_HEAD_CHARS).join("")}…${chars.slice(chars.length - NOTE_PREVIEW_TAIL_CHARS).join("")}`;
49
+ lines.push(preview.split("\n").map((line) => ` ${line}`).join("\n"));
50
+ }
51
+ sections.push(lines.join("\n"));
52
+ }
53
+ return sections.join("\n\n");
43
54
  }
44
55
 
45
56
  /**
package/src/protocol.ts CHANGED
@@ -26,7 +26,7 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
26
26
  * line (reserve + WARNING_RUNWAY_TOKENS); what lies below is overdraft the model
27
27
  * never sees — Codex's fallback buffer, relocated above the line.
28
28
  */
29
- export const WARNING_RUNWAY_TOKENS = 8_192;
29
+ export const WARNING_RUNWAY_TOKENS = 12_288;
30
30
  export const RESET_SUMMARY =
31
31
  "You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.";
32
32
  export const NOTE_PREVIEW_HEAD_CHARS = 80;
@@ -40,17 +40,17 @@ export const CONTINUATION = "Your memory was just erased. Pull only the details
40
40
  * it is never re-injected, so it stays cache-stable at the head of the window.
41
41
  */
42
42
  export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
43
- Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write_file / notes_append_to_file, read them back with notes_read_file / notes_search_contents; history is read-only through the history_* tools. Everything else wakes blank.
43
+ Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write, revise them with notes_edit, and read them back with notes_read / notes_search / notes_list; history is read-only through the history_* tools. Everything else wakes blank.
44
+ Mark outdated or unneeded notes stale — leave them, and they will keep misleading you.
44
45
 
45
- Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
46
+ Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. history_list returns those IDs; history_read pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
46
47
 
47
48
  Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
48
49
 
49
- If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read_item directly when you know the window and item IDs, history_list_items or history_search_contents to find them when you don't.
50
+ If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
50
51
 
51
- Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
52
+ Notes are real markdown files scoped session, project, or global. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
52
53
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
53
54
 
54
55
  export const WARNING_PROMPT =
55
56
  "Your memory is about to be erased. Write the note. NOW. If it already exists, append instead: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Do not continue any task. Then call new_context IMMEDIATELY — anything not in the note dies with the window.";
56
-
package/src/note-tools.ts DELETED
@@ -1,171 +0,0 @@
1
- import { Type } from "@earendil-works/pi-ai";
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";
4
- import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
- import { notesFromSession, assertVirtualPath, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
6
- import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
7
-
8
- export function registerNoteTools(pi: ExtensionAPI) {
9
- const saveNote = (op: NoteOperation) => {
10
- // pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
11
- // ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
12
- pi.appendEntry(NOTE_TYPE, op);
13
- };
14
-
15
- pi.registerTool(defineTool({
16
- name: "notes_list_files",
17
- label: "Notes list files",
18
- description: "List note files, optionally filtered by a glob pattern (* within a path segment, ** across segments). The default order is most recently updated first; file_order_by (name, created_at, updated_at) and file_order (ascending, descending) select another. Entries carry each file's stale flag.",
19
- parameters: Type.Object({ pattern: nullableString(), max_results: positiveInteger(), cursor: cursor(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
20
- async execute(_id, params, _signal, _update, ctx) {
21
- const pattern = assertGlobPattern(params.pattern);
22
- const matcher = pattern ? globToRegExp(pattern) : undefined;
23
- let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
24
- const key = params.file_order_by ?? "updated_at";
25
- // Deterministic total order: (axis key, createdAt, path) ascending. Paths are unique, so
26
- // this never depends on map iteration order; descending reverses the whole comparator.
27
- files.sort(([aPath, a], [bPath, b]) => {
28
- const primary = key === "name" ? aPath.localeCompare(bPath) : key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt;
29
- if (primary !== 0) return primary;
30
- if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
31
- return aPath.localeCompare(bPath);
32
- });
33
- // An explicit file_order always wins; otherwise the axis's natural direction applies
34
- // (descending for the time axes, ascending for name).
35
- if (params.file_order ? params.file_order === "descending" : key !== "name") files.reverse();
36
- const listed: Array<{ path: string; size_bytes: number; stale: boolean; created_at: string; updated_at: string; path_truncated?: boolean }> = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
37
- // `path` is the entry's identity: return it intact whenever the entry fits, and only
38
- // ever alter it together with a visible `path_truncated: true` flag. A pathological
39
- // legacy path predating the write cap is the one case that cannot fit at all.
40
- return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
41
- if (fits(file)) return file;
42
- const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
43
- return { ...file, path, path_truncated: true };
44
- }));
45
- },
46
- }));
47
-
48
- pi.registerTool(defineTool({
49
- name: "notes_read_file",
50
- label: "Notes read file",
51
- description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw note text behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
52
- parameters: Type.Object({ path: 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: 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." })) }, { additionalProperties: false }),
53
- async execute(_id, params, _signal, _update, ctx) {
54
- const path = assertVirtualPath(params.path);
55
- const file = notesFromSession(ctx).get(path);
56
- if (!file) return output({ error: "note file not found", path });
57
- const totalChars = Array.from(file.text).length;
58
- // A positive offset past the end is an addressing error, not an empty page: say so,
59
- // and name the largest legal offset (offset == total stays the legal empty end-read).
60
- if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
61
- 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)`, path, offset_chars: params.offset_chars, total_chars: totalChars });
62
- }
63
- const created_at = localIso(file.createdAt);
64
- const updated_at = localIso(file.updatedAt);
65
- const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
66
- return readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => {
67
- const { content, ...cursor } = window;
68
- return outputRaw(characterWindowHeader(path, window, ` · created ${created_at} · updated ${updated_at}`), content, { path, ...cursor, limit_chars, created_at, updated_at });
69
- }, (result) => withinTextBudget(result.content[0].text));
70
- },
71
- }));
72
-
73
- pi.registerTool(defineTool({
74
- name: "notes_search_contents",
75
- label: "Notes search",
76
- description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. Optionally filtered by a glob pattern (* within a path segment, ** across segments). Each file entry carries matches_total, its full match count before capping: matches_total minus matches.length is how many were dropped. Each match carries line and offset_chars (the file-absolute code-point offset of the earliest match): notes_read_file at offset_chars shows the query. An over-budget matched line comes back as a prefix with truncated and total_chars; read the rest at the same offset_chars.",
77
- parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), pattern: nullableString() }, { additionalProperties: false }),
78
- async execute(_id, params, _signal, _update, ctx) {
79
- const queries = searchQueries(params.query);
80
- const pattern = assertGlobPattern(params.pattern);
81
- const matcher = pattern ? globToRegExp(pattern) : undefined;
82
- let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
83
- if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
84
- const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
85
- const result: Array<{ path: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = files
86
- .map(([path, file]) => {
87
- // A match's offset_chars is file-absolute: the code points before its line, plus the
88
- // earliest occurrence of any query inside that line. Search then composes with
89
- // notes_read_file exactly like history_search_contents composes with history_read_item.
90
- let baseChars = 0;
91
- const allMatches = file.text.split("\n").flatMap((line, index) => {
92
- const match = queries.some((query) => line.includes(query))
93
- ? [{ line: index + 1, text: line, truncated: false, total_chars: Array.from(line).length, offset_chars: baseChars + earliestMatchOffsetChars(line, queries) }]
94
- : [];
95
- baseChars += Array.from(line).length + 1;
96
- return match;
97
- });
98
- return { path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches_total: allMatches.length, matches: allMatches.slice(0, maxPerFile) };
99
- })
100
- .filter((file) => file.matches.length > 0);
101
- // Trailing matches are dropped to fit the budget (bounded by a monotone binary search),
102
- // and the entry's matches_total keeps naming the drop. Only when a single intact match is
103
- // over budget is its line delivered as a plain prefix, flagged and counted. Only when the
104
- // entry cannot fit even then is the identity field itself truncated, and then only together
105
- // with a visible `path_truncated: true` flag.
106
- const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
107
- if (fits(file)) return file;
108
- const matches = file.matches;
109
- // First, drop whole trailing matches: the largest prefix that fits intact is kept, so an
110
- // entry only truncates a line when that single line alone is over budget.
111
- let low = 0;
112
- let high = matches.length;
113
- while (low < high) {
114
- const mid = Math.ceil((low + high) / 2);
115
- if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
116
- else high = mid - 1;
117
- }
118
- if (low >= 1) return { ...file, matches: matches.slice(0, low) };
119
- // Even one intact match is over budget: keep the first match as a plain, named prefix.
120
- const first = matches[0]!;
121
- const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
122
- const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
123
- const prefix: (typeof result)[number] = fitted(text);
124
- if (fits(prefix)) return prefix;
125
- const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
126
- return { ...prefix, path, path_truncated: true };
127
- };
128
- return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
129
- },
130
- }));
131
-
132
- for (const [name, op] of [["notes_append_to_file", "append"], ["notes_write_file", "write"]] as const) {
133
- pi.registerTool(defineTool({
134
- name,
135
- label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
136
- description: name === "notes_append_to_file"
137
- ? "Append exact text to a note file. Appending suits chronological logs; for current-state notes use notes_write_file instead. mark_stale closes a note."
138
- : "Create or replace a note file. Keep notes small and split by topic; replace outdated notes whole. mark_stale: true flags a note stale (optionally with its final content): stale notes leave the boot index but stay readable and searchable; rewriting revives them.",
139
- parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
140
- // Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
141
- // Pi's per-tool equivalent is executionMode "sequential": a batch containing either
142
- // tool runs its calls one at a time, so note read-modify-write cannot race.
143
- executionMode: "sequential",
144
- async execute(_id, params, _signal, _update, ctx) {
145
- const path = assertVirtualPath(params.path);
146
- const pathBytes = Buffer.byteLength(path, "utf8");
147
- // The cap lives here, at the tool boundary, and never in assertVirtualPath: note
148
- // replay validates persisted ops through that helper and must keep loading sessions
149
- // that already contain a longer legacy path (reads stay un-capped too).
150
- if (pathBytes > MAX_NOTE_PATH_BYTES) return output({ error: `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes`, path_bytes: pathBytes });
151
- const hasText = params.text !== undefined;
152
- const hasStale = params.mark_stale !== undefined;
153
- if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
154
- const old = notesFromSession(ctx).get(path);
155
- if (!hasText && !old) return output({ error: "note file not found", path });
156
- // Appending is not creating: an append to a path that does not exist almost always
157
- // means a typo'd path, so it dies loudly instead of silently minting a new note.
158
- if (op === "append" && hasText && !old) return output({ error: "note file not found (use notes_write_file to create)", path });
159
- const next = hasText ? (op === "append" ? `${old?.text ?? ""}${params.text}` : params.text as string) : old!.text;
160
- const bytes = Buffer.byteLength(next, "utf8");
161
- if (hasText && bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
162
- const now = Date.now();
163
- const operation: NoteOperation = { op, path, createdAt: old?.createdAt ?? now, updatedAt: now };
164
- if (hasText) operation.text = params.text;
165
- if (hasStale) operation.stale = params.mark_stale;
166
- saveNote(operation);
167
- return output({ path, size_bytes: bytes, operation: op, stale: hasStale ? params.mark_stale : false });
168
- },
169
- }));
170
- }
171
- }