@astrosheep/pi-context 0.24.0 → 0.25.1
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 +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -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 +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -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 +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -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 +9 -10
- 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/paths.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, renameSync } from "node:fs";
|
|
3
3
|
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" | "human" | "agent" | "model";
|
|
8
8
|
|
|
9
9
|
/** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
|
|
10
10
|
export function notesRoot(): string {
|
|
@@ -44,13 +44,70 @@ function sessionId(ctx: ExtensionContext): string {
|
|
|
44
44
|
return ctx.sessionManager.getSessionId();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
export
|
|
49
|
-
|
|
47
|
+
/** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
|
|
48
|
+
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Identity slugs: one declared name per home, never detected from prompt content.
|
|
52
|
+
* `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
|
|
53
|
+
* from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
|
|
54
|
+
*/
|
|
55
|
+
export function slugify(value: string): string {
|
|
56
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
57
|
+
return slug.length > 0 ? slug : "root";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The current agent's home name: the launch-declared identity, defaulting to "root". */
|
|
61
|
+
export function agentSlug(_ctx: ExtensionContext): string {
|
|
62
|
+
return slugify(process.env.PI_NOTES_AGENT ?? "root");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
|
|
66
|
+
export function modelSlug(ctx: ExtensionContext): string {
|
|
67
|
+
const id = ctx.model?.id;
|
|
68
|
+
if (!id) return "default";
|
|
69
|
+
return slugify(id.split("/").pop() ?? id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Absolute directory holding every note of one scope. `who` names an agent or model
|
|
74
|
+
* home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
|
|
75
|
+
* model live from ctx.model).
|
|
76
|
+
*/
|
|
77
|
+
export function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string {
|
|
78
|
+
if (scope === "human") return join(notesRoot(), "human");
|
|
50
79
|
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
80
|
+
if (scope === "agent") return join(notesRoot(), "agents", who ?? agentSlug(ctx));
|
|
81
|
+
if (scope === "model") return join(notesRoot(), "models", who ?? modelSlug(ctx));
|
|
51
82
|
return join(sessionHomesRoot(), sessionId(ctx));
|
|
52
83
|
}
|
|
53
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
|
|
87
|
+
* activation; returns a warning string when both directories exist (no auto-merge),
|
|
88
|
+
* undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
|
|
89
|
+
*/
|
|
90
|
+
export function migrateLegacyHomes(home = notesRoot()): string | undefined {
|
|
91
|
+
const legacy = join(home, "personal");
|
|
92
|
+
const modern = join(home, "human");
|
|
93
|
+
if (!existsSync(legacy)) return undefined;
|
|
94
|
+
if (existsSync(modern)) return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
|
|
95
|
+
renameSync(legacy, modern);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every existing home directory of the agents/ or models/ namespace, as slugs. */
|
|
100
|
+
export function namespaceSlugs(namespace: "agents" | "models", home = notesRoot()): string[] {
|
|
101
|
+
try {
|
|
102
|
+
return readdirSync(join(home, namespace), { withFileTypes: true })
|
|
103
|
+
.filter((entry) => entry.isDirectory())
|
|
104
|
+
.map((entry) => entry.name)
|
|
105
|
+
.sort();
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
54
111
|
/**
|
|
55
112
|
* Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
|
|
56
113
|
* `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
|
|
@@ -60,6 +117,6 @@ export function noteFileName(vpath: string): string {
|
|
|
60
117
|
}
|
|
61
118
|
|
|
62
119
|
/** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
|
|
63
|
-
export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext): string {
|
|
64
|
-
return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
|
|
120
|
+
export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext, who?: string): string {
|
|
121
|
+
return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
|
|
65
122
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SessionReader } from "../session-reader.js";
|
|
2
|
+
import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
|
|
3
|
+
import { assertVirtualPath } from "./address.js";
|
|
4
|
+
|
|
5
|
+
export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
|
|
6
|
+
export type NoteOperation = {
|
|
7
|
+
op: "write" | "append";
|
|
8
|
+
path: string;
|
|
9
|
+
// Both are optional on the wire so mark-only and explicit-revive operations replay:
|
|
10
|
+
// at least one of text/stale is present, enforced by the note tools and isNoteOperation.
|
|
11
|
+
text?: string;
|
|
12
|
+
stale?: boolean;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Replays only pi-context note operations from session custom entries. */
|
|
18
|
+
function isNoteOperation(data: unknown): data is NoteOperation {
|
|
19
|
+
if (typeof data !== "object" || data === null) return false;
|
|
20
|
+
const op = data as Partial<NoteOperation>;
|
|
21
|
+
return (
|
|
22
|
+
(op.op === "write" || op.op === "append") &&
|
|
23
|
+
typeof op.path === "string" &&
|
|
24
|
+
(op.text === undefined || typeof op.text === "string") &&
|
|
25
|
+
(op.stale === undefined || typeof op.stale === "boolean") &&
|
|
26
|
+
(op.text !== undefined || op.stale !== undefined) &&
|
|
27
|
+
typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
|
|
28
|
+
typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
|
|
33
|
+
const files = new Map<string, NoteFile>();
|
|
34
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
35
|
+
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
|
|
36
|
+
const op = entry.data;
|
|
37
|
+
try {
|
|
38
|
+
assertVirtualPath(op.path);
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const previous = files.get(op.path);
|
|
43
|
+
const hasText = op.text !== undefined;
|
|
44
|
+
// A mark-only operation needs an existing note to change; without one it is a no-op.
|
|
45
|
+
if (!hasText && !previous) continue;
|
|
46
|
+
const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text as string) : previous!.text;
|
|
47
|
+
if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) continue;
|
|
48
|
+
// Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
|
|
49
|
+
const stale = hasText ? op.stale ?? false : op.stale ?? previous!.stale;
|
|
50
|
+
files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
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 notes, `@self/<vpath>` for your own, and `@model/<vpath>` for the current model's. `@self` and `@model` mean whoever is running now. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no fallback across prefixes. Paths reject `..`, absolute paths, and backslashes.";
|
|
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 prefixes: this session, @project/, @human/, @self/, and @model/.`,
|
|
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 prefixes as notes_list. 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.
|
|
@@ -32,9 +32,8 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
|
32
32
|
* never sees — Codex's fallback buffer, relocated above the line.
|
|
33
33
|
*/
|
|
34
34
|
export const WARNING_RUNWAY_TOKENS = 12_288;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
|
|
35
|
+
/** The single reset message: the only reset prose persisted, carried by the continuation entry. */
|
|
36
|
+
export const CONTINUATION = "Your memory was just erased. Your head is blank. Good news: your notes are still here, and history remains... searchable. Do try to keep up.";
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* Static protocol teaching adapted from Codex's token_budget.guidance_message to
|
|
@@ -49,12 +48,12 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
|
|
|
49
48
|
|
|
50
49
|
Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can call wipe_memory yourself instead of waiting for the erase. Do not let a window die undocumented.
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
51
|
+
Note addresses take five prefixes: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own, as the current agent; @model/<vpath> is the current model's. @self and @model resolve to who is running now; listings always show resolved names. Nothing else is legal — any other @ prefix, or @ inside a vpath, is a hard error, with no fallback across prefixes.
|
|
55
52
|
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
53
|
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
57
|
-
@
|
|
54
|
+
@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.
|
|
55
|
+
@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.
|
|
56
|
+
@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
57
|
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
59
58
|
|
|
60
59
|
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
|
-
}
|