@astrosheep/pi-context 0.13.0 → 0.15.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 +27 -60
- package/docs/architecture.md +6 -4
- package/docs/reset-lifecycle.md +9 -8
- package/package.json +2 -2
- package/src/budget.ts +39 -101
- package/src/history-tools.ts +39 -18
- package/src/history.ts +13 -3
- package/src/index.ts +6 -7
- package/src/note-tools.ts +87 -41
- package/src/notes.ts +34 -13
- package/src/prompts.ts +1 -1
- package/src/protocol.ts +16 -5
- package/src/reset-lifecycle.ts +9 -22
- package/src/thresholds.ts +78 -0
- package/src/tool-output.ts +78 -1
- package/src/tool-schema.ts +18 -2
- package/src/warning.ts +46 -0
package/src/index.ts
CHANGED
|
@@ -2,14 +2,15 @@ import { registerHistoryTools } from "./history-tools.js";
|
|
|
2
2
|
import { registerNoteTools } from "./note-tools.js";
|
|
3
3
|
import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
|
|
4
4
|
import { output } from "./tool-output.js";
|
|
5
|
-
export { deriveThresholds, mergePiContextSettings }
|
|
6
|
-
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE,
|
|
5
|
+
export { deriveThresholds, mergePiContextSettings };
|
|
6
|
+
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
7
7
|
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
|
|
8
|
-
import { assertVirtualPath
|
|
8
|
+
import { assertVirtualPath } from "./notes.js";
|
|
9
9
|
import { bootBlock } from "./prompts.js";
|
|
10
10
|
export { historyFromSession } from "./history.js";
|
|
11
11
|
export { notesFromSession } from "./notes.js";
|
|
12
12
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
13
|
+
import { registerWarning } from "./warning.js";
|
|
13
14
|
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { Type } from "@earendil-works/pi-ai";
|
|
15
16
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -17,6 +18,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
17
18
|
export default function piContext(pi: ExtensionAPI) {
|
|
18
19
|
let enabled = true;
|
|
19
20
|
registerBudget(pi, () => enabled);
|
|
21
|
+
registerWarning(pi, () => enabled);
|
|
20
22
|
|
|
21
23
|
pi.on("session_start", (_event, ctx) => {
|
|
22
24
|
if (!enabled) return;
|
|
@@ -48,8 +50,6 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
48
50
|
registerHistoryTools(pi);
|
|
49
51
|
registerNoteTools(pi);
|
|
50
52
|
|
|
51
|
-
const fallbackGuidance = () => `${GUIDANCE_OPEN_TAG}\n${FALLBACK_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
|
|
52
|
-
|
|
53
53
|
pi.registerTool(defineTool({
|
|
54
54
|
name: "new_context",
|
|
55
55
|
label: "New context",
|
|
@@ -63,7 +63,6 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
63
63
|
|
|
64
64
|
const resets = registerResetLifecycle(pi, {
|
|
65
65
|
isEnabled: () => enabled,
|
|
66
|
-
fallback: { customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true },
|
|
67
66
|
continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
68
67
|
isCurrentReset: (entryId, ctx) => {
|
|
69
68
|
const entry = ctx.sessionManager.getEntry(entryId);
|
|
@@ -96,4 +95,4 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
96
95
|
});
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE,
|
|
98
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
|
package/src/note-tools.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { output, page, middleTruncate,
|
|
4
|
-
import { nullableString,
|
|
5
|
-
import { notesFromSession, assertVirtualPath, assertVirtualPrefix,
|
|
6
|
-
import { NOTE_TYPE, MAX_NOTE_BYTES } from "./protocol.js";
|
|
3
|
+
import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
|
|
4
|
+
import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
|
|
5
|
+
import { notesFromSession, assertVirtualPath, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
|
|
6
|
+
import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
|
|
7
7
|
|
|
8
8
|
export function registerNoteTools(pi: ExtensionAPI) {
|
|
9
9
|
const saveNote = (op: NoteOperation) => {
|
|
@@ -13,65 +13,106 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
pi.registerTool(defineTool({
|
|
16
|
-
name: "
|
|
16
|
+
name: "notes_list_files",
|
|
17
17
|
label: "Notes list files",
|
|
18
|
-
description: "List
|
|
19
|
-
parameters: Type.Object({
|
|
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
20
|
async execute(_id, params, _signal, _update, ctx) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}));
|
|
28
45
|
},
|
|
29
46
|
}));
|
|
30
47
|
|
|
31
48
|
pi.registerTool(defineTool({
|
|
32
49
|
name: "notes_read_file",
|
|
33
50
|
label: "Notes read file",
|
|
34
|
-
description: "Read a
|
|
35
|
-
parameters: Type.Object({ path: Type.String(),
|
|
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 the response echoes the resolved absolute offset — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: pass next_offset_chars back unchanged to continue, concatenate pages in order, null only at the 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 }),
|
|
36
53
|
async execute(_id, params, _signal, _update, ctx) {
|
|
37
54
|
const path = assertVirtualPath(params.path);
|
|
38
55
|
const file = notesFromSession(ctx).get(path);
|
|
39
56
|
if (!file) return output({ error: "note file not found", path });
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const result = (content: string, count: number) => ({ path, start_line: range.start_line, stop_line: range.start_line + count - 1, content, total_lines: totalLines, next_start_line: range.start_line + count <= range.stop_line ? range.start_line + count : null, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) });
|
|
44
|
-
let count = lines.length;
|
|
45
|
-
while (count > 0 && !withinBudget(result(lines.slice(0, count).join("\n"), count))) count--;
|
|
46
|
-
if (count === 0 && lines.length > 0) {
|
|
47
|
-
// One indivisible line is larger than the whole budget: return it middle-truncated and
|
|
48
|
-
// advance past it instead of looping on an empty page whose cursor never moves.
|
|
49
|
-
return output(result(middleTruncate(lines[0], (candidate) => withinBudget(result(candidate, 1))), 1));
|
|
50
|
-
}
|
|
51
|
-
return output(result(lines.slice(0, count).join("\n"), count));
|
|
57
|
+
const created_at = localIso(file.createdAt);
|
|
58
|
+
const updated_at = localIso(file.updatedAt);
|
|
59
|
+
return output(readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => ({ path, ...window, created_at, updated_at })));
|
|
52
60
|
},
|
|
53
61
|
}));
|
|
54
62
|
|
|
55
63
|
pi.registerTool(defineTool({
|
|
56
64
|
name: "notes_search_contents",
|
|
57
65
|
label: "Notes search",
|
|
58
|
-
description: "Case-sensitive literal substring search over
|
|
59
|
-
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query:
|
|
66
|
+
description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. 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.",
|
|
67
|
+
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
|
|
60
68
|
async execute(_id, params, _signal, _update, ctx) {
|
|
69
|
+
const queries = searchQueries(params.query);
|
|
61
70
|
const prefix = assertVirtualPrefix(params.path_prefix);
|
|
62
71
|
let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
|
|
63
72
|
if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
|
|
64
73
|
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
65
|
-
const result
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
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
|
|
75
|
+
.map(([path, file]) => {
|
|
76
|
+
// A match's offset_chars is file-absolute: the code points before its line, plus the
|
|
77
|
+
// earliest occurrence of any query inside that line. Search then composes with
|
|
78
|
+
// notes_read_file exactly like history_search_contents composes with history_read_item.
|
|
79
|
+
let baseChars = 0;
|
|
80
|
+
const allMatches = file.text.split("\n").flatMap((line, index) => {
|
|
81
|
+
const match = queries.some((query) => line.includes(query))
|
|
82
|
+
? [{ line: index + 1, text: line, truncated: false, total_chars: Array.from(line).length, offset_chars: baseChars + earliestMatchOffsetChars(line, queries) }]
|
|
83
|
+
: [];
|
|
84
|
+
baseChars += Array.from(line).length + 1;
|
|
85
|
+
return match;
|
|
86
|
+
});
|
|
87
|
+
return { path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches_total: allMatches.length, matches: allMatches.slice(0, maxPerFile) };
|
|
88
|
+
})
|
|
89
|
+
.filter((file) => file.matches.length > 0);
|
|
90
|
+
// Trailing matches are dropped to fit the budget (bounded by a monotone binary search),
|
|
91
|
+
// and the entry's matches_total keeps naming the drop. Only when a single intact match is
|
|
92
|
+
// over budget is its line delivered as a plain prefix, flagged and counted. Only when the
|
|
93
|
+
// entry cannot fit even then is the identity field itself truncated, and then only together
|
|
94
|
+
// with a visible `path_truncated: true` flag.
|
|
68
95
|
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
if (fits(file)) return file;
|
|
97
|
+
const matches = file.matches;
|
|
98
|
+
// First, drop whole trailing matches: the largest prefix that fits intact is kept, so an
|
|
99
|
+
// entry only truncates a line when that single line alone is over budget.
|
|
100
|
+
let low = 0;
|
|
101
|
+
let high = matches.length;
|
|
102
|
+
while (low < high) {
|
|
103
|
+
const mid = Math.ceil((low + high) / 2);
|
|
104
|
+
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
|
|
105
|
+
else high = mid - 1;
|
|
106
|
+
}
|
|
107
|
+
if (low >= 1) return { ...file, matches: matches.slice(0, low) };
|
|
108
|
+
// Even one intact match is over budget: keep the first match as a plain, named prefix.
|
|
109
|
+
const first = matches[0]!;
|
|
110
|
+
const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
111
|
+
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
112
|
+
const prefix: (typeof result)[number] = fitted(text);
|
|
113
|
+
if (fits(prefix)) return prefix;
|
|
114
|
+
const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
|
|
115
|
+
return { ...prefix, path, path_truncated: true };
|
|
75
116
|
};
|
|
76
117
|
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
77
118
|
},
|
|
@@ -82,8 +123,8 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
82
123
|
name,
|
|
83
124
|
label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
|
|
84
125
|
description: name === "notes_append_to_file"
|
|
85
|
-
? "Append exact text to a
|
|
86
|
-
: "Create or replace a
|
|
126
|
+
? "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."
|
|
127
|
+
: "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.",
|
|
87
128
|
parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
88
129
|
// Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
|
|
89
130
|
// Pi's per-tool equivalent is executionMode "sequential": a batch containing either
|
|
@@ -91,6 +132,11 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
91
132
|
executionMode: "sequential",
|
|
92
133
|
async execute(_id, params, _signal, _update, ctx) {
|
|
93
134
|
const path = assertVirtualPath(params.path);
|
|
135
|
+
const pathBytes = Buffer.byteLength(path, "utf8");
|
|
136
|
+
// The cap lives here, at the tool boundary, and never in assertVirtualPath: note
|
|
137
|
+
// replay validates persisted ops through that helper and must keep loading sessions
|
|
138
|
+
// that already contain a longer legacy path (reads stay un-capped too).
|
|
139
|
+
if (pathBytes > MAX_NOTE_PATH_BYTES) return output({ error: `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes`, path_bytes: pathBytes });
|
|
94
140
|
const hasText = params.text !== undefined;
|
|
95
141
|
const hasStale = params.mark_stale !== undefined;
|
|
96
142
|
if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
|
package/src/notes.ts
CHANGED
|
@@ -25,6 +25,40 @@ export function assertVirtualPrefix(value: unknown): string | undefined {
|
|
|
25
25
|
return assertVirtualPath(value);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Minimal glob over virtual note paths: `*` matches any run within a segment (never
|
|
30
|
+
* `/`), `**` matches any run across segments (a leading double-star followed by a
|
|
31
|
+
* slash also matches zero segments, so it covers the root too), `?` matches exactly
|
|
32
|
+
* one non-`/` character. Everything else is literal and the match is anchored to the
|
|
33
|
+
* whole path.
|
|
34
|
+
*/
|
|
35
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
36
|
+
let source = "^";
|
|
37
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
38
|
+
const char = pattern[index]!;
|
|
39
|
+
if (char === "*") {
|
|
40
|
+
if (pattern[index + 1] === "*") {
|
|
41
|
+
const followedBySlash = pattern[index + 2] === "/";
|
|
42
|
+
source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
|
|
43
|
+
index += followedBySlash ? 2 : 1;
|
|
44
|
+
} else {
|
|
45
|
+
source += "[^/]*";
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return new RegExp(`${source}$`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
|
|
55
|
+
export function assertGlobPattern(value: unknown): string | undefined {
|
|
56
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
57
|
+
if (typeof value !== "string") throw new Error("glob pattern must be a string");
|
|
58
|
+
if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
/** Replays only pi-context note operations from session custom entries. */
|
|
29
63
|
function isNoteOperation(data: unknown): data is NoteOperation {
|
|
30
64
|
if (typeof data !== "object" || data === null) return false;
|
|
@@ -63,19 +97,6 @@ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
|
|
|
63
97
|
return files;
|
|
64
98
|
}
|
|
65
99
|
|
|
66
|
-
export function lineRange(text: string, startValue: unknown, stopValue: unknown) {
|
|
67
|
-
const lines = text.split("\n");
|
|
68
|
-
const resolve = (value: unknown, fallback: number) => {
|
|
69
|
-
if (value === undefined || value === null) return fallback;
|
|
70
|
-
if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
|
|
71
|
-
const line = value as number;
|
|
72
|
-
return line > 0 ? line : lines.length + line + 1;
|
|
73
|
-
};
|
|
74
|
-
const start = Math.max(1, resolve(startValue, 1));
|
|
75
|
-
const stop = Math.min(lines.length, resolve(stopValue, lines.length));
|
|
76
|
-
return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
100
|
const pad2 = (value: number) => String(value).padStart(2, "0");
|
|
80
101
|
|
|
81
102
|
/**
|
package/src/prompts.ts
CHANGED
|
@@ -65,6 +65,6 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
|
|
|
65
65
|
* at write time; get_context_remaining remains the live source for the current figure.
|
|
66
66
|
*/
|
|
67
67
|
export function tokenBudgetGuidance(remaining: number): string {
|
|
68
|
-
return `${GUIDANCE_OPEN_TAG}\nYour
|
|
68
|
+
return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then end the window yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
69
69
|
}
|
|
70
70
|
|
package/src/protocol.ts
CHANGED
|
@@ -2,11 +2,15 @@ export const STATE_TYPE = "pi-context/state";
|
|
|
2
2
|
export const NOTE_TYPE = "pi-context/note";
|
|
3
3
|
export const BOOT_TYPE = "pi-context/boot";
|
|
4
4
|
export const GUIDANCE_TYPE = "pi-context/guidance";
|
|
5
|
-
export const
|
|
5
|
+
export const WARNING_TYPE = "pi-context/warning";
|
|
6
6
|
export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
7
7
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
8
8
|
export const RESET_V2 = "reset-v2";
|
|
9
9
|
export const MAX_NOTE_BYTES = 1_000_000;
|
|
10
|
+
// Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
|
|
11
|
+
// notesFromSession replays already-persisted operations, which must keep loading sessions
|
|
12
|
+
// that contain a longer legacy path. Reads and replay stay un-capped.
|
|
13
|
+
export const MAX_NOTE_PATH_BYTES = 512;
|
|
10
14
|
export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
11
15
|
export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
12
16
|
export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
@@ -16,6 +20,13 @@ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
|
16
20
|
export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
|
|
17
21
|
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
18
22
|
export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
23
|
+
/**
|
|
24
|
+
* The runway: the budget between the final warning and the wipe, deliberately
|
|
25
|
+
* invisible to the model. get_context_remaining counts down to zero at the warning
|
|
26
|
+
* line (reserve + WARNING_RUNWAY_TOKENS); what lies below is overdraft the model
|
|
27
|
+
* never sees — Codex's fallback buffer, relocated above the line.
|
|
28
|
+
*/
|
|
29
|
+
export const WARNING_RUNWAY_TOKENS = 8_192;
|
|
19
30
|
export const RESET_SUMMARY =
|
|
20
31
|
"You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.";
|
|
21
32
|
export const NOTE_PREVIEW_HEAD_CHARS = 80;
|
|
@@ -31,15 +42,15 @@ export const CONTINUATION = "Your memory was just erased. Pull only the details
|
|
|
31
42
|
export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
|
|
32
43
|
Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write_file / notes_append_to_file, read them back with notes_read_file / notes_search_contents; history is read-only through the history_* tools. Everything else wakes blank.
|
|
33
44
|
|
|
34
|
-
Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress,
|
|
45
|
+
Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: 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. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
|
|
35
46
|
|
|
36
|
-
Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
|
|
47
|
+
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 end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
|
|
37
48
|
|
|
38
49
|
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_item directly when you know the window and item IDs, history_list_items or history_search_contents to find them when you don't.
|
|
39
50
|
|
|
40
51
|
Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
|
|
41
52
|
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
42
53
|
|
|
43
|
-
export const
|
|
44
|
-
"
|
|
54
|
+
export const WARNING_PROMPT =
|
|
55
|
+
"Memory wipe incoming — this turn is all you get. Do not continue any task. Get that checkpoint down NOW — one note, write it or append to it: 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. Then call new_context immediately and go out clean — anything not in the note is gone.";
|
|
45
56
|
|
package/src/reset-lifecycle.ts
CHANGED
|
@@ -7,26 +7,22 @@ type ResetResult = { cancel: true } | {
|
|
|
7
7
|
/** A reset request is session-local. Only this module schedules compaction/continuation. */
|
|
8
8
|
export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
9
9
|
isEnabled: () => boolean;
|
|
10
|
-
fallback: Parameters<ExtensionAPI["sendMessage"]>[0];
|
|
11
10
|
continuation: Parameters<ExtensionAPI["sendMessage"]>[0];
|
|
12
11
|
buildReset: (event: SessionBeforeCompactEvent, ctx: ExtensionContext, explicit: boolean) => ResetResult;
|
|
13
12
|
isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
|
|
14
13
|
onReset: (entryId: string) => void;
|
|
15
14
|
}) {
|
|
16
|
-
type Fallback = "available" | "borrowed" | "ready" | "spent";
|
|
17
15
|
type Attempt = { completed: boolean; sessionId: string; explicit: boolean };
|
|
18
16
|
type Request =
|
|
19
17
|
| { phase: "idle" }
|
|
20
18
|
| { phase: "requested" }
|
|
21
19
|
| { phase: "compacting"; attempt: Attempt };
|
|
22
20
|
let state: Request = { phase: "idle" };
|
|
23
|
-
let fallback: Fallback = "available";
|
|
24
21
|
let handledEntry: string | undefined;
|
|
25
22
|
let active = true;
|
|
26
23
|
|
|
27
24
|
const clear = () => {
|
|
28
25
|
state = { phase: "idle" };
|
|
29
|
-
fallback = "available";
|
|
30
26
|
handledEntry = undefined;
|
|
31
27
|
};
|
|
32
28
|
const valid = (request: Attempt, ctx: ExtensionContext) =>
|
|
@@ -43,25 +39,22 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
43
39
|
if (ctx.signal?.aborted) {
|
|
44
40
|
// Esc cancels the user's run. Do not reset or resurrect it at settled.
|
|
45
41
|
state = { phase: "idle" };
|
|
46
|
-
if (fallback !== "available") fallback = "spent";
|
|
47
42
|
return;
|
|
48
43
|
}
|
|
49
|
-
if (fallback === "borrowed") fallback = "ready";
|
|
50
44
|
});
|
|
51
45
|
|
|
52
46
|
pi.on("agent_settled", (_event, ctx) => {
|
|
53
47
|
if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
|
|
54
|
-
if (state.phase !== "requested"
|
|
55
|
-
// One owner for
|
|
56
|
-
//
|
|
57
|
-
const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit:
|
|
48
|
+
if (state.phase !== "requested") return;
|
|
49
|
+
// One owner for requested resets. Consume the request before any external call;
|
|
50
|
+
// repeated settled events and reentrant callbacks are harmless.
|
|
51
|
+
const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
|
|
58
52
|
state = { phase: "compacting", attempt: request };
|
|
59
|
-
if (fallback !== "available") fallback = "spent";
|
|
60
53
|
const onError = (error: Error) => {
|
|
61
54
|
if (!valid(request, ctx)) return;
|
|
62
55
|
state = { phase: "idle" };
|
|
63
56
|
// Do not retry from settled in a tight loop. A later prompt may trigger a
|
|
64
|
-
// native reset or explicitly request one
|
|
57
|
+
// native reset or explicitly request one.
|
|
65
58
|
ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
|
|
66
59
|
};
|
|
67
60
|
try {
|
|
@@ -86,16 +79,11 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
86
79
|
pi.on("session_before_compact", (event, ctx) => {
|
|
87
80
|
if (!active || !options.isEnabled()) return undefined;
|
|
88
81
|
if (event.signal.aborted) return { cancel: true };
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// nested prompt, so pre-prompt automatic compactions always reset directly.
|
|
93
|
-
fallback = "borrowed";
|
|
94
|
-
pi.sendMessage(options.fallback, { triggerTurn: true });
|
|
95
|
-
return { cancel: true };
|
|
96
|
-
}
|
|
82
|
+
// Automatic threshold/overflow compactions reset on the spot — no model turn.
|
|
83
|
+
// The warning steer fired earlier (see warning.ts); what crosses the reserve
|
|
84
|
+
// line now is the wipe itself.
|
|
97
85
|
try {
|
|
98
|
-
return options.buildReset(event, ctx, state.phase === "requested"
|
|
86
|
+
return options.buildReset(event, ctx, state.phase === "requested");
|
|
99
87
|
} catch (error) {
|
|
100
88
|
ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
101
89
|
return { cancel: true }; // Never fall through to a generated default summary.
|
|
@@ -106,7 +94,6 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
106
94
|
if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id) return;
|
|
107
95
|
if (!options.isCurrentReset(event.compactionEntry.id, ctx)) return;
|
|
108
96
|
handledEntry = event.compactionEntry.id;
|
|
109
|
-
fallback = "available";
|
|
110
97
|
if (state.phase === "compacting") state.attempt.completed = !event.willRetry;
|
|
111
98
|
else state = { phase: "idle" };
|
|
112
99
|
// A native compaction (including overflow retry) owns its own scheduling.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
3
|
+
|
|
4
|
+
export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
|
|
5
|
+
type PiContextMargins = { reminderMarginTokens?: unknown };
|
|
6
|
+
|
|
7
|
+
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
12
|
+
function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
13
|
+
if (!isSettingsObject(settings)) return {};
|
|
14
|
+
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
15
|
+
return isSettingsObject(value) ? value : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
19
|
+
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
|
|
20
|
+
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
21
|
+
return { reminderMarginTokens: merged.reminderMarginTokens };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
25
|
+
function validMargin(raw: unknown): number | undefined {
|
|
26
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
|
|
32
|
+
* plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
|
|
33
|
+
* An invalid margin degrades to the default and reports one warning. Pi's automatic
|
|
34
|
+
* threshold/overflow compaction itself resets immediately, with no model turn.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
37
|
+
const warnings: string[] = [];
|
|
38
|
+
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
39
|
+
let reminderMargin: number;
|
|
40
|
+
if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
41
|
+
else {
|
|
42
|
+
const parsed = validMargin(margins.reminderMarginTokens);
|
|
43
|
+
if (parsed === undefined) {
|
|
44
|
+
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
45
|
+
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
46
|
+
} else reminderMargin = parsed;
|
|
47
|
+
}
|
|
48
|
+
return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cached: ResolvedThresholds | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Session-level threshold resolution: Pi's compaction reserve plus the settings.json
|
|
55
|
+
* "pi-context" margins. The file-backed read is cached until resetThresholds (called
|
|
56
|
+
* on session_start/session_tree); invalid configuration degrades per offending key
|
|
57
|
+
* with one warning and never throws during session operation.
|
|
58
|
+
*/
|
|
59
|
+
export function thresholdsFor(ctx: ExtensionContext): ResolvedThresholds {
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
try {
|
|
62
|
+
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
63
|
+
const derived = deriveThresholds(
|
|
64
|
+
settingsManager.getCompactionSettings().reserveTokens,
|
|
65
|
+
mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
|
|
66
|
+
);
|
|
67
|
+
for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
|
|
68
|
+
cached = derived.thresholds;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
|
|
71
|
+
cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
|
|
72
|
+
}
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resetThresholds(): void {
|
|
77
|
+
cached = undefined;
|
|
78
|
+
}
|
package/src/tool-output.ts
CHANGED
|
@@ -41,6 +41,83 @@ export function middleTruncate(text: string, fits: (content: string) => boolean)
|
|
|
41
41
|
return build(low);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Longest contiguous prefix of `text` (counted in code points) accepted by `fits`.
|
|
46
|
+
*
|
|
47
|
+
* This is the truncation used by every cursor-bearing payload: the delivered text is
|
|
48
|
+
* always a plain prefix of the original, so a cursor computed from its code-point length
|
|
49
|
+
* addresses exactly the first undelivered character. No marker character is ever appended;
|
|
50
|
+
* the companion `truncated`/`total_chars` fields name what was left out.
|
|
51
|
+
*/
|
|
52
|
+
export function prefixFit(text: string, fits: (content: string) => boolean): string {
|
|
53
|
+
if (fits(text)) return text;
|
|
54
|
+
const chars = Array.from(text);
|
|
55
|
+
// Serialized size is non-decreasing in the kept count, so the largest fitting prefix is
|
|
56
|
+
// found by a monotone binary search instead of a quadratic shrink loop.
|
|
57
|
+
let low = 0;
|
|
58
|
+
let high = chars.length;
|
|
59
|
+
while (low < high) {
|
|
60
|
+
const mid = Math.ceil((low + high) / 2);
|
|
61
|
+
if (fits(chars.slice(0, mid).join(""))) low = mid;
|
|
62
|
+
else high = mid - 1;
|
|
63
|
+
}
|
|
64
|
+
// A candidate's serialized size can dip by a byte or two at the very end (a numeric cursor
|
|
65
|
+
// becoming null), so the predicate is not perfectly monotone at the tail. Back off until the
|
|
66
|
+
// returned prefix provably fits; in the monotone case this loop never runs.
|
|
67
|
+
while (low > 0 && !fits(chars.slice(0, low).join(""))) low -= 1;
|
|
68
|
+
return chars.slice(0, low).join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fields every character-window read returns; each tool adds its own identity and metadata.
|
|
73
|
+
* `offset_chars` is always the resolved absolute offset, and `next_offset_chars` is exactly
|
|
74
|
+
* that offset plus the delivered code-point count, null only at the text's true end.
|
|
75
|
+
*/
|
|
76
|
+
export type CharacterWindow = {
|
|
77
|
+
offset_chars: number;
|
|
78
|
+
content: string;
|
|
79
|
+
total_chars: number;
|
|
80
|
+
next_offset_chars: number | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read one character window of `text`: the longest contiguous prefix of
|
|
85
|
+
* `chars[resolved, resolved + limit)` that fits the wire budget.
|
|
86
|
+
*
|
|
87
|
+
* `offsetChars` is a code-point offset. A negative value counts back from the end and
|
|
88
|
+
* resolves to `max(0, total_chars + offsetChars)`, so `-N` reaches the tail and any
|
|
89
|
+
* `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
|
|
90
|
+
* Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
|
|
91
|
+
* payload is always a plain prefix with no marker. `render` builds the exact response for
|
|
92
|
+
* a candidate window, so the budget is measured on the bytes that go on the wire.
|
|
93
|
+
*/
|
|
94
|
+
export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T): T {
|
|
95
|
+
const chars = Array.from(text);
|
|
96
|
+
const requested = offsetChars ?? 0;
|
|
97
|
+
const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
|
|
98
|
+
const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? 12000, 50000));
|
|
99
|
+
const build = (content: string): CharacterWindow => {
|
|
100
|
+
const next = resolved + Array.from(content).length;
|
|
101
|
+
return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
|
|
102
|
+
};
|
|
103
|
+
const content = prefixFit(windowChars.join(""), (candidate) => withinBudget(render(build(candidate))));
|
|
104
|
+
return render(build(content));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
109
|
+
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
110
|
+
*/
|
|
111
|
+
export function earliestMatchOffsetChars(text: string, queries: string[]): number {
|
|
112
|
+
let earliest = -1;
|
|
113
|
+
for (const query of queries) {
|
|
114
|
+
const index = text.indexOf(query);
|
|
115
|
+
if (index < 0) continue;
|
|
116
|
+
if (earliest < 0 || index < earliest) earliest = index;
|
|
117
|
+
}
|
|
118
|
+
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
119
|
+
}
|
|
120
|
+
|
|
44
121
|
/** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
|
|
45
122
|
export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
|
|
46
123
|
|
|
@@ -48,7 +125,7 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
|
|
|
48
125
|
* Build a page without ever adding an item that would exceed the wire budget.
|
|
49
126
|
*
|
|
50
127
|
* A single item that cannot fit is middle-truncated through the optional `truncate`
|
|
51
|
-
* callback and still included, with `next_cursor` advanced past it. Without that
|
|
128
|
+
* callback and still included, with `next_cursor` advanced past it. Without that treatment
|
|
52
129
|
* an oversized item would yield an empty page forever: the cursor would keep pointing back
|
|
53
130
|
* at the same index.
|
|
54
131
|
*/
|