@astrosheep/pi-context 0.24.0 → 0.25.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 +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1861 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +97 -0
- package/dist/src/context/prompts.js +94 -0
- package/dist/src/context/reset-lifecycle.js +134 -0
- package/dist/src/context/runtime.js +236 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +7 -5
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +813 -221
- package/dist/test/boot.integration.test.js +167 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +393 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +273 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +248 -180
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +16 -14
- package/package.json +11 -10
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +103 -0
- package/src/context/prompts.ts +111 -0
- package/src/context/reset-lifecycle.ts +145 -0
- package/src/context/runtime.ts +246 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +7 -5
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/src/notes/store.ts
CHANGED
|
@@ -3,11 +3,10 @@ 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 "./model.js";
|
|
7
6
|
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
|
|
8
7
|
import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
|
|
9
|
-
import { addressFor } from "./address.js";
|
|
10
|
-
import { physicalPath, scopeDir, type Scope } from "./paths.js";
|
|
8
|
+
import { addressFor, assertGlobPattern, assertVirtualPath, globToRegExp } from "./address.js";
|
|
9
|
+
import { agentSlug, modelSlug, namespaceSlugs, physicalPath, scopeDir, type Scope } from "./paths.js";
|
|
11
10
|
import { earliestMatchOffsetChars } from "../tool-output.js";
|
|
12
11
|
|
|
13
12
|
export type { NoteMeta, Origin, Scope };
|
|
@@ -32,10 +31,10 @@ export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteM
|
|
|
32
31
|
export type NoteMatch = { line: number; text: string; offsetChars: number };
|
|
33
32
|
export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
|
|
34
33
|
|
|
35
|
-
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "
|
|
34
|
+
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "human", "agent", "model"];
|
|
36
35
|
|
|
37
36
|
function assertScope(value: unknown): Scope {
|
|
38
|
-
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
37
|
+
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
|
|
39
38
|
return value;
|
|
40
39
|
}
|
|
41
40
|
|
|
@@ -49,8 +48,12 @@ function walkMarkdown(dir: string, base = dir): string[] {
|
|
|
49
48
|
let entries: Dirent[];
|
|
50
49
|
try {
|
|
51
50
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
52
|
-
} catch {
|
|
53
|
-
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// A home that has never been created is normal. Every other directory
|
|
53
|
+
// failure must reach the boot snapshot boundary instead of masquerading as
|
|
54
|
+
// an empty home.
|
|
55
|
+
if (typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
56
|
+
throw error;
|
|
54
57
|
}
|
|
55
58
|
const paths: string[] = [];
|
|
56
59
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -99,6 +102,52 @@ function frontmatterOf(meta: NoteMeta): string {
|
|
|
99
102
|
return serializeNote(meta, "").slice(0, -2);
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/** Named agent/model homes are read-only to whoever is not running there. */
|
|
106
|
+
function assertWritableHome(scope: Scope, who: string | undefined, ctx: ExtensionContext): void {
|
|
107
|
+
if (who === undefined) return;
|
|
108
|
+
const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
|
|
109
|
+
if (who === current) return;
|
|
110
|
+
const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
|
|
111
|
+
throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which homes one call iterates. A pattern whose head is a reserved home narrows the set
|
|
116
|
+
* before any file is read; `@agents/<name>/` and `@models/<name>/` address one home, a glob
|
|
117
|
+
* in the name segment scans the whole namespace, and an unknown `@` head matches nothing.
|
|
118
|
+
* Undefined means the default merged view: session, project, human, your own agent home,
|
|
119
|
+
* and the current model home.
|
|
120
|
+
*/
|
|
121
|
+
type HomeRef = { scope: Scope; who?: string };
|
|
122
|
+
|
|
123
|
+
function homesForPattern(pattern: string | undefined): HomeRef[] | undefined {
|
|
124
|
+
if (!pattern || !pattern.startsWith("@")) return undefined;
|
|
125
|
+
const head = /^@([^/]+)\//.exec(pattern)?.[1];
|
|
126
|
+
if (head === "project") return [{ scope: "project" }];
|
|
127
|
+
if (head === "human") return [{ scope: "human" }];
|
|
128
|
+
if (head === "self") return [{ scope: "agent" }];
|
|
129
|
+
if (head === "model") return [{ scope: "model" }];
|
|
130
|
+
if (head === "agents" || head === "models") {
|
|
131
|
+
const scope: Scope = head === "agents" ? "agent" : "model";
|
|
132
|
+
const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
|
|
133
|
+
if (name.length > 0 && !/[*?]/.test(name)) return [{ scope, who: name }];
|
|
134
|
+
return namespaceSlugs(head).map((who) => ({ scope, who }));
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
|
|
140
|
+
function normalizePattern(pattern: string | undefined, ctx: ExtensionContext): string | undefined {
|
|
141
|
+
if (!pattern) return pattern;
|
|
142
|
+
if (pattern.startsWith("@self/")) return `@agents/${agentSlug(ctx)}/${pattern.slice("@self/".length)}`;
|
|
143
|
+
if (pattern.startsWith("@model/")) return `@models/${modelSlug(ctx)}/${pattern.slice("@model/".length)}`;
|
|
144
|
+
return pattern;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function homesFor(ctx: ExtensionContext, opts: { scope?: Scope; who?: string; pattern?: string }): HomeRef[] {
|
|
148
|
+
if (opts.scope !== undefined) return [{ scope: opts.scope, who: opts.who }];
|
|
149
|
+
return homesForPattern(opts.pattern) ?? SCOPE_ORDER.map((scope) => ({ scope }));
|
|
150
|
+
}
|
|
102
151
|
/** Line numbers (1-based) of every occurrence of `needle` in `body`. */
|
|
103
152
|
function matchLineNumbers(body: string, needle: string): number[] {
|
|
104
153
|
const lines: number[] = [];
|
|
@@ -112,15 +161,16 @@ function matchLineNumbers(body: string, needle: string): number[] {
|
|
|
112
161
|
return lines;
|
|
113
162
|
}
|
|
114
163
|
|
|
115
|
-
export type WriteOptions = { scope: Scope; origin: Origin; stale?: boolean };
|
|
164
|
+
export type WriteOptions = { scope: Scope; who?: string; origin: Origin; stale?: boolean };
|
|
116
165
|
|
|
117
166
|
/** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
|
|
118
167
|
export function writeNote(ctx: ExtensionContext, vpath: string, body: string, opts: WriteOptions): { meta: NoteMeta } {
|
|
119
168
|
assertVirtualPath(vpath);
|
|
120
169
|
assertWritablePath(vpath);
|
|
121
170
|
const scope = assertScope(opts.scope);
|
|
171
|
+
assertWritableHome(scope, opts.who, ctx);
|
|
122
172
|
const origin = assertOrigin(opts.origin);
|
|
123
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
173
|
+
const path = physicalPath(scope, vpath, ctx, opts.who);
|
|
124
174
|
const now = Date.now();
|
|
125
175
|
const cleanBody = stripLeadingFrontmatter(body);
|
|
126
176
|
const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
|
|
@@ -149,9 +199,9 @@ export type EditOperation = { oldText: string; newText: string };
|
|
|
149
199
|
export type EditOptions = { origin?: Origin; stale?: boolean; replaceAll?: boolean };
|
|
150
200
|
|
|
151
201
|
/** Dream harness mutation: metadata changes still use the store's atomic writer. */
|
|
152
|
-
export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void): { meta: NoteMeta; body: string } {
|
|
202
|
+
export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void, who?: string): { meta: NoteMeta; body: string } {
|
|
153
203
|
assertVirtualPath(vpath);
|
|
154
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
204
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
155
205
|
if (!existsSync(path)) throw new NoteError("not_found", `note not found: ${vpath}`);
|
|
156
206
|
const parsed = parseNote(readFileSync(path, "utf8"));
|
|
157
207
|
const meta = { ...parsed.meta, scope };
|
|
@@ -164,14 +214,15 @@ export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scop
|
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
/** 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 } {
|
|
217
|
+
export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edits: EditOperation[] | undefined, opts: EditOptions = {}, who?: string): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
|
|
168
218
|
assertVirtualPath(vpath);
|
|
169
219
|
assertWritablePath(vpath);
|
|
220
|
+
assertWritableHome(scope, who, ctx);
|
|
170
221
|
const operations = edits ?? [];
|
|
171
222
|
if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
|
|
172
223
|
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
|
|
173
224
|
}
|
|
174
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
225
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
175
226
|
if (!existsSync(path)) throw new NoteError("not_found", "note not found");
|
|
176
227
|
const raw = readFileSync(path, "utf8");
|
|
177
228
|
const { meta, body } = parseNote(raw);
|
|
@@ -229,9 +280,9 @@ function accessedMeta(meta: NoteMeta, scope: Scope, now: number): NoteMeta {
|
|
|
229
280
|
}
|
|
230
281
|
|
|
231
282
|
/** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
|
|
232
|
-
export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; text: string; resolvedScope: Scope } | undefined {
|
|
283
|
+
export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope, who?: string): { meta: NoteMeta; body: string; text: string; resolvedScope: Scope } | undefined {
|
|
233
284
|
assertVirtualPath(vpath);
|
|
234
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
285
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
235
286
|
if (!existsSync(path)) return undefined;
|
|
236
287
|
const now = Date.now();
|
|
237
288
|
const parsed = parseNote(readFileSync(path, "utf8"), now);
|
|
@@ -242,13 +293,14 @@ export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): {
|
|
|
242
293
|
}
|
|
243
294
|
|
|
244
295
|
/** Merged rows across homes, most recently updated first (address breaks ties). */
|
|
245
|
-
export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
|
|
246
|
-
const matcher = matcherFor(opts.pattern);
|
|
296
|
+
export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; who?: string; pattern?: string } = {}): NoteRow[] {
|
|
297
|
+
const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
|
|
247
298
|
const rows: NoteRow[] = [];
|
|
248
|
-
for (const
|
|
249
|
-
const
|
|
299
|
+
for (const home of homesFor(ctx, opts)) {
|
|
300
|
+
const scope = home.scope;
|
|
301
|
+
const root = scopeDir(scope, ctx, home.who);
|
|
250
302
|
for (const path of walkMarkdown(root)) {
|
|
251
|
-
const address = addressFor(scope, path);
|
|
303
|
+
const address = addressFor(ctx, scope, path, home.who);
|
|
252
304
|
if (matcher && !matcher.test(address)) continue;
|
|
253
305
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
254
306
|
meta.scope = scope;
|
|
@@ -260,13 +312,14 @@ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?
|
|
|
260
312
|
}
|
|
261
313
|
|
|
262
314
|
/** Case-sensitive literal substring search over note bodies, with a match address per line. */
|
|
263
|
-
export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
|
|
264
|
-
const matcher = matcherFor(opts.pattern);
|
|
315
|
+
export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; who?: string; pattern?: string } = {}): NoteSearchRow[] {
|
|
316
|
+
const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
|
|
265
317
|
const rows: NoteSearchRow[] = [];
|
|
266
|
-
for (const
|
|
267
|
-
const
|
|
318
|
+
for (const home of homesFor(ctx, opts)) {
|
|
319
|
+
const scope = home.scope;
|
|
320
|
+
const root = scopeDir(scope, ctx, home.who);
|
|
268
321
|
for (const path of walkMarkdown(root)) {
|
|
269
|
-
const address = addressFor(scope, path);
|
|
322
|
+
const address = addressFor(ctx, scope, path, home.who);
|
|
270
323
|
if (matcher && !matcher.test(address)) continue;
|
|
271
324
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
272
325
|
meta.scope = scope;
|
package/src/notes/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { localIso } from "./
|
|
3
|
+
import { localIso } from "./frontmatter.js";
|
|
4
4
|
import { DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, readWindowBlock, withinTextBudget } from "../tool-output.js";
|
|
5
5
|
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
6
|
import { assertAddress } from "./address.js";
|
|
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
10
10
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
11
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
12
|
}));
|
|
13
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project
|
|
13
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project home, `@self/<vpath>` / `@agents/<name>/<vpath>` for agent homes, and `@model/<vpath>` / `@models/<name>/<vpath>` for model homes. `@self` and `@model` mean the current agent/model; the `<name>` forms name one absolutely. The word after `@` is always one of the reserved home names — names live at the second level, never `@faye/`. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes. Homes you do not own (`@agents/<other>/`, `@models/<other>/`) are read-only.";
|
|
14
14
|
|
|
15
15
|
function failure(error: unknown) {
|
|
16
16
|
if (error instanceof NoteError) {
|
|
@@ -31,7 +31,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
31
31
|
const content = params.content;
|
|
32
32
|
try {
|
|
33
33
|
const destination = assertAddress(params.address);
|
|
34
|
-
writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
34
|
+
writeNote(ctx, destination.path, content, { scope: destination.scope, who: destination.who, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
35
35
|
return output({ address: params.address, written: true });
|
|
36
36
|
} catch (error) { return failure(error); }
|
|
37
37
|
},
|
|
@@ -44,7 +44,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
44
44
|
async execute(_id, params, _signal, _update, ctx) {
|
|
45
45
|
try {
|
|
46
46
|
const destination = assertAddress(params.address);
|
|
47
|
-
const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
|
|
47
|
+
const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all }, destination.who);
|
|
48
48
|
return output({ address: params.address, applied, diff });
|
|
49
49
|
} catch (error) { return failure(error); }
|
|
50
50
|
},
|
|
@@ -58,7 +58,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
58
58
|
let note: ReturnType<typeof readNote>;
|
|
59
59
|
try {
|
|
60
60
|
const destination = assertAddress(params.address);
|
|
61
|
-
note = readNote(ctx, destination.path, destination.scope);
|
|
61
|
+
note = readNote(ctx, destination.path, destination.scope, destination.who);
|
|
62
62
|
} catch (error) { return failure(error); }
|
|
63
63
|
if (!note) return output({ error: "note not found", address: params.address });
|
|
64
64
|
const text = note.text;
|
|
@@ -73,7 +73,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
73
73
|
|
|
74
74
|
pi.registerTool(defineTool({
|
|
75
75
|
name: "notes_list", label: "Notes list",
|
|
76
|
-
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION}
|
|
76
|
+
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five reachable homes: this session, @project/, @human/, your @self home, and the current @model home; other agents and models appear only under an explicit glob (@agents/<name>/**, @models/<name>/**, or a glob in the name segment to scan a whole namespace).`,
|
|
77
77
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
78
78
|
async execute(_id, params, _signal, _update, ctx) {
|
|
79
79
|
let rows: ReturnType<typeof listNotes>;
|
|
@@ -89,7 +89,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
89
89
|
|
|
90
90
|
pi.registerTool(defineTool({
|
|
91
91
|
name: "notes_search", label: "Notes search",
|
|
92
|
-
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION}
|
|
92
|
+
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five reachable homes as notes_list; explicit globs reach other agents and models. 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 (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
|
|
93
93
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
94
94
|
async execute(_id, params, _signal, _update, ctx) {
|
|
95
95
|
const queries = searchQueries(params.query);
|
package/src/protocol.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
export const STATE_TYPE = "pi-context/state";
|
|
2
1
|
export const NOTE_TYPE = "pi-context/note";
|
|
3
2
|
export const BOOT_TYPE = "pi-context/boot";
|
|
4
3
|
export const GUIDANCE_TYPE = "pi-context/guidance";
|
|
5
4
|
export const WARNING_TYPE = "pi-context/warning";
|
|
6
5
|
export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
7
6
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
8
|
-
export const RESET_V2 = "reset-v2";
|
|
9
7
|
export const MAX_NOTE_BYTES = 1_000_000;
|
|
10
8
|
export const POCKET_SESSION_LIMIT = 5;
|
|
11
9
|
export const POCKET_PROJECT_LIMIT = 2;
|
|
12
|
-
export const
|
|
10
|
+
export const POCKET_HUMAN_LIMIT = 2;
|
|
11
|
+
export const POCKET_AGENT_LIMIT = 1;
|
|
12
|
+
export const POCKET_MODEL_LIMIT = 1;
|
|
13
13
|
// Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
|
|
14
14
|
// notesFromSession replays already-persisted operations, which must keep loading sessions
|
|
15
15
|
// that contain a longer legacy path. Reads and replay stay un-capped.
|
|
@@ -51,10 +51,12 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
|
|
|
51
51
|
|
|
52
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.
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
Notes live in five homes, and the word after @ is always one of their reserved names — your own name and other people's names live at the second level (@agents/faye/, never @faye/). Bare names are this session; @project/<vpath> is this project's workspace; @human/<vpath> is the human's cross-project home; @self/<vpath> and @agents/<name>/<vpath> are agent homes; @model/<vpath> and @models/<name>/<vpath> are model homes. @self and @model are the only relative forms — the current agent, the current model — and listings never show them, only the resolved name. There is no cross-home fallback.
|
|
55
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
56
|
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
57
|
-
@
|
|
57
|
+
@human notes hold the human's durable preferences and standing rules, plus lessons that apply across projects — for every agent that serves this human, whoever is running. You write there as the human's scribe; what the human dictates carries origin: user. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
|
|
58
|
+
@self notes are yours — your voice, your lessons, your gripes — for the next run of whoever you are. Other agents read yours by explicit address and never write them; you read theirs the same way. A note only its author would ever need belongs here, not in @human.
|
|
59
|
+
@model notes capture the substrate — how the current model actually behaves: context honesty, tool quirks, fallback patterns. @model resolves live, so what you learn on one model is filed under that model even when a fallback moves you mid-window.
|
|
58
60
|
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
59
61
|
|
|
60
62
|
export const WARNING_PROMPT =
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PI_CONTEXT_SETTINGS_KEY } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
export type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
|
|
4
|
+
|
|
5
|
+
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
10
|
+
function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
11
|
+
if (!isSettingsObject(settings)) return {};
|
|
12
|
+
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
13
|
+
return isSettingsObject(value) ? value : {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
17
|
+
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
|
|
18
|
+
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
19
|
+
return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
|
|
20
|
+
}
|
package/src/tool-schema.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }))
|
|
|
4
4
|
export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
|
|
5
5
|
export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
|
|
6
6
|
/** Role filter. `developer` is the known author for this extension's own custom entries. */
|
|
7
|
-
export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages,
|
|
7
|
+
export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction or branch summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, or any pi-context/* custom message)." });
|
|
8
8
|
|
|
9
9
|
/** Search query parameter: one literal, or several literals combined with OR. */
|
|
10
10
|
export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
|
|
@@ -23,4 +23,3 @@ export function searchQueries(query: unknown): string[] {
|
|
|
23
23
|
if (candidates.some((candidate) => candidate === "")) throw new Error("query strings must be non-empty: an empty query matches everything");
|
|
24
24
|
return candidates as string[];
|
|
25
25
|
}
|
|
26
|
-
|
package/dist/src/budget.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
|
|
4
|
-
import { thresholdsFor, resetThresholds } from "./thresholds.js";
|
|
5
|
-
import { currentWindowId, hasWindowMessage } from "./history.js";
|
|
6
|
-
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
|
-
import { output } from "./tool-output.js";
|
|
8
|
-
/** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
|
|
9
|
-
export function remainingTokens(ctx) {
|
|
10
|
-
const usage = ctx.getContextUsage();
|
|
11
|
-
return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
12
|
-
}
|
|
13
|
-
export function registerBudget(pi, isEnabled) {
|
|
14
|
-
let guidancePersistedInWindow;
|
|
15
|
-
pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
|
|
16
|
-
pi.on("session_tree", () => { guidancePersistedInWindow = undefined; resetThresholds(); });
|
|
17
|
-
pi.on("context", (_event, ctx) => {
|
|
18
|
-
if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE))
|
|
19
|
-
return undefined;
|
|
20
|
-
// The early reminder persists once per window the first time remaining crosses
|
|
21
|
-
// reserve+margin. It never edits the outgoing request.
|
|
22
|
-
const remaining = remainingTokens(ctx);
|
|
23
|
-
if (remaining === null)
|
|
24
|
-
return undefined;
|
|
25
|
-
const windowId = currentWindowId(ctx);
|
|
26
|
-
const { reminder, reserve, warning } = thresholdsFor(ctx);
|
|
27
|
-
// The final warning owns the deep band: when it has fired (or is due now),
|
|
28
|
-
// the shallow reminder would only repeat the same instruction closer to
|
|
29
|
-
// the wipe, at a worse position. See warning.ts.
|
|
30
|
-
if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE))
|
|
31
|
-
return undefined;
|
|
32
|
-
if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
|
|
33
|
-
guidancePersistedInWindow = windowId;
|
|
34
|
-
// Persist once per window — no transient copy. A transient bridge would
|
|
35
|
-
// cover the crossing request, but history would record the reminder after
|
|
36
|
-
// that request's assistant reply, so across the boundary the model would
|
|
37
|
-
// meet the same text twice at shifted positions. The reminder is an early
|
|
38
|
-
// warning, not a per-request instruction: arriving from the next request
|
|
39
|
-
// on (sendMessage defers safely to end of turn while streaming, queueing
|
|
40
|
-
// instead of splitting a tool call/result pair) costs nothing, and the
|
|
41
|
-
// model's view stays identical to recorded history, Codex-style.
|
|
42
|
-
// The persisted copy stays out of the TUI (display: false); one ephemeral
|
|
43
|
-
// notify tells the user instead — visible to the human, invisible to the
|
|
44
|
-
// model, and never recorded, so history and the model's view don't diverge.
|
|
45
|
-
// The model-facing count ends at the warning line: what lies below is the
|
|
46
|
-
// runway, invisible by design. The human's notify keeps the honest count.
|
|
47
|
-
const left = Math.max(0, remaining - warning);
|
|
48
|
-
pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
|
|
49
|
-
ctx.ui.notify(`pi-context: context budget low (${Math.max(0, remaining - reserve)} tokens before reserve) — checkpoint reminder recorded for the model, kept out of the chat view.`, "warning");
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
52
|
-
});
|
|
53
|
-
pi.registerTool(defineTool({
|
|
54
|
-
name: "get_context_remaining",
|
|
55
|
-
label: "Get context remaining",
|
|
56
|
-
description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
|
|
57
|
-
parameters: Type.Object({}, { additionalProperties: false }),
|
|
58
|
-
async execute(_id, _params, _signal, _update, ctx) {
|
|
59
|
-
// The countdown the model sees ends at the warning line (reserve + runway);
|
|
60
|
-
// the runway below it is overdraft the model never sees. See protocol.ts.
|
|
61
|
-
const remaining = remainingTokens(ctx);
|
|
62
|
-
return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx).warning) });
|
|
63
|
-
},
|
|
64
|
-
}));
|
|
65
|
-
}
|
package/dist/src/notes/model.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
|
|
2
|
-
export function assertVirtualPath(value) {
|
|
3
|
-
if (typeof value !== "string" || value.length === 0)
|
|
4
|
-
throw new Error("path must be a non-empty virtual relative path");
|
|
5
|
-
if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
|
|
6
|
-
throw new Error("path must be a safe virtual relative path");
|
|
7
|
-
const parts = value.split("/");
|
|
8
|
-
if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
|
|
9
|
-
throw new Error("path contains an unsupported component");
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Minimal glob over virtual note paths: `*` matches any run within a segment (never
|
|
14
|
-
* `/`), `**` matches any run across segments (a leading double-star followed by a
|
|
15
|
-
* slash also matches zero segments, so it covers the root too), `?` matches exactly
|
|
16
|
-
* one non-`/` character. Everything else is literal and the match is anchored to the
|
|
17
|
-
* whole path.
|
|
18
|
-
*/
|
|
19
|
-
export function globToRegExp(pattern) {
|
|
20
|
-
let source = "^";
|
|
21
|
-
for (let index = 0; index < pattern.length; index++) {
|
|
22
|
-
const char = pattern[index];
|
|
23
|
-
if (char === "*") {
|
|
24
|
-
if (pattern[index + 1] === "*") {
|
|
25
|
-
const followedBySlash = pattern[index + 2] === "/";
|
|
26
|
-
source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
|
|
27
|
-
index += followedBySlash ? 2 : 1;
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
source += "[^/]*";
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return new RegExp(`${source}$`);
|
|
38
|
-
}
|
|
39
|
-
/** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
|
|
40
|
-
export function assertGlobPattern(value) {
|
|
41
|
-
if (value === undefined || value === null || value === "")
|
|
42
|
-
return undefined;
|
|
43
|
-
if (typeof value !== "string")
|
|
44
|
-
throw new Error("glob pattern must be a string");
|
|
45
|
-
if (value.includes("\0") || value.includes("\\"))
|
|
46
|
-
throw new Error("glob pattern must not contain NUL or backslashes");
|
|
47
|
-
return value;
|
|
48
|
-
}
|
|
49
|
-
/** Replays only pi-context note operations from session custom entries. */
|
|
50
|
-
function isNoteOperation(data) {
|
|
51
|
-
if (typeof data !== "object" || data === null)
|
|
52
|
-
return false;
|
|
53
|
-
const op = data;
|
|
54
|
-
return ((op.op === "write" || op.op === "append") &&
|
|
55
|
-
typeof op.path === "string" &&
|
|
56
|
-
(op.text === undefined || typeof op.text === "string") &&
|
|
57
|
-
(op.stale === undefined || typeof op.stale === "boolean") &&
|
|
58
|
-
(op.text !== undefined || op.stale !== undefined) &&
|
|
59
|
-
typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
|
|
60
|
-
typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime()));
|
|
61
|
-
}
|
|
62
|
-
export function notesFromSession(ctx) {
|
|
63
|
-
const files = new Map();
|
|
64
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
65
|
-
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data))
|
|
66
|
-
continue;
|
|
67
|
-
const op = entry.data;
|
|
68
|
-
try {
|
|
69
|
-
assertVirtualPath(op.path);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
const previous = files.get(op.path);
|
|
75
|
-
const hasText = op.text !== undefined;
|
|
76
|
-
// A mark-only operation needs an existing note to change; without one it is a no-op.
|
|
77
|
-
if (!hasText && !previous)
|
|
78
|
-
continue;
|
|
79
|
-
const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text) : previous.text;
|
|
80
|
-
if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES)
|
|
81
|
-
continue;
|
|
82
|
-
// Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
|
|
83
|
-
const stale = hasText ? op.stale ?? false : op.stale ?? previous.stale;
|
|
84
|
-
files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
|
|
85
|
-
}
|
|
86
|
-
return files;
|
|
87
|
-
}
|
|
88
|
-
const pad2 = (value) => String(value).padStart(2, "0");
|
|
89
|
-
/**
|
|
90
|
-
* Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
|
|
91
|
-
* explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
|
|
92
|
-
* "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
|
|
93
|
-
*/
|
|
94
|
-
export function localIso(epochMs) {
|
|
95
|
-
const date = new Date(epochMs);
|
|
96
|
-
const offsetMinutes = -date.getTimezoneOffset();
|
|
97
|
-
const absOffset = Math.abs(offsetMinutes);
|
|
98
|
-
const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
|
|
99
|
-
const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
|
|
100
|
-
return `${wallClock}${offset}`;
|
|
101
|
-
}
|
package/dist/src/prompts.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { historyFromSession } from "./history.js";
|
|
2
|
-
import { listNotes } from "./notes/store.js";
|
|
3
|
-
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";
|
|
4
|
-
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
5
|
-
function identityBlock(agentName, firstWindowId, currentWindowId, previousWindowId) {
|
|
6
|
-
const lines = [
|
|
7
|
-
`Agent name: ${agentName}`,
|
|
8
|
-
`First context window id: ${firstWindowId}`,
|
|
9
|
-
`Current context window id: ${currentWindowId}`,
|
|
10
|
-
];
|
|
11
|
-
if (previousWindowId)
|
|
12
|
-
lines.push(`Previous context window id: ${previousWindowId}`);
|
|
13
|
-
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
14
|
-
}
|
|
15
|
-
function relativeTime(timestamp, now) {
|
|
16
|
-
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
17
|
-
const [unit, size] = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]]
|
|
18
|
-
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s");
|
|
19
|
-
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
20
|
-
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
24
|
-
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
25
|
-
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
26
|
-
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
27
|
-
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
28
|
-
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
29
|
-
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
30
|
-
*/
|
|
31
|
-
function notesIndex(ctx) {
|
|
32
|
-
const sections = [];
|
|
33
|
-
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
34
|
-
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
35
|
-
for (const scope of ["personal", "project"]) {
|
|
36
|
-
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
37
|
-
if (toc && !toc.meta.stale) {
|
|
38
|
-
if (toc.body.length > 0)
|
|
39
|
-
sections.push(toc.body);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
43
|
-
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
44
|
-
const recentNotes = [
|
|
45
|
-
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
46
|
-
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
47
|
-
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
48
|
-
];
|
|
49
|
-
if (recentNotes.length > 0) {
|
|
50
|
-
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:`];
|
|
51
|
-
const now = Date.now();
|
|
52
|
-
for (const row of recentNotes) {
|
|
53
|
-
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
54
|
-
}
|
|
55
|
-
sections.push(lines.join("\n"));
|
|
56
|
-
}
|
|
57
|
-
return sections.join("\n\n");
|
|
58
|
-
}
|
|
59
|
-
function notesHomeBlock() {
|
|
60
|
-
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.";
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
|
64
|
-
* <context_window> identity block, the recent-notes index at window-open time, and
|
|
65
|
-
* the <context_window_protocol> teaching block. Nothing here is re-injected, so the
|
|
66
|
-
* head of the window stays cache-stable.
|
|
67
|
-
*/
|
|
68
|
-
export function bootBlock(ctx, currentId, previousId, resetLine) {
|
|
69
|
-
const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
|
|
70
|
-
const parts = [];
|
|
71
|
-
if (resetLine)
|
|
72
|
-
parts.push(RESET_SUMMARY);
|
|
73
|
-
parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
|
|
74
|
-
parts.push(notesHomeBlock());
|
|
75
|
-
const index = notesIndex(ctx);
|
|
76
|
-
if (index)
|
|
77
|
-
parts.push(index);
|
|
78
|
-
parts.push(PROTOCOL_BLOCK);
|
|
79
|
-
return parts.join("\n\n");
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
83
|
-
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
84
|
-
* at write time; get_context_remaining remains the live source for the current figure.
|
|
85
|
-
*/
|
|
86
|
-
export function tokenBudgetGuidance(remaining) {
|
|
87
|
-
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 call wipe_memory yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
88
|
-
}
|