@astrosheep/pi-context 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/src/budget.js +10 -8
- package/dist/src/dream/cli.js +108 -24
- package/dist/src/dream/gates.js +13 -8
- package/dist/src/dream/git.js +71 -0
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +90 -21
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +7 -5
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +7 -3
- package/dist/src/{memory → notes}/store.js +47 -74
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +38 -29
- package/dist/src/protocol.js +9 -4
- package/dist/src/thresholds.js +33 -3
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +419 -35
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +107 -47
- package/dist/test/{memory.test.js → notes.test.js} +154 -50
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +95 -17
- package/src/dream/gates.ts +14 -7
- package/src/dream/git.ts +73 -0
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +87 -20
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +7 -5
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +8 -3
- package/src/{memory → notes}/store.ts +49 -79
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +39 -29
- package/src/protocol.ts +9 -4
- package/src/thresholds.ts +38 -6
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- package/src/memory/tools.ts +0 -175
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
export type Scope = "session" | "project" | "
|
|
7
|
+
export type Scope = "session" | "project" | "personal";
|
|
8
8
|
|
|
9
9
|
/** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
|
|
10
10
|
export function notesRoot(): string {
|
|
@@ -12,6 +12,11 @@ export function notesRoot(): string {
|
|
|
12
12
|
return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Absolute directory holding the per-session note homes. */
|
|
16
|
+
export function sessionHomesRoot(home = notesRoot()): string {
|
|
17
|
+
return join(home, "pi", "session");
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
|
|
17
22
|
* No git root yields undefined, which projectKey then replaces with the cwd itself.
|
|
@@ -41,9 +46,9 @@ function sessionId(ctx: ExtensionContext): string {
|
|
|
41
46
|
|
|
42
47
|
/** Absolute directory holding every note of one scope. */
|
|
43
48
|
export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
|
|
44
|
-
if (scope === "
|
|
49
|
+
if (scope === "personal") return join(notesRoot(), "personal");
|
|
45
50
|
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
46
|
-
return join(
|
|
51
|
+
return join(sessionHomesRoot(), sessionId(ctx));
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -3,10 +3,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { generateDiffString } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { assertGlobPattern, assertVirtualPath, globToRegExp } from "
|
|
6
|
+
import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
|
|
7
7
|
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
|
|
8
8
|
import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
|
|
9
|
+
import { addressFor } from "./address.js";
|
|
9
10
|
import { physicalPath, scopeDir, type Scope } from "./paths.js";
|
|
11
|
+
import { earliestMatchOffsetChars } from "../tool-output.js";
|
|
10
12
|
|
|
11
13
|
export type { NoteMeta, Origin, Scope };
|
|
12
14
|
|
|
@@ -26,14 +28,14 @@ export class NoteError extends Error {
|
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export type NoteRow = { path: string; meta: NoteMeta; sizeBytes: number };
|
|
31
|
+
export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteMeta; body: string; sizeBytes: number };
|
|
30
32
|
export type NoteMatch = { line: number; text: string; offsetChars: number };
|
|
31
|
-
export type NoteSearchRow = {
|
|
33
|
+
export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
|
|
32
34
|
|
|
33
|
-
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "
|
|
35
|
+
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "personal"];
|
|
34
36
|
|
|
35
37
|
function assertScope(value: unknown): Scope {
|
|
36
|
-
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
38
|
+
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
|
|
37
39
|
return value;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -42,22 +44,6 @@ function assertOrigin(value: unknown): Origin {
|
|
|
42
44
|
return value;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function scopeList(scope?: unknown): Scope[] {
|
|
46
|
-
if (scope === undefined || scope === null) return [...SCOPE_ORDER];
|
|
47
|
-
return [assertScope(scope)];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
type Resolved = { scope: Scope; path: string; raw: string };
|
|
51
|
-
|
|
52
|
-
/** First existing file by precedence session → project → global, or only `scope` when given. */
|
|
53
|
-
function resolve(ctx: ExtensionContext, vpath: string, scope?: unknown): Resolved | undefined {
|
|
54
|
-
for (const candidate of scopeList(scope)) {
|
|
55
|
-
const path = physicalPath(candidate, vpath, ctx);
|
|
56
|
-
if (existsSync(path)) return { scope: candidate, path, raw: readFileSync(path, "utf8") };
|
|
57
|
-
}
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
47
|
/** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
|
|
62
48
|
function walkMarkdown(dir: string, base = dir): string[] {
|
|
63
49
|
let entries: Dirent[];
|
|
@@ -160,7 +146,7 @@ export function writeNote(ctx: ExtensionContext, vpath: string, body: string, op
|
|
|
160
146
|
}
|
|
161
147
|
|
|
162
148
|
export type EditOperation = { oldText: string; newText: string };
|
|
163
|
-
export type EditOptions = {
|
|
149
|
+
export type EditOptions = { origin?: Origin; stale?: boolean; replaceAll?: boolean };
|
|
164
150
|
|
|
165
151
|
/** Dream harness mutation: metadata changes still use the store's atomic writer. */
|
|
166
152
|
export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void): { meta: NoteMeta; body: string } {
|
|
@@ -177,17 +163,19 @@ export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scop
|
|
|
177
163
|
return { meta, body: parsed.body };
|
|
178
164
|
}
|
|
179
165
|
|
|
180
|
-
/** Apply body-only edits against one
|
|
181
|
-
export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
|
|
166
|
+
/** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
|
|
167
|
+
export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
|
|
182
168
|
assertVirtualPath(vpath);
|
|
183
169
|
assertWritablePath(vpath);
|
|
184
170
|
const operations = edits ?? [];
|
|
185
|
-
if (operations.length === 0 && opts.
|
|
186
|
-
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of
|
|
171
|
+
if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
|
|
172
|
+
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
|
|
187
173
|
}
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
const
|
|
174
|
+
const path = physicalPath(scope, vpath, ctx);
|
|
175
|
+
if (!existsSync(path)) throw new NoteError("not_found", "note not found");
|
|
176
|
+
const raw = readFileSync(path, "utf8");
|
|
177
|
+
const { meta, body } = parseNote(raw);
|
|
178
|
+
meta.scope = scope;
|
|
191
179
|
// Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
|
|
192
180
|
const beforeMeta: NoteMeta = { ...meta };
|
|
193
181
|
// Every edit runs against this one snapshot; nothing is written until all of them succeed,
|
|
@@ -203,79 +191,65 @@ export function editNote(ctx: ExtensionContext, vpath: string, edits: EditOperat
|
|
|
203
191
|
if (lines.length > 1 && !opts.replaceAll) {
|
|
204
192
|
throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { line_numbers: lines, edit_index: index });
|
|
205
193
|
}
|
|
206
|
-
|
|
194
|
+
// Single replacement is positional splicing, never String.replace: user text must be
|
|
195
|
+
// inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
|
|
196
|
+
if (opts.replaceAll) {
|
|
197
|
+
next = next.split(oldText).join(newText);
|
|
198
|
+
} else {
|
|
199
|
+
const matchIndex = next.indexOf(oldText);
|
|
200
|
+
next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
|
|
201
|
+
}
|
|
207
202
|
});
|
|
208
|
-
const destScope = opts.scope === undefined ? found.scope : assertScope(opts.scope);
|
|
209
203
|
if (opts.origin !== undefined) meta.origin = assertOrigin(opts.origin);
|
|
210
204
|
if (opts.stale !== undefined) meta.stale = opts.stale;
|
|
211
|
-
meta.scope = destScope;
|
|
212
205
|
meta.updated_at = Date.now();
|
|
213
|
-
const dest = physicalPath(destScope, vpath, ctx);
|
|
214
|
-
const moving = dest !== found.path;
|
|
215
|
-
if (moving && existsSync(dest)) {
|
|
216
|
-
throw new NoteError("target_exists", `a note already exists at ${vpath} in scope ${destScope}; the move was refused and both files are unchanged`);
|
|
217
|
-
}
|
|
218
206
|
const serialized = serializeNote(meta, next);
|
|
219
207
|
assertSerializedSize(serialized);
|
|
220
208
|
// pi-edit-style diff: body only for a content edit, frontmatter only for a metadata-only
|
|
221
|
-
// update, one combined file diff when both
|
|
209
|
+
// update, one combined file diff when both change.
|
|
222
210
|
const bodyChanged = body !== next;
|
|
223
|
-
const metadataChanged = beforeMeta.
|
|
211
|
+
const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
|
|
224
212
|
const diff = bodyChanged && metadataChanged
|
|
225
|
-
? generateDiffString(
|
|
213
|
+
? generateDiffString(raw, serialized).diff
|
|
226
214
|
: bodyChanged
|
|
227
215
|
? generateDiffString(body, next).diff
|
|
228
216
|
: metadataChanged
|
|
229
217
|
? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
|
|
230
218
|
: "";
|
|
231
|
-
atomicWrite(
|
|
232
|
-
|
|
233
|
-
return { meta, applied: operations.length, resolved_scope: found.scope, diff };
|
|
219
|
+
atomicWrite(path, serialized);
|
|
220
|
+
return { meta, applied: operations.length, resolved_scope: scope, diff };
|
|
234
221
|
}
|
|
235
222
|
|
|
236
223
|
/** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
|
|
237
|
-
export function readNote(ctx: ExtensionContext, vpath: string,
|
|
224
|
+
export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; resolvedScope: Scope } | undefined {
|
|
238
225
|
assertVirtualPath(vpath);
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
226
|
+
const path = physicalPath(scope, vpath, ctx);
|
|
227
|
+
if (!existsSync(path)) return undefined;
|
|
241
228
|
const now = Date.now();
|
|
242
|
-
const { meta, body } = parseNote(
|
|
243
|
-
meta.scope =
|
|
229
|
+
const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
|
|
230
|
+
meta.scope = scope;
|
|
244
231
|
// Only the two access keys move; updated_at and every other key keep their bytes.
|
|
245
232
|
meta.last_accessed = now;
|
|
246
233
|
meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
|
|
247
|
-
atomicWrite(
|
|
248
|
-
return { meta, body, resolvedScope:
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** The scope that holds `vpath` first by precedence, without reading or mutating the file. */
|
|
252
|
-
export function resolveNoteScope(ctx: ExtensionContext, vpath: string, scope?: Scope): { scope: Scope; path: string } | undefined {
|
|
253
|
-
const found = resolve(ctx, vpath, scope);
|
|
254
|
-
return found ? { scope: found.scope, path: found.path } : undefined;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Read a note's meta and body without the read side effect (used by the boot index). */
|
|
258
|
-
export function peekNote(ctx: ExtensionContext, scope: Scope, vpath: string): { meta: NoteMeta; body: string } {
|
|
259
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
260
|
-
const { meta, body } = parseNote(readFileSync(path, "utf8"));
|
|
261
|
-
meta.scope = scope;
|
|
262
|
-
return { meta, body };
|
|
234
|
+
atomicWrite(path, serializeNote(meta, body));
|
|
235
|
+
return { meta, body, resolvedScope: scope };
|
|
263
236
|
}
|
|
264
237
|
|
|
265
|
-
/** Merged rows across
|
|
238
|
+
/** Merged rows across homes, most recently updated first (address breaks ties). */
|
|
266
239
|
export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
|
|
267
240
|
const matcher = matcherFor(opts.pattern);
|
|
268
241
|
const rows: NoteRow[] = [];
|
|
269
|
-
for (const scope of
|
|
242
|
+
for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
|
|
270
243
|
const root = scopeDir(scope, ctx);
|
|
271
244
|
for (const path of walkMarkdown(root)) {
|
|
272
|
-
|
|
245
|
+
const address = addressFor(scope, path);
|
|
246
|
+
if (matcher && !matcher.test(address)) continue;
|
|
273
247
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
274
248
|
meta.scope = scope;
|
|
275
|
-
rows.push({ path, meta, sizeBytes: Buffer.byteLength(body, "utf8") });
|
|
249
|
+
rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
|
|
276
250
|
}
|
|
277
251
|
}
|
|
278
|
-
rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.
|
|
252
|
+
rows.sort((a, b) => b.meta.updated_at - a.meta.updated_at || a.address.localeCompare(b.address));
|
|
279
253
|
return rows;
|
|
280
254
|
}
|
|
281
255
|
|
|
@@ -283,28 +257,24 @@ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?
|
|
|
283
257
|
export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
|
|
284
258
|
const matcher = matcherFor(opts.pattern);
|
|
285
259
|
const rows: NoteSearchRow[] = [];
|
|
286
|
-
for (const scope of
|
|
260
|
+
for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
|
|
287
261
|
const root = scopeDir(scope, ctx);
|
|
288
262
|
for (const path of walkMarkdown(root)) {
|
|
289
|
-
|
|
263
|
+
const address = addressFor(scope, path);
|
|
264
|
+
if (matcher && !matcher.test(address)) continue;
|
|
290
265
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
291
266
|
meta.scope = scope;
|
|
292
267
|
let baseChars = 0;
|
|
293
268
|
const matches: NoteMatch[] = [];
|
|
294
269
|
for (const [index, line] of body.split("\n").entries()) {
|
|
295
270
|
if (queries.some((query) => line.includes(query))) {
|
|
296
|
-
|
|
297
|
-
for (const query of queries) {
|
|
298
|
-
const found = line.indexOf(query);
|
|
299
|
-
if (found >= 0 && (earliest < 0 || found < earliest)) earliest = found;
|
|
300
|
-
}
|
|
301
|
-
matches.push({ line: index + 1, text: line, offsetChars: baseChars + (earliest <= 0 ? 0 : Array.from(line.slice(0, earliest)).length) });
|
|
271
|
+
matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
|
|
302
272
|
}
|
|
303
273
|
baseChars += Array.from(line).length + 1;
|
|
304
274
|
}
|
|
305
|
-
if (matches.length > 0) rows.push({ path, scope, meta, matches });
|
|
275
|
+
if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
|
|
306
276
|
}
|
|
307
277
|
}
|
|
308
|
-
rows.sort((a, b) => a.
|
|
278
|
+
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
309
279
|
return rows;
|
|
310
280
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { localIso } from "./model.js";
|
|
4
|
+
import { characterWindowHeader, DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
|
|
5
|
+
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
|
+
import { assertAddress } from "./address.js";
|
|
7
|
+
import { serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
|
|
8
|
+
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
9
|
+
|
|
10
|
+
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
11
|
+
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
12
|
+
}));
|
|
13
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
14
|
+
|
|
15
|
+
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
16
|
+
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function failure(error: unknown) {
|
|
20
|
+
if (error instanceof NoteError) {
|
|
21
|
+
const payload: Record<string, unknown> = { error: error.message };
|
|
22
|
+
if (error.line_numbers) payload.line_numbers = error.line_numbers;
|
|
23
|
+
if (error.edit_index !== undefined) payload.edit_index = error.edit_index;
|
|
24
|
+
return output(payload);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerNotesTools(pi: ExtensionAPI) {
|
|
30
|
+
pi.registerTool(defineTool({
|
|
31
|
+
name: "notes_write", label: "Notes write",
|
|
32
|
+
description: `Create or replace a note as a real markdown file, and name it for what it holds: a fresh window sees only an index entry, never the note itself. ${ADDRESS_DESCRIPTION} Keep notes small and split by topic — by what the note is about, never by who said it (authorship is origin's job); a rewrite replaces the body whole while preserving created_at and every other frontmatter key. stale: true marks the note closed so it leaves the boot index but stays readable and searchable.`,
|
|
33
|
+
parameters: Type.Object({ address: Type.String(), content: Type.String(), origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
|
|
34
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
35
|
+
const content = params.content;
|
|
36
|
+
try {
|
|
37
|
+
const destination = assertAddress(params.address);
|
|
38
|
+
const { meta } = writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
39
|
+
return output({ address: params.address, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
|
|
40
|
+
} catch (error) { return failure(error); }
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
pi.registerTool(defineTool({
|
|
45
|
+
name: "notes_edit", label: "Notes edit",
|
|
46
|
+
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries resolved_scope and a diff of what changed.`,
|
|
47
|
+
parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
|
|
48
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
49
|
+
try {
|
|
50
|
+
const destination = assertAddress(params.address);
|
|
51
|
+
const { meta, applied, resolved_scope, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
|
|
52
|
+
return output({ address: params.address, applied, resolved_scope, diff, meta: wireMeta(meta) });
|
|
53
|
+
} catch (error) { return failure(error); }
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
pi.registerTool(defineTool({
|
|
58
|
+
name: "notes_read", label: "Notes read",
|
|
59
|
+
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the address, the resolved offset, the delivered char range, and the resume cursor.`,
|
|
60
|
+
parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
|
|
61
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
62
|
+
let note: ReturnType<typeof readNote>;
|
|
63
|
+
try {
|
|
64
|
+
const destination = assertAddress(params.address);
|
|
65
|
+
note = readNote(ctx, destination.path, destination.scope);
|
|
66
|
+
} catch (error) { return failure(error); }
|
|
67
|
+
if (!note) return output({ error: "note not found", address: params.address });
|
|
68
|
+
const text = serializeNote(note.meta, note.body);
|
|
69
|
+
const totalChars = Array.from(text).length;
|
|
70
|
+
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
71
|
+
const created_at = localIso(note.meta.created_at);
|
|
72
|
+
const updated_at = localIso(note.meta.updated_at);
|
|
73
|
+
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
74
|
+
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
75
|
+
const { content, ...rest } = window;
|
|
76
|
+
return outputRaw(characterWindowHeader(params.address, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { address: params.address, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
|
|
77
|
+
}, (result) => withinTextBudget(result.content[0].text));
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
pi.registerTool(defineTool({
|
|
82
|
+
name: "notes_list", label: "Notes list",
|
|
83
|
+
description: `List note files as rows carrying address, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
|
|
84
|
+
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
85
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
86
|
+
let rows: ReturnType<typeof listNotes>;
|
|
87
|
+
try { rows = listNotes(ctx, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
88
|
+
const files: Array<{ address: string; scope: string; origin: Origin; status: string; stale: boolean; size_bytes: number; created_at: string; updated_at: string; address_truncated?: boolean }> = rows.map((row) => ({ address: row.address, scope: row.scope, origin: row.meta.origin, status: row.meta.status, stale: row.meta.stale, size_bytes: row.sizeBytes, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at) }));
|
|
89
|
+
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
90
|
+
if (fits(file)) return file;
|
|
91
|
+
const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
|
|
92
|
+
return { ...file, address, address_truncated: true };
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
pi.registerTool(defineTool({
|
|
98
|
+
name: "notes_search", label: "Notes search",
|
|
99
|
+
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address and derived scope. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).`,
|
|
100
|
+
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
101
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
102
|
+
const queries = searchQueries(params.query);
|
|
103
|
+
let rows: ReturnType<typeof searchNotes>;
|
|
104
|
+
try { rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
105
|
+
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
106
|
+
const result: Array<{ address: string; scope: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; address_truncated?: boolean }> = rows.map((row) => {
|
|
107
|
+
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
|
|
108
|
+
return { address: row.address, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
109
|
+
});
|
|
110
|
+
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
111
|
+
if (fits(file)) return file;
|
|
112
|
+
const matches = file.matches;
|
|
113
|
+
let low = 0;
|
|
114
|
+
let high = matches.length;
|
|
115
|
+
while (low < high) {
|
|
116
|
+
const mid = Math.ceil((low + high) / 2);
|
|
117
|
+
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
|
|
118
|
+
else high = mid - 1;
|
|
119
|
+
}
|
|
120
|
+
if (low >= 1) return { ...file, matches: matches.slice(0, low) };
|
|
121
|
+
const first = matches[0]!;
|
|
122
|
+
const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
123
|
+
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
124
|
+
const prefix = fitted(text);
|
|
125
|
+
if (fits(prefix)) return prefix;
|
|
126
|
+
const address = middleTruncate(prefix.address, (candidate) => fits({ ...prefix, address: candidate, address_truncated: true }));
|
|
127
|
+
return { ...prefix, address, address_truncated: true };
|
|
128
|
+
};
|
|
129
|
+
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { historyFromSession } from "./history.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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";
|
|
3
|
+
import { listNotes } from "./notes/store.js";
|
|
4
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_PERSONAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
|
|
6
5
|
|
|
7
6
|
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
8
7
|
function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
@@ -15,44 +14,55 @@ function identityBlock(agentName: string, firstWindowId: string, currentWindowId
|
|
|
15
14
|
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
16
15
|
}
|
|
17
16
|
|
|
17
|
+
function relativeTime(timestamp: number, now: number): string {
|
|
18
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
19
|
+
const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
|
|
20
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
|
|
21
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
22
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
26
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
27
|
+
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
28
|
+
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
29
|
+
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
30
|
+
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
31
|
+
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
32
|
+
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
25
33
|
*/
|
|
26
34
|
function notesIndex(ctx: ExtensionContext): string {
|
|
27
35
|
const sections: string[] = [];
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
36
|
+
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
37
|
+
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
38
|
+
for (const scope of ["personal", "project"] as const) {
|
|
39
|
+
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
40
|
+
if (toc && !toc.meta.stale) {
|
|
41
|
+
if (toc.body.length > 0) sections.push(toc.body);
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
|
-
// listNotes is
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.slice(0,
|
|
44
|
+
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
45
|
+
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
46
|
+
const recentNotes = [
|
|
47
|
+
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
48
|
+
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
49
|
+
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
50
|
+
];
|
|
38
51
|
if (recentNotes.length > 0) {
|
|
39
|
-
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (up to
|
|
52
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_PERSONAL_LIMIT} from personal). A note's content never appears here, so its name has to say what the note is about:`];
|
|
53
|
+
const now = Date.now();
|
|
40
54
|
for (const row of recentNotes) {
|
|
41
|
-
|
|
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"));
|
|
55
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
50
56
|
}
|
|
51
57
|
sections.push(lines.join("\n"));
|
|
52
58
|
}
|
|
53
59
|
return sections.join("\n\n");
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
function notesHomeBlock(): string {
|
|
63
|
+
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @personal/<vpath> is the human's cross-project home. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
/**
|
|
57
67
|
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
|
58
68
|
* <context_window> identity block, the recent-notes index at window-open time, and
|
|
@@ -64,6 +74,7 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
|
|
|
64
74
|
const parts: string[] = [];
|
|
65
75
|
if (resetLine) parts.push(RESET_SUMMARY);
|
|
66
76
|
parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
|
|
77
|
+
parts.push(notesHomeBlock());
|
|
67
78
|
const index = notesIndex(ctx);
|
|
68
79
|
if (index) parts.push(index);
|
|
69
80
|
parts.push(PROTOCOL_BLOCK);
|
|
@@ -78,4 +89,3 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
|
|
|
78
89
|
export function tokenBudgetGuidance(remaining: number): string {
|
|
79
90
|
return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — 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. Replacing an older checkpoint? Mark it stale. Then end the window yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
80
91
|
}
|
|
81
|
-
|
package/src/protocol.ts
CHANGED
|
@@ -7,6 +7,9 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
7
7
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
8
8
|
export const RESET_V2 = "reset-v2";
|
|
9
9
|
export const MAX_NOTE_BYTES = 1_000_000;
|
|
10
|
+
export const POCKET_SESSION_LIMIT = 5;
|
|
11
|
+
export const POCKET_PROJECT_LIMIT = 2;
|
|
12
|
+
export const POCKET_PERSONAL_LIMIT = 2;
|
|
10
13
|
// Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
|
|
11
14
|
// notesFromSession replays already-persisted operations, which must keep loading sessions
|
|
12
15
|
// that contain a longer legacy path. Reads and replay stay un-capped.
|
|
@@ -18,6 +21,8 @@ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
|
|
|
18
21
|
export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
|
|
19
22
|
export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
20
23
|
export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
|
|
24
|
+
/** Nested under "pi-context": the default dreamer model pattern, overridden by CLI --dreamer. */
|
|
25
|
+
export const PI_CONTEXT_DREAMER_KEY = "dreamer";
|
|
21
26
|
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
22
27
|
export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
23
28
|
/**
|
|
@@ -29,9 +34,6 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
|
29
34
|
export const WARNING_RUNWAY_TOKENS = 12_288;
|
|
30
35
|
export const RESET_SUMMARY =
|
|
31
36
|
"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
|
-
export const NOTE_PREVIEW_HEAD_CHARS = 80;
|
|
33
|
-
export const NOTE_PREVIEW_TAIL_CHARS = 240;
|
|
34
|
-
export const NOTE_PREVIEW_CHARS = NOTE_PREVIEW_HEAD_CHARS + NOTE_PREVIEW_TAIL_CHARS;
|
|
35
37
|
export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -49,7 +51,10 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
|
|
|
49
51
|
|
|
50
52
|
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.
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
Your notes live in three homes: this session (bare names), this project (@project/<vpath>), the human across projects (@personal/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
|
|
55
|
+
Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
|
|
56
|
+
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
57
|
+
@personal notes hold the human's durable preferences and standing rules, plus lessons that apply across projects. Duration does not make a note personal; its stated scope must already be broader than the project or conversation at hand. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
|
|
53
58
|
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
54
59
|
|
|
55
60
|
export const WARNING_PROMPT =
|
package/src/thresholds.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
2
|
+
import { PI_CONTEXT_SETTINGS_KEY, PI_CONTEXT_DREAMER_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
3
3
|
|
|
4
4
|
export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
|
|
5
|
-
type
|
|
5
|
+
type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
|
|
6
6
|
|
|
7
7
|
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
8
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -16,9 +16,9 @@ function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
19
|
-
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown):
|
|
19
|
+
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
|
|
20
20
|
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
21
|
-
return { reminderMarginTokens: merged.reminderMarginTokens };
|
|
21
|
+
return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
@@ -33,7 +33,7 @@ function validMargin(raw: unknown): number | undefined {
|
|
|
33
33
|
* An invalid margin degrades to the default and reports one warning. Pi's automatic
|
|
34
34
|
* threshold/overflow compaction itself resets immediately, with no model turn.
|
|
35
35
|
*/
|
|
36
|
-
export function deriveThresholds(reserveTokens: number, margins:
|
|
36
|
+
export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
37
37
|
const warnings: string[] = [];
|
|
38
38
|
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
39
39
|
let reminderMargin: number;
|
|
@@ -48,6 +48,35 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextMargin
|
|
|
48
48
|
return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export type DreamerSetting = { pattern?: string; warnings: string[] };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
|
|
55
|
+
* with one warning; absent means no configured pattern, so the automatic model applies.
|
|
56
|
+
*/
|
|
57
|
+
export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
|
|
58
|
+
const raw = settings.dreamer;
|
|
59
|
+
if (raw === undefined) return { warnings: [] };
|
|
60
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
61
|
+
return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
|
|
62
|
+
}
|
|
63
|
+
return { pattern: raw.trim(), warnings: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
|
|
68
|
+
* `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
|
|
69
|
+
* values winning per key. A settings read failure degrades to no pattern with one warning.
|
|
70
|
+
*/
|
|
71
|
+
export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
|
|
72
|
+
try {
|
|
73
|
+
const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
|
|
74
|
+
return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
let cached: ResolvedThresholds | undefined;
|
|
52
81
|
|
|
53
82
|
/**
|
|
@@ -60,8 +89,11 @@ export function thresholdsFor(ctx: ExtensionContext): ResolvedThresholds {
|
|
|
60
89
|
if (cached) return cached;
|
|
61
90
|
try {
|
|
62
91
|
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
92
|
+
// Pass the active model so per-model compaction.modelOverrides resolve (SDK 0.86);
|
|
93
|
+
// on older runtimes the extra argument is ignored and the ordinary setting wins.
|
|
94
|
+
const model = ctx.model;
|
|
63
95
|
const derived = deriveThresholds(
|
|
64
|
-
settingsManager.getCompactionSettings().reserveTokens,
|
|
96
|
+
settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined).reserveTokens,
|
|
65
97
|
mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
|
|
66
98
|
);
|
|
67
99
|
for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
|