@astrosheep/pi-context 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/src/budget.js +63 -0
  2. package/dist/src/dream/apply.js +87 -0
  3. package/dist/src/dream/cli.js +82 -0
  4. package/dist/src/dream/gates.js +21 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/manifest.js +16 -0
  7. package/dist/src/dream/runner.js +56 -0
  8. package/dist/src/history-tools.js +105 -0
  9. package/dist/src/history.js +210 -0
  10. package/dist/src/index.js +99 -0
  11. package/dist/src/memory/frontmatter.js +134 -0
  12. package/dist/src/memory/paths.js +54 -0
  13. package/dist/src/memory/store.js +297 -0
  14. package/dist/src/memory/tools.js +175 -0
  15. package/dist/src/notes.js +101 -0
  16. package/dist/src/prompts.js +79 -0
  17. package/dist/src/protocol.js +52 -0
  18. package/dist/src/reset-lifecycle.js +101 -0
  19. package/dist/src/session-reader.js +1 -0
  20. package/dist/src/thresholds.js +72 -0
  21. package/dist/src/tool-output.js +172 -0
  22. package/dist/src/tool-schema.js +26 -0
  23. package/dist/src/warning.js +44 -0
  24. package/dist/test/agent-loop.test.js +212 -0
  25. package/dist/test/coherence.test.js +371 -0
  26. package/dist/test/dream.test.js +43 -0
  27. package/dist/test/history.test.js +21 -0
  28. package/dist/test/integration.test.js +1716 -0
  29. package/dist/test/memory.test.js +370 -0
  30. package/dist/test/pagination.property.test.js +476 -0
  31. package/dist/test/reset-lifecycle.test.js +199 -0
  32. package/package.json +9 -3
  33. package/playbook.md +5 -0
  34. package/src/dream/apply.ts +47 -0
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +19 -0
  37. package/src/dream/lock.ts +39 -0
  38. package/src/dream/manifest.ts +21 -0
  39. package/src/dream/runner.ts +53 -0
  40. package/src/memory/store.ts +15 -0
  41. package/src/memory/tools.ts +12 -3
  42. package/src/protocol.ts +2 -2
@@ -0,0 +1,63 @@
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
+ export { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
9
+ export function registerBudget(pi, isEnabled) {
10
+ let guidancePersistedInWindow;
11
+ pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
12
+ pi.on("session_tree", () => { guidancePersistedInWindow = undefined; resetThresholds(); });
13
+ pi.on("context", (_event, ctx) => {
14
+ if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE))
15
+ return undefined;
16
+ // The early reminder persists once per window the first time remaining crosses
17
+ // reserve+margin. It never edits the outgoing request.
18
+ const usage = ctx.getContextUsage();
19
+ if (!usage || usage.tokens === null)
20
+ return undefined;
21
+ const remaining = Math.max(0, usage.contextWindow - usage.tokens);
22
+ const windowId = currentWindowId(ctx);
23
+ const { reminder, reserve, warning } = thresholdsFor(ctx);
24
+ // The final warning owns the deep band: when it has fired (or is due now),
25
+ // the shallow reminder would only repeat the same instruction closer to
26
+ // the wipe, at a worse position. See warning.ts.
27
+ if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE))
28
+ return undefined;
29
+ if (remaining <= reminder && guidancePersistedInWindow !== windowId && !hasWindowMessage(ctx, GUIDANCE_TYPE)) {
30
+ guidancePersistedInWindow = windowId;
31
+ // Persist once per window — no transient copy. A transient bridge would
32
+ // cover the crossing request, but history would record the reminder after
33
+ // that request's assistant reply, so across the boundary the model would
34
+ // meet the same text twice at shifted positions. The reminder is an early
35
+ // warning, not a per-request instruction: arriving from the next request
36
+ // on (sendMessage defers safely to end of turn while streaming, queueing
37
+ // instead of splitting a tool call/result pair) costs nothing, and the
38
+ // model's view stays identical to recorded history, Codex-style.
39
+ // The persisted copy stays out of the TUI (display: false); one ephemeral
40
+ // notify tells the user instead — visible to the human, invisible to the
41
+ // model, and never recorded, so history and the model's view don't diverge.
42
+ // The model-facing count ends at the warning line: what lies below is the
43
+ // runway, invisible by design. The human's notify keeps the honest count.
44
+ const left = Math.max(0, remaining - warning);
45
+ pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
46
+ 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");
47
+ }
48
+ return undefined;
49
+ });
50
+ pi.registerTool(defineTool({
51
+ name: "get_context_remaining",
52
+ label: "Get context remaining",
53
+ description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
54
+ parameters: Type.Object({}, { additionalProperties: false }),
55
+ async execute(_id, _params, _signal, _update, ctx) {
56
+ const usage = ctx.getContextUsage();
57
+ // The countdown the model sees ends at the warning line (reserve + runway);
58
+ // the runway below it is overdraft the model never sees. See protocol.ts.
59
+ const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - thresholdsFor(ctx).warning);
60
+ return output({ remaining_tokens: remaining });
61
+ },
62
+ }));
63
+ }
@@ -0,0 +1,87 @@
1
+ import { mkdirSync, renameSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { editNote, peekNote, resolveNoteScope, updateNoteMeta, writeNote } from "../memory/store.js";
4
+ import { physicalPath } from "../memory/paths.js";
5
+ function target(ctx, value, required = true) {
6
+ const m = /^(session|project|global):(.*)$/.exec(value);
7
+ if (m) {
8
+ const scope = m[1];
9
+ const path = m[2];
10
+ const physical = physicalPath(scope, path, ctx);
11
+ if (!existsSync(physical)) {
12
+ if (required)
13
+ throw new Error(`unknown path: ${value}`);
14
+ return undefined;
15
+ }
16
+ return { scope, path };
17
+ }
18
+ const found = resolveNoteScope(ctx, value);
19
+ if (!found && required)
20
+ throw new Error(`unknown path: ${value}`);
21
+ return found ? { scope: found.scope, path: value } : undefined;
22
+ }
23
+ function body(ctx, t) { return peekNote(ctx, t.scope, t.path); }
24
+ export function applyManifest(ctx, home, stamp, manifest) {
25
+ const actions = [];
26
+ // Resolve every referenced note and promotion destination before the first mutation.
27
+ for (const m of manifest.merge ?? []) {
28
+ target(ctx, m.into);
29
+ for (const p of m.from)
30
+ target(ctx, p);
31
+ }
32
+ for (const p of manifest.promote ?? []) {
33
+ const from = target(ctx, p.path);
34
+ if (p.to !== "global") {
35
+ if (!["session", "project"].includes(p.to))
36
+ throw new Error(`invalid promotion scope: ${p.to}`);
37
+ if (existsSync(physicalPath(p.to, p.path, ctx)))
38
+ throw new Error(`promotion collision: ${p.path}`);
39
+ }
40
+ void from;
41
+ }
42
+ for (const p of manifest.trash ?? [])
43
+ target(ctx, p.path);
44
+ for (const merge of manifest.merge ?? []) {
45
+ const into = target(ctx, merge.into);
46
+ const sources = merge.from.map((p) => target(ctx, p));
47
+ const base = body(ctx, into);
48
+ const chunks = [base.body, ...sources.map((s) => body(ctx, s).body)].filter(Boolean);
49
+ const dedup = [...new Set(chunks)].join("\n\n");
50
+ writeNote(ctx, into.path, dedup, { scope: into.scope, origin: base.meta.origin });
51
+ updateNoteMeta(ctx, into.path, into.scope, (meta) => { meta.recurrence_count = (meta.recurrence_count ?? 0) + sources.length; meta.recurrence_windows = [...new Set([...(meta.recurrence_windows ?? []), ...sources.map((s) => body(ctx, s).meta.source_window).filter((x) => typeof x === "string")])]; });
52
+ for (const source of sources)
53
+ updateNoteMeta(ctx, source.path, source.scope, (meta) => { meta.status = "superseded"; meta.supersedes = merge.into; });
54
+ actions.push(`merged ${merge.from.join(", ")} into ${merge.into}`);
55
+ }
56
+ for (const p of manifest.promote ?? []) {
57
+ const from = target(ctx, p.path);
58
+ const m = body(ctx, from);
59
+ const scope = p.to;
60
+ if (!["session", "project", "global"].includes(scope))
61
+ throw new Error(`invalid promotion scope: ${p.to}`);
62
+ if (scope === "global") {
63
+ actions.push(`proposal: promote ${p.path} to global (${p.reason})`);
64
+ continue;
65
+ }
66
+ const dest = physicalPath(scope, p.path, ctx);
67
+ if (existsSync(dest))
68
+ throw new Error(`promotion collision: ${p.path}`);
69
+ editNote(ctx, from.path, undefined, { scope });
70
+ actions.push(`promoted ${p.path} to ${scope}`);
71
+ }
72
+ const trashRoot = join(home, "trash", stamp);
73
+ mkdirSync(trashRoot, { recursive: true });
74
+ for (const item of manifest.trash ?? []) {
75
+ const t = target(ctx, item.path);
76
+ const source = physicalPath(t.scope, t.path, ctx);
77
+ const dest = join(trashRoot, t.scope, t.path.endsWith(".md") ? t.path : `${t.path}.md`);
78
+ mkdirSync(dirname(dest), { recursive: true });
79
+ renameSync(source, dest);
80
+ actions.push(`trashed ${item.path}: ${item.reason}`);
81
+ }
82
+ for (const p of manifest.pending ?? [])
83
+ actions.push(`pending ${p.path}: ${p.reason}`);
84
+ for (const p of manifest.skillCandidates ?? [])
85
+ actions.push(`skill proposal ${p.title}: ${p.rationale}`);
86
+ return actions;
87
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { acquireLock, failLock, releaseLock } from "./lock.js";
6
+ import { materialGate, timeGate } from "./gates.js";
7
+ import { loadPlaybook, runDreamer } from "./runner.js";
8
+ import { applyManifest } from "./apply.js";
9
+ function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
10
+ const a = argv[i];
11
+ if (a === "--force" || a === "--help")
12
+ out[a.slice(2)] = true;
13
+ else if (a.startsWith("--"))
14
+ out[a.slice(2)] = argv[++i] ?? "";
15
+ } return out; }
16
+ function packageRoot() {
17
+ let dir = dirname(new URL(import.meta.url).pathname);
18
+ while (true) {
19
+ if (existsSync(join(dir, "package.json")))
20
+ return dir;
21
+ const parent = dirname(dir);
22
+ if (parent === dir)
23
+ throw new Error("could not locate installed package root");
24
+ dir = parent;
25
+ }
26
+ }
27
+ export async function main(argv = process.argv.slice(2)) {
28
+ const a = args(argv);
29
+ if (a.help) {
30
+ console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <cmd>] [--dreamer-model <pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with read-only tools; use a cheap model in production. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
31
+ return 0;
32
+ }
33
+ const home = resolve(String(a["notes-home"] ?? process.env.PI_NOTES_HOME ?? join(homedir(), ".agents", "notes")));
34
+ process.env.PI_NOTES_HOME = home;
35
+ mkdirSync(home, { recursive: true });
36
+ const lockPath = join(home, ".dream.lock");
37
+ const minHours = Number(a["min-hours"] ?? 24);
38
+ const minSessions = Number(a["min-sessions"] ?? 3);
39
+ const time = timeGate(lockPath, minHours);
40
+ console.log(time.reason);
41
+ if (!a.force && !time.ok)
42
+ return 0;
43
+ const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions);
44
+ console.log(material.reason);
45
+ if (!a.force && !material.ok)
46
+ return 0;
47
+ let lock;
48
+ try {
49
+ lock = acquireLock(lockPath);
50
+ }
51
+ catch (e) {
52
+ console.log(`lock gate: ${e instanceof Error ? e.message : e}`);
53
+ return 0;
54
+ }
55
+ if (!lock.held) {
56
+ console.log(lock.reason);
57
+ return 0;
58
+ }
59
+ const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-");
60
+ const reportPath = join(home, "dreams", `${stamp}.md`);
61
+ try {
62
+ const defaultBook = join(packageRoot(), "playbook.md");
63
+ const playbookPath = String(a.playbook ?? defaultBook);
64
+ const playbook = loadPlaybook(playbookPath);
65
+ const manifest = await runDreamer(playbook, home, { command: a.dreamer ? String(a.dreamer) : undefined, modelPattern: a["dreamer-model"] ? String(a["dreamer-model"]) : undefined });
66
+ const ctx = { cwd: home, sessionManager: { getSessionId: () => "dream" } };
67
+ const actions = applyManifest(ctx, home, stamp, manifest);
68
+ mkdirSync(join(home, "dreams"), { recursive: true });
69
+ writeFileSync(reportPath, `# Dream ${stamp}\n\n${manifest.report}\n\n${actions.map((x) => `- ${x}`).join("\n")}\n`);
70
+ console.log(reportPath);
71
+ return 0;
72
+ }
73
+ catch (e) {
74
+ failLock(lock);
75
+ console.error(e instanceof Error ? e.message : e);
76
+ return 1;
77
+ }
78
+ finally {
79
+ releaseLock(lock);
80
+ }
81
+ }
82
+ main().then((code) => { process.exitCode = code; });
@@ -0,0 +1,21 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function timeGate(lockPath, minHours, now = Date.now()) {
4
+ if (!existsSync(lockPath))
5
+ return { ok: true, reason: "time gate: no prior lock" };
6
+ const age = now - statSync(lockPath).mtimeMs;
7
+ return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
8
+ }
9
+ export function materialGate(home, lockMtime, minSessions) {
10
+ const root = join(home, "pi", "session");
11
+ let changed = 0;
12
+ if (existsSync(root))
13
+ for (const dir of readdirSync(root, { withFileTypes: true })) {
14
+ if (!dir.isDirectory())
15
+ continue;
16
+ const files = readdirSync(join(root, dir.name), { withFileTypes: true });
17
+ if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > lockMtime))
18
+ changed++;
19
+ }
20
+ return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
21
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ const HOUR = 60 * 60 * 1000;
3
+ function live(pid) {
4
+ if (!Number.isInteger(pid) || pid <= 0)
5
+ return false;
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function acquireLock(path) {
15
+ const now = Date.now();
16
+ let priorMtime;
17
+ if (existsSync(path)) {
18
+ const stat = statSync(path);
19
+ priorMtime = stat.mtimeMs;
20
+ let pid = 0;
21
+ try {
22
+ pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
23
+ }
24
+ catch { /* reclaim */ }
25
+ if (now - stat.mtimeMs <= HOUR && live(pid))
26
+ return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
27
+ try {
28
+ unlinkSync(path);
29
+ }
30
+ catch {
31
+ return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now };
32
+ }
33
+ }
34
+ writeFileSync(path, String(process.pid), { flag: "wx" });
35
+ return { path, held: true, startedAt: now, priorMtime };
36
+ }
37
+ export function releaseLock(lock) {
38
+ // The lock is also the durable last-dream timestamp. Leave the PID marker in place;
39
+ // the next acquisition reclaims it once the PID is dead or it is older than an hour.
40
+ }
41
+ export function restoreMtime(path, mtimeMs) {
42
+ try {
43
+ utimesSync(path, new Date(), new Date(mtimeMs));
44
+ }
45
+ catch { /* advisory */ }
46
+ }
47
+ export function failLock(lock) {
48
+ if (!lock.held)
49
+ return;
50
+ if (lock.priorMtime === undefined) {
51
+ try {
52
+ unlinkSync(lock.path);
53
+ }
54
+ catch { /* best effort */ }
55
+ }
56
+ else
57
+ restoreMtime(lock.path, lock.priorMtime);
58
+ }
@@ -0,0 +1,16 @@
1
+ export function parseManifest(output) {
2
+ const starts = [...output.matchAll(/[\{[]/g)].map((m) => m.index ?? 0).reverse();
3
+ for (const start of starts) {
4
+ try {
5
+ const value = JSON.parse(output.slice(start));
6
+ if (!value || typeof value !== "object" || typeof value.report !== "string")
7
+ continue;
8
+ for (const key of ["merge", "promote", "trash", "pending", "skillCandidates"])
9
+ if (value[key] !== undefined && !Array.isArray(value[key]))
10
+ throw new Error("invalid array");
11
+ return value;
12
+ }
13
+ catch { /* try an earlier JSON start */ }
14
+ }
15
+ throw new Error("dreamer did not return a valid JSON manifest");
16
+ }
@@ -0,0 +1,56 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { createAgentSession, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { parseManifest } from "./manifest.js";
5
+ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "notes_read", "notes_list", "notes_search"];
6
+ export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
7
+ let model;
8
+ if (modelPattern) {
9
+ const runtime = await ModelRuntime.create({ allowModelNetwork: false, refreshOnCreate: false });
10
+ const result = await resolveModelScopeWithDiagnostics([modelPattern], runtime);
11
+ model = result.scopedModels[0]?.model;
12
+ if (!model)
13
+ throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
14
+ }
15
+ const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
16
+ return session;
17
+ };
18
+ export function runExternalDreamer(command, playbook, cwd) {
19
+ const result = spawnSync(command, { shell: true, cwd, input: playbook, encoding: "utf8" });
20
+ if (result.error || result.status !== 0)
21
+ throw new Error(`dreamer failed: ${result.error?.message ?? result.stderr ?? `exit ${result.status}`}`);
22
+ return parseManifest(result.stdout);
23
+ }
24
+ export async function runDreamer(playbook, cwd, options = {}) {
25
+ if (options.command)
26
+ return runExternalDreamer(options.command, playbook, cwd);
27
+ const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
28
+ let answer = "";
29
+ let providerError;
30
+ const unsubscribe = session.subscribe((event) => {
31
+ if (event.type !== "message_end" || event.message?.role !== "assistant")
32
+ return;
33
+ if (event.message.stopReason === "error") {
34
+ providerError = event.message.errorMessage ?? "unknown provider error";
35
+ return;
36
+ }
37
+ const content = event.message.content;
38
+ answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
39
+ });
40
+ try {
41
+ await session.prompt(`${playbook}\n\nReturn exactly one JSON manifest matching this schema: { merge?, promote?, trash?, pending?, skillCandidates?, report }.`);
42
+ try {
43
+ return parseManifest(answer);
44
+ }
45
+ catch (error) {
46
+ if (providerError)
47
+ throw new Error(`dreamer failed: ${providerError}`);
48
+ throw error;
49
+ }
50
+ }
51
+ finally {
52
+ unsubscribe?.();
53
+ session.dispose();
54
+ }
55
+ }
56
+ export function loadPlaybook(path) { return readFileSync(path, "utf8"); }
@@ -0,0 +1,105 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
4
+ import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
+ import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
+ /**
7
+ * Shrink one page item to fit the wire budget. `truncated`/`total_chars` stay honest: the
8
+ * payload is only ever cut to a plain prefix of itself, never filled with a marker, and the
9
+ * flag flips on whenever a shrink actually removed characters. `tool_name` is metadata, not a
10
+ * payload, and keeps its visible middle-truncation marker. `item_id`, `window_id`, and `role`
11
+ * are identity or tiny metadata and are never touched.
12
+ */
13
+ function truncateHistoryItem(item, fits) {
14
+ if (fits(item))
15
+ return item;
16
+ const shrinkContent = (base) => ({
17
+ ...base,
18
+ truncated: true,
19
+ truncated_content: prefixFit(base.truncated_content, (candidate) => fits({ ...base, truncated: true, truncated_content: candidate })),
20
+ });
21
+ const withContent = shrinkContent(item);
22
+ if (fits(withContent))
23
+ return withContent;
24
+ if (item.tool_name === null)
25
+ return withContent;
26
+ // Content could not help even as an empty prefix: tool_name is oversized, so keep the
27
+ // original payload and truncate the metadata as the last resort.
28
+ const withName = { ...item, tool_name: middleTruncate(item.tool_name, (candidate) => fits({ ...item, tool_name: candidate })) };
29
+ if (fits(withName))
30
+ return withName;
31
+ // Both fields are oversized: dissolve the payload against the truncated metadata.
32
+ return shrinkContent(withName);
33
+ }
34
+ export function registerHistoryTools(pi) {
35
+ pi.registerTool(defineTool({
36
+ name: "history_windows",
37
+ label: "History list windows",
38
+ description: "List durable Pi session-history windows.",
39
+ parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst() }, { additionalProperties: false }),
40
+ async execute(_id, params, _signal, _update, ctx) {
41
+ let windows = historyFromSession(ctx);
42
+ if (params.recent_first !== false)
43
+ windows = [...windows].reverse();
44
+ const limit = params.limit ?? windows.length;
45
+ return output({ windows: windows.slice(0, limit).map((window) => ({ window_id: window.windowId, item_count: window.items.length })) });
46
+ },
47
+ }));
48
+ pi.registerTool(defineTool({
49
+ name: "history_list",
50
+ label: "History list items",
51
+ description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
52
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
53
+ async execute(_id, params, _signal, _update, ctx) {
54
+ const invalid = vacuousRoleToolCombo(params);
55
+ if (invalid)
56
+ return output({ error: invalid, role: params.role, tool_name: params.tool_name });
57
+ const badWindow = unknownWindowId(ctx, params);
58
+ if (badWindow)
59
+ return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
60
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
61
+ return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
62
+ },
63
+ }));
64
+ pi.registerTool(defineTool({
65
+ name: "history_read",
66
+ label: "History read item",
67
+ description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
68
+ parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })), window_id: Type.String() }, { additionalProperties: false }),
69
+ async execute(_id, params, _signal, _update, ctx) {
70
+ const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
71
+ if (!item)
72
+ return output({ error: "unknown item_id or window_id" });
73
+ const totalChars = Array.from(item.content).length;
74
+ // A positive offset past the end is an addressing error, not an empty page: say so,
75
+ // and name the largest legal offset (offset == total stays the legal empty end-read).
76
+ if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
77
+ return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
78
+ }
79
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
80
+ return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
81
+ const { content, ...cursor } = window;
82
+ return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
83
+ }, (result) => withinTextBudget(result.content[0].text));
84
+ },
85
+ }));
86
+ pi.registerTool(defineTool({
87
+ name: "history_search",
88
+ label: "History search",
89
+ description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read at match_offset_chars.",
90
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
91
+ async execute(_id, params, _signal, _update, ctx) {
92
+ const invalid = vacuousRoleToolCombo(params);
93
+ if (invalid)
94
+ return output({ error: invalid, role: params.role, tool_name: params.tool_name });
95
+ const badWindow = unknownWindowId(ctx, params);
96
+ if (badWindow)
97
+ return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
98
+ const queries = searchQueries(params.query);
99
+ const matching = filteredItems(ctx, params)
100
+ .filter((item) => queries.some((query) => item.content.includes(query)))
101
+ .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
102
+ return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
103
+ },
104
+ }));
105
+ }