@astrosheep/pi-context 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/src/budget.js +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/docs/reset-lifecycle.md +1 -1
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/history-tools.ts +6 -6
- package/src/history.ts +1 -1
- package/src/index.ts +2 -2
- package/src/memory/frontmatter.ts +153 -0
- package/src/memory/paths.ts +60 -0
- package/src/memory/store.ts +310 -0
- package/src/memory/tools.ts +175 -0
- package/src/prompts.ts +28 -17
- package/src/protocol.ts +7 -7
- package/src/note-tools.ts +0 -171
package/src/note-tools.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
|
|
4
|
-
import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
|
|
5
|
-
import { notesFromSession, assertVirtualPath, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
|
|
6
|
-
import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
|
|
7
|
-
|
|
8
|
-
export function registerNoteTools(pi: ExtensionAPI) {
|
|
9
|
-
const saveNote = (op: NoteOperation) => {
|
|
10
|
-
// pi.appendEntry writes a custom SessionManager entry. Custom entries are persistent but excluded from LLM context.
|
|
11
|
-
// ExtensionContext deliberately exposes only a readonly SessionManager, so this is the public extension write path.
|
|
12
|
-
pi.appendEntry(NOTE_TYPE, op);
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
pi.registerTool(defineTool({
|
|
16
|
-
name: "notes_list_files",
|
|
17
|
-
label: "Notes list files",
|
|
18
|
-
description: "List note files, optionally filtered by a glob pattern (* within a path segment, ** across segments). The default order is most recently updated first; file_order_by (name, created_at, updated_at) and file_order (ascending, descending) select another. Entries carry each file's stale flag.",
|
|
19
|
-
parameters: Type.Object({ pattern: nullableString(), max_results: positiveInteger(), cursor: cursor(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
|
|
20
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
21
|
-
const pattern = assertGlobPattern(params.pattern);
|
|
22
|
-
const matcher = pattern ? globToRegExp(pattern) : undefined;
|
|
23
|
-
let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
|
|
24
|
-
const key = params.file_order_by ?? "updated_at";
|
|
25
|
-
// Deterministic total order: (axis key, createdAt, path) ascending. Paths are unique, so
|
|
26
|
-
// this never depends on map iteration order; descending reverses the whole comparator.
|
|
27
|
-
files.sort(([aPath, a], [bPath, b]) => {
|
|
28
|
-
const primary = key === "name" ? aPath.localeCompare(bPath) : key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt;
|
|
29
|
-
if (primary !== 0) return primary;
|
|
30
|
-
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
|
31
|
-
return aPath.localeCompare(bPath);
|
|
32
|
-
});
|
|
33
|
-
// An explicit file_order always wins; otherwise the axis's natural direction applies
|
|
34
|
-
// (descending for the time axes, ascending for name).
|
|
35
|
-
if (params.file_order ? params.file_order === "descending" : key !== "name") files.reverse();
|
|
36
|
-
const listed: Array<{ path: string; size_bytes: number; stale: boolean; created_at: string; updated_at: string; path_truncated?: boolean }> = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
|
|
37
|
-
// `path` is the entry's identity: return it intact whenever the entry fits, and only
|
|
38
|
-
// ever alter it together with a visible `path_truncated: true` flag. A pathological
|
|
39
|
-
// legacy path predating the write cap is the one case that cannot fit at all.
|
|
40
|
-
return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
41
|
-
if (fits(file)) return file;
|
|
42
|
-
const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
|
|
43
|
-
return { ...file, path, path_truncated: true };
|
|
44
|
-
}));
|
|
45
|
-
},
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
pi.registerTool(defineTool({
|
|
49
|
-
name: "notes_read_file",
|
|
50
|
-
label: "Notes read file",
|
|
51
|
-
description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw note text behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
|
|
52
|
-
parameters: Type.Object({ path: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
|
|
53
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
54
|
-
const path = assertVirtualPath(params.path);
|
|
55
|
-
const file = notesFromSession(ctx).get(path);
|
|
56
|
-
if (!file) return output({ error: "note file not found", path });
|
|
57
|
-
const totalChars = Array.from(file.text).length;
|
|
58
|
-
// A positive offset past the end is an addressing error, not an empty page: say so,
|
|
59
|
-
// and name the largest legal offset (offset == total stays the legal empty end-read).
|
|
60
|
-
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
61
|
-
return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, path, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
62
|
-
}
|
|
63
|
-
const created_at = localIso(file.createdAt);
|
|
64
|
-
const updated_at = localIso(file.updatedAt);
|
|
65
|
-
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
66
|
-
return readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => {
|
|
67
|
-
const { content, ...cursor } = window;
|
|
68
|
-
return outputRaw(characterWindowHeader(path, window, ` · created ${created_at} · updated ${updated_at}`), content, { path, ...cursor, limit_chars, created_at, updated_at });
|
|
69
|
-
}, (result) => withinTextBudget(result.content[0].text));
|
|
70
|
-
},
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
pi.registerTool(defineTool({
|
|
74
|
-
name: "notes_search_contents",
|
|
75
|
-
label: "Notes search",
|
|
76
|
-
description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. Optionally filtered by a glob pattern (* within a path segment, ** across segments). Each file entry carries matches_total, its full match count before capping: matches_total minus matches.length is how many were dropped. Each match carries line and offset_chars (the file-absolute code-point offset of the earliest match): notes_read_file at offset_chars shows the query. An over-budget matched line comes back as a prefix with truncated and total_chars; read the rest at the same offset_chars.",
|
|
77
|
-
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), pattern: nullableString() }, { additionalProperties: false }),
|
|
78
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
79
|
-
const queries = searchQueries(params.query);
|
|
80
|
-
const pattern = assertGlobPattern(params.pattern);
|
|
81
|
-
const matcher = pattern ? globToRegExp(pattern) : undefined;
|
|
82
|
-
let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
|
|
83
|
-
if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
|
|
84
|
-
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
85
|
-
const result: Array<{ path: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = files
|
|
86
|
-
.map(([path, file]) => {
|
|
87
|
-
// A match's offset_chars is file-absolute: the code points before its line, plus the
|
|
88
|
-
// earliest occurrence of any query inside that line. Search then composes with
|
|
89
|
-
// notes_read_file exactly like history_search_contents composes with history_read_item.
|
|
90
|
-
let baseChars = 0;
|
|
91
|
-
const allMatches = file.text.split("\n").flatMap((line, index) => {
|
|
92
|
-
const match = queries.some((query) => line.includes(query))
|
|
93
|
-
? [{ line: index + 1, text: line, truncated: false, total_chars: Array.from(line).length, offset_chars: baseChars + earliestMatchOffsetChars(line, queries) }]
|
|
94
|
-
: [];
|
|
95
|
-
baseChars += Array.from(line).length + 1;
|
|
96
|
-
return match;
|
|
97
|
-
});
|
|
98
|
-
return { path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches_total: allMatches.length, matches: allMatches.slice(0, maxPerFile) };
|
|
99
|
-
})
|
|
100
|
-
.filter((file) => file.matches.length > 0);
|
|
101
|
-
// Trailing matches are dropped to fit the budget (bounded by a monotone binary search),
|
|
102
|
-
// and the entry's matches_total keeps naming the drop. Only when a single intact match is
|
|
103
|
-
// over budget is its line delivered as a plain prefix, flagged and counted. Only when the
|
|
104
|
-
// entry cannot fit even then is the identity field itself truncated, and then only together
|
|
105
|
-
// with a visible `path_truncated: true` flag.
|
|
106
|
-
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
107
|
-
if (fits(file)) return file;
|
|
108
|
-
const matches = file.matches;
|
|
109
|
-
// First, drop whole trailing matches: the largest prefix that fits intact is kept, so an
|
|
110
|
-
// entry only truncates a line when that single line alone is over budget.
|
|
111
|
-
let low = 0;
|
|
112
|
-
let high = matches.length;
|
|
113
|
-
while (low < high) {
|
|
114
|
-
const mid = Math.ceil((low + high) / 2);
|
|
115
|
-
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
|
|
116
|
-
else high = mid - 1;
|
|
117
|
-
}
|
|
118
|
-
if (low >= 1) return { ...file, matches: matches.slice(0, low) };
|
|
119
|
-
// Even one intact match is over budget: keep the first match as a plain, named prefix.
|
|
120
|
-
const first = matches[0]!;
|
|
121
|
-
const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
122
|
-
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
123
|
-
const prefix: (typeof result)[number] = fitted(text);
|
|
124
|
-
if (fits(prefix)) return prefix;
|
|
125
|
-
const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
|
|
126
|
-
return { ...prefix, path, path_truncated: true };
|
|
127
|
-
};
|
|
128
|
-
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
129
|
-
},
|
|
130
|
-
}));
|
|
131
|
-
|
|
132
|
-
for (const [name, op] of [["notes_append_to_file", "append"], ["notes_write_file", "write"]] as const) {
|
|
133
|
-
pi.registerTool(defineTool({
|
|
134
|
-
name,
|
|
135
|
-
label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
|
|
136
|
-
description: name === "notes_append_to_file"
|
|
137
|
-
? "Append exact text to a note file. Appending suits chronological logs; for current-state notes use notes_write_file instead. mark_stale closes a note."
|
|
138
|
-
: "Create or replace a note file. Keep notes small and split by topic; replace outdated notes whole. mark_stale: true flags a note stale (optionally with its final content): stale notes leave the boot index but stay readable and searchable; rewriting revives them.",
|
|
139
|
-
parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
140
|
-
// Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
|
|
141
|
-
// Pi's per-tool equivalent is executionMode "sequential": a batch containing either
|
|
142
|
-
// tool runs its calls one at a time, so note read-modify-write cannot race.
|
|
143
|
-
executionMode: "sequential",
|
|
144
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
145
|
-
const path = assertVirtualPath(params.path);
|
|
146
|
-
const pathBytes = Buffer.byteLength(path, "utf8");
|
|
147
|
-
// The cap lives here, at the tool boundary, and never in assertVirtualPath: note
|
|
148
|
-
// replay validates persisted ops through that helper and must keep loading sessions
|
|
149
|
-
// that already contain a longer legacy path (reads stay un-capped too).
|
|
150
|
-
if (pathBytes > MAX_NOTE_PATH_BYTES) return output({ error: `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes`, path_bytes: pathBytes });
|
|
151
|
-
const hasText = params.text !== undefined;
|
|
152
|
-
const hasStale = params.mark_stale !== undefined;
|
|
153
|
-
if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
|
|
154
|
-
const old = notesFromSession(ctx).get(path);
|
|
155
|
-
if (!hasText && !old) return output({ error: "note file not found", path });
|
|
156
|
-
// Appending is not creating: an append to a path that does not exist almost always
|
|
157
|
-
// means a typo'd path, so it dies loudly instead of silently minting a new note.
|
|
158
|
-
if (op === "append" && hasText && !old) return output({ error: "note file not found (use notes_write_file to create)", path });
|
|
159
|
-
const next = hasText ? (op === "append" ? `${old?.text ?? ""}${params.text}` : params.text as string) : old!.text;
|
|
160
|
-
const bytes = Buffer.byteLength(next, "utf8");
|
|
161
|
-
if (hasText && bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
|
|
162
|
-
const now = Date.now();
|
|
163
|
-
const operation: NoteOperation = { op, path, createdAt: old?.createdAt ?? now, updatedAt: now };
|
|
164
|
-
if (hasText) operation.text = params.text;
|
|
165
|
-
if (hasStale) operation.stale = params.mark_stale;
|
|
166
|
-
saveNote(operation);
|
|
167
|
-
return output({ path, size_bytes: bytes, operation: op, stale: hasStale ? params.mark_stale : false });
|
|
168
|
-
},
|
|
169
|
-
}));
|
|
170
|
-
}
|
|
171
|
-
}
|