@astrosheep/pi-context 0.18.0 → 0.20.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 +10 -10
- package/dist/src/budget.js +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/docs/reset-lifecycle.md +1 -1
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/history-tools.ts +6 -6
- package/src/history.ts +1 -1
- package/src/index.ts +2 -2
- package/src/memory/frontmatter.ts +153 -0
- package/src/memory/paths.ts +60 -0
- package/src/memory/store.ts +310 -0
- package/src/memory/tools.ts +175 -0
- package/src/prompts.ts +28 -17
- package/src/protocol.ts +7 -7
- package/src/note-tools.ts +0 -171
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
/** Dream harness mutation: metadata changes still use the store's atomic writer. */
|
|
166
|
+
export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void): { meta: NoteMeta; body: string } {
|
|
167
|
+
assertVirtualPath(vpath);
|
|
168
|
+
const path = physicalPath(scope, vpath, ctx);
|
|
169
|
+
if (!existsSync(path)) throw new NoteError("not_found", `note not found: ${vpath}`);
|
|
170
|
+
const parsed = parseNote(readFileSync(path, "utf8"));
|
|
171
|
+
const meta = { ...parsed.meta, scope };
|
|
172
|
+
mutate(meta);
|
|
173
|
+
meta.updated_at = Date.now();
|
|
174
|
+
const serialized = serializeNote(meta, parsed.body);
|
|
175
|
+
assertSerializedSize(serialized);
|
|
176
|
+
atomicWrite(path, serialized);
|
|
177
|
+
return { meta, body: parsed.body };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Apply body-only edits against one snapshot, then optionally move via the scope/origin/stale setters. */
|
|
181
|
+
export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
|
|
182
|
+
assertVirtualPath(vpath);
|
|
183
|
+
assertWritablePath(vpath);
|
|
184
|
+
const operations = edits ?? [];
|
|
185
|
+
if (operations.length === 0 && opts.scope === undefined && opts.origin === undefined && opts.stale === undefined) {
|
|
186
|
+
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of scope, origin, stale");
|
|
187
|
+
}
|
|
188
|
+
const found = resolve(ctx, vpath);
|
|
189
|
+
if (!found) throw new NoteError("not_found", "note not found");
|
|
190
|
+
const { meta, body } = parseNote(found.raw);
|
|
191
|
+
// Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
|
|
192
|
+
const beforeMeta: NoteMeta = { ...meta };
|
|
193
|
+
// Every edit runs against this one snapshot; nothing is written until all of them succeed,
|
|
194
|
+
// so a failing edit leaves the file byte-identical (frontmatter included).
|
|
195
|
+
let next = body;
|
|
196
|
+
operations.forEach((edit, index) => {
|
|
197
|
+
const oldText = edit?.oldText;
|
|
198
|
+
const newText = edit?.newText;
|
|
199
|
+
if (typeof oldText !== "string" || oldText.length === 0) throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
|
|
200
|
+
if (typeof newText !== "string") throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
|
|
201
|
+
const lines = matchLineNumbers(next, oldText);
|
|
202
|
+
if (lines.length === 0) throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
|
|
203
|
+
if (lines.length > 1 && !opts.replaceAll) {
|
|
204
|
+
throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { line_numbers: lines, edit_index: index });
|
|
205
|
+
}
|
|
206
|
+
next = opts.replaceAll ? next.split(oldText).join(newText) : next.replace(oldText, newText);
|
|
207
|
+
});
|
|
208
|
+
const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
|
|
209
|
+
if (opts.origin !== undefined) meta.origin = assertOrigin(opts.origin);
|
|
210
|
+
if (opts.stale !== undefined) meta.stale = opts.stale;
|
|
211
|
+
meta.scope = destScope;
|
|
212
|
+
meta.updated_at = Date.now();
|
|
213
|
+
const dest = physicalPath(destScope, vpath, ctx);
|
|
214
|
+
const moving = dest !== found.path;
|
|
215
|
+
if (moving && existsSync(dest)) {
|
|
216
|
+
throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
|
|
217
|
+
}
|
|
218
|
+
const serialized = serializeNote(meta, next);
|
|
219
|
+
assertSerializedSize(serialized);
|
|
220
|
+
// pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
|
|
221
|
+
// update, one combined file diff when both moved.
|
|
222
|
+
const bodyChanged = body !== next;
|
|
223
|
+
const metadataChanged = beforeMeta.scope !== meta.scope || beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
|
|
224
|
+
const diff = bodyChanged && metadataChanged
|
|
225
|
+
? generateDiffString(found.raw, serialized).diff
|
|
226
|
+
: bodyChanged
|
|
227
|
+
? generateDiffString(body, next).diff
|
|
228
|
+
: metadataChanged
|
|
229
|
+
? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
|
|
230
|
+
: "";
|
|
231
|
+
atomicWrite(dest, serialized);
|
|
232
|
+
if (moving) rmSync(found.path);
|
|
233
|
+
return { meta, applied: operations.length, resolved_scope: found.scope, diff };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
|
|
237
|
+
export function readNote(ctx: ExtensionContext, vpath: string, opts: { scope?: Scope } = {}): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
|
|
238
|
+
assertVirtualPath(vpath);
|
|
239
|
+
const found = resolve(ctx, vpath, opts.scope);
|
|
240
|
+
if (!found) return undefined;
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const { meta, body } = parseNote(found.raw, now);
|
|
243
|
+
meta.scope = found.scope;
|
|
244
|
+
// Only the two access keys move; updated_at and every other key keep their bytes.
|
|
245
|
+
meta.last_accessed = now;
|
|
246
|
+
meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
|
|
247
|
+
atomicWrite(found.path, serializeNote(meta, body));
|
|
248
|
+
return { meta, body, resolvedScope: found.scope };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
|
|
252
|
+
export function resolveNoteScope(ctx: ExtensionContext, vpath: string, scope?: Scope): { scope: Scope; path: string } | undefined {
|
|
253
|
+
const found = resolve(ctx, vpath, scope);
|
|
254
|
+
return found ? { scope: found.scope, path: found.path } : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Read a note's meta and body without the read side effect (used by the boot index). */
|
|
258
|
+
export function peekNote(ctx: ExtensionContext, scope: Scope, vpath: string): { meta: NoteMeta; body: string } {
|
|
259
|
+
const path = physicalPath(scope, vpath, ctx);
|
|
260
|
+
const { meta, body } = parseNote(readFileSync(path, "utf8"));
|
|
261
|
+
meta.scope = scope;
|
|
262
|
+
return { meta, body };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Merged rows across scopes, most recently updated first (path then scope break ties). */
|
|
266
|
+
export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
|
|
267
|
+
const matcher = matcherFor(opts.pattern);
|
|
268
|
+
const rows: NoteRow[] = [];
|
|
269
|
+
for (const scope of scopeList(opts.scope)) {
|
|
270
|
+
const root = scopeDir(scope, ctx);
|
|
271
|
+
for (const path of walkMarkdown(root)) {
|
|
272
|
+
if (matcher && !matcher.test(path)) continue;
|
|
273
|
+
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
274
|
+
meta.scope = scope;
|
|
275
|
+
rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.path.localeCompare(b.path) || a.meta.scope.localeCompare(b.meta.scope));
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Case-sensitive literal substring search over note bodies, with a match address per line. */
|
|
283
|
+
export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
|
|
284
|
+
const matcher = matcherFor(opts.pattern);
|
|
285
|
+
const rows: NoteSearchRow[] = [];
|
|
286
|
+
for (const scope of scopeList(opts.scope)) {
|
|
287
|
+
const root = scopeDir(scope, ctx);
|
|
288
|
+
for (const path of walkMarkdown(root)) {
|
|
289
|
+
if (matcher && !matcher.test(path)) continue;
|
|
290
|
+
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
291
|
+
meta.scope = scope;
|
|
292
|
+
let baseChars = 0;
|
|
293
|
+
const matches: NoteMatch[] = [];
|
|
294
|
+
for (const [index, line] of body.split("\n").entries()) {
|
|
295
|
+
if (queries.some((query) => line.includes(query))) {
|
|
296
|
+
let earliest = -1;
|
|
297
|
+
for (const query of queries) {
|
|
298
|
+
const found = line.indexOf(query);
|
|
299
|
+
if (found >= 0 && (earliest < 0 || found < earliest)) earliest = found;
|
|
300
|
+
}
|
|
301
|
+
matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
|
|
302
|
+
}
|
|
303
|
+
baseChars += Array.from(line).length + 1;
|
|
304
|
+
}
|
|
305
|
+
if (matches.length > 0) rows.push({ path, scope, meta, matches });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
rows.sort((a, b) => a.path.localeCompare(b.path) || a.scope.localeCompare(b.scope));
|
|
309
|
+
return rows;
|
|
310
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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(
|
|
11
|
+
Type.Union([Type.Literal("session"), Type.Literal("project"), Type.Literal("global")], {
|
|
12
|
+
description:
|
|
13
|
+
"The note's reach — which root it lives under. session: only this session needs it (checkpoints, scratch state, worker rosters); dies with the session. project: tied to the current working directory — design decisions and repo facts that future sessions here still need. global: follows you everywhere — user laws, preferences, cross-project maps. On write, picks the destination root (default: session). Omit on read/list/search to cover all three; a read resolves session → project → global and returns the first existing file.",
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
const ORIGIN = Type.Optional(
|
|
17
|
+
Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
18
|
+
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.",
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** Render epoch-ms metadata as the same local ISO timestamps the frontmatter carries. */
|
|
23
|
+
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
24
|
+
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Turn a typed store refusal into the pinned error arm; unknown errors stay thrown. */
|
|
28
|
+
function failure(error: unknown) {
|
|
29
|
+
if (error instanceof NoteError) {
|
|
30
|
+
const payload: Record<string, unknown> = { error: error.message };
|
|
31
|
+
if (error.line_numbers) payload.line_numbers = error.line_numbers;
|
|
32
|
+
if (error.edit_index !== undefined) payload.edit_index = error.edit_index;
|
|
33
|
+
return output(payload);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerMemoryTools(pi: ExtensionAPI) {
|
|
39
|
+
pi.registerTool(defineTool({
|
|
40
|
+
name: "notes_write",
|
|
41
|
+
label: "Notes write",
|
|
42
|
+
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 — 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.",
|
|
43
|
+
parameters: Type.Object({ path: Type.String(), content: Type.String(), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
44
|
+
// A batch containing write or edit runs one call at a time, so note read-modify-write cannot race.
|
|
45
|
+
executionMode: "sequential",
|
|
46
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
47
|
+
const content = params.content;
|
|
48
|
+
try {
|
|
49
|
+
const { meta } = writeNote(ctx, params.path, content, { scope: (params.scope ?? "session") as Scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
50
|
+
return output({ path: params.path, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return failure(error);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
pi.registerTool(defineTool({
|
|
58
|
+
name: "notes_edit",
|
|
59
|
+
label: "Notes edit",
|
|
60
|
+
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.",
|
|
61
|
+
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 }),
|
|
62
|
+
executionMode: "sequential",
|
|
63
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
64
|
+
try {
|
|
65
|
+
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 });
|
|
66
|
+
return output({ path: params.path, applied, resolved_scope, diff, meta: wireMeta(meta) });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return failure(error);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
pi.registerTool(defineTool({
|
|
74
|
+
name: "notes_read",
|
|
75
|
+
label: "Notes read",
|
|
76
|
+
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.",
|
|
77
|
+
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 }),
|
|
78
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
79
|
+
let note: ReturnType<typeof readNote>;
|
|
80
|
+
try {
|
|
81
|
+
note = readNote(ctx, params.path, { scope: params.scope as Scope | undefined });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return failure(error);
|
|
84
|
+
}
|
|
85
|
+
if (!note) return output({ error: "note not found", path: params.path });
|
|
86
|
+
const text = serializeNote(note.meta, note.body);
|
|
87
|
+
const totalChars = Array.from(text).length;
|
|
88
|
+
// A positive offset past the end is an addressing error, not an empty page.
|
|
89
|
+
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
90
|
+
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 });
|
|
91
|
+
}
|
|
92
|
+
const created_at = localIso(note.meta.created_at);
|
|
93
|
+
const updated_at = localIso(note.meta.updated_at);
|
|
94
|
+
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
95
|
+
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
96
|
+
const { content, ...rest } = window;
|
|
97
|
+
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 });
|
|
98
|
+
}, (result) => withinTextBudget(result.content[0].text));
|
|
99
|
+
},
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
pi.registerTool(defineTool({
|
|
103
|
+
name: "notes_list",
|
|
104
|
+
label: "Notes list",
|
|
105
|
+
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.",
|
|
106
|
+
parameters: Type.Object({ scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
107
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
108
|
+
let rows: ReturnType<typeof listNotes>;
|
|
109
|
+
try {
|
|
110
|
+
rows = listNotes(ctx, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return failure(error);
|
|
113
|
+
}
|
|
114
|
+
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) => ({
|
|
115
|
+
path: row.path,
|
|
116
|
+
scope: row.meta.scope,
|
|
117
|
+
origin: row.meta.origin,
|
|
118
|
+
status: row.meta.status,
|
|
119
|
+
stale: row.meta.stale,
|
|
120
|
+
size_bytes: row.sizeBytes,
|
|
121
|
+
created_at: localIso(row.meta.created_at),
|
|
122
|
+
updated_at: localIso(row.meta.updated_at),
|
|
123
|
+
}));
|
|
124
|
+
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
125
|
+
if (fits(file)) return file;
|
|
126
|
+
const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
|
|
127
|
+
return { ...file, path, path_truncated: true };
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
pi.registerTool(defineTool({
|
|
133
|
+
name: "notes_search",
|
|
134
|
+
label: "Notes search",
|
|
135
|
+
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).",
|
|
136
|
+
parameters: Type.Object({ query: searchQuery(), scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
137
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
138
|
+
const queries = searchQueries(params.query);
|
|
139
|
+
let rows: ReturnType<typeof searchNotes>;
|
|
140
|
+
try {
|
|
141
|
+
rows = searchNotes(ctx, queries, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failure(error);
|
|
144
|
+
}
|
|
145
|
+
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
146
|
+
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) => {
|
|
147
|
+
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 }));
|
|
148
|
+
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) };
|
|
149
|
+
});
|
|
150
|
+
// Trailing matches are dropped to fit the budget, named by matches_total; a single
|
|
151
|
+
// over-budget line is delivered as a flagged prefix; only a pathological path is
|
|
152
|
+
// middle-truncated, and then only with a visible path_truncated flag.
|
|
153
|
+
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
154
|
+
if (fits(file)) return file;
|
|
155
|
+
const matches = file.matches;
|
|
156
|
+
let low = 0;
|
|
157
|
+
let high = matches.length;
|
|
158
|
+
while (low < high) {
|
|
159
|
+
const mid = Math.ceil((low + high) / 2);
|
|
160
|
+
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
|
|
161
|
+
else high = mid - 1;
|
|
162
|
+
}
|
|
163
|
+
if (low >= 1) return { ...file, matches: matches.slice(0, low) };
|
|
164
|
+
const first = matches[0]!;
|
|
165
|
+
const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
166
|
+
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
167
|
+
const prefix: (typeof result)[number] = fitted(text);
|
|
168
|
+
if (fits(prefix)) return prefix;
|
|
169
|
+
const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
|
|
170
|
+
return { ...prefix, path, path_truncated: true };
|
|
171
|
+
};
|
|
172
|
+
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
}
|
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 {
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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_*:
|
|
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
|
|
52
|
+
Notes are real markdown files scoped session, project, or global — pick scope by reach: session dies with the session, project follows the repo, global follows you. 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
|
-
"Your memory is about to be erased. Write the note. NOW. If it already exists,
|
|
56
|
-
|
|
56
|
+
"Your memory is about to be erased. Write the note. NOW. If it already exists, revise it with notes_edit (or rewrite it whole): 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.";
|