@linxiraos/pi-agent-core 1.0.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 (71) hide show
  1. package/CHANGELOG.md +1263 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +137 -0
  4. package/dist/types/agent.d.ts +465 -0
  5. package/dist/types/append-only-context.d.ts +131 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
  8. package/dist/types/compaction/compaction.d.ts +307 -0
  9. package/dist/types/compaction/entries.d.ts +111 -0
  10. package/dist/types/compaction/errors.d.ts +38 -0
  11. package/dist/types/compaction/index.d.ts +13 -0
  12. package/dist/types/compaction/message-cache.d.ts +23 -0
  13. package/dist/types/compaction/messages.d.ts +79 -0
  14. package/dist/types/compaction/openai.d.ts +117 -0
  15. package/dist/types/compaction/pruning.d.ts +105 -0
  16. package/dist/types/compaction/shake.d.ts +97 -0
  17. package/dist/types/compaction/tool-protection.d.ts +19 -0
  18. package/dist/types/compaction/utils.d.ts +61 -0
  19. package/dist/types/compaction.d.ts +1 -0
  20. package/dist/types/index.d.ts +13 -0
  21. package/dist/types/pause.d.ts +46 -0
  22. package/dist/types/proxy.d.ts +91 -0
  23. package/dist/types/replay-policy.d.ts +5 -0
  24. package/dist/types/run-collector.d.ts +196 -0
  25. package/dist/types/telemetry.d.ts +608 -0
  26. package/dist/types/thinking.d.ts +18 -0
  27. package/dist/types/tokenizer.d.ts +2 -0
  28. package/dist/types/types.d.ts +781 -0
  29. package/dist/types/utils/yield.d.ts +71 -0
  30. package/package.json +79 -0
  31. package/src/agent-loop.ts +2926 -0
  32. package/src/agent.ts +1748 -0
  33. package/src/append-only-context.ts +348 -0
  34. package/src/compaction/branch-summarization.ts +369 -0
  35. package/src/compaction/compaction-v2-streaming.ts +846 -0
  36. package/src/compaction/compaction.ts +1733 -0
  37. package/src/compaction/entries.ts +141 -0
  38. package/src/compaction/errors.ts +47 -0
  39. package/src/compaction/index.ts +14 -0
  40. package/src/compaction/message-cache.ts +92 -0
  41. package/src/compaction/messages.ts +241 -0
  42. package/src/compaction/openai.ts +992 -0
  43. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  44. package/src/compaction/prompts/branch-summary-context.md +5 -0
  45. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  46. package/src/compaction/prompts/branch-summary.md +30 -0
  47. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  48. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  49. package/src/compaction/prompts/compaction-summary.md +38 -0
  50. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  51. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  52. package/src/compaction/prompts/context-window-truncated-output.md +1 -0
  53. package/src/compaction/prompts/file-operations.md +5 -0
  54. package/src/compaction/prompts/handoff-document.md +49 -0
  55. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  56. package/src/compaction/prompts/summarization-system.md +3 -0
  57. package/src/compaction/pruning.ts +427 -0
  58. package/src/compaction/shake.ts +446 -0
  59. package/src/compaction/tool-protection.ts +65 -0
  60. package/src/compaction/utils.ts +343 -0
  61. package/src/compaction.ts +1 -0
  62. package/src/index.ts +26 -0
  63. package/src/pause.ts +107 -0
  64. package/src/proxy.ts +391 -0
  65. package/src/replay-policy.ts +13 -0
  66. package/src/run-collector.ts +631 -0
  67. package/src/telemetry.ts +2078 -0
  68. package/src/thinking.ts +20 -0
  69. package/src/tokenizer.ts +27 -0
  70. package/src/types.ts +875 -0
  71. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Append-only context mode — stabilizes the byte prefix sent to the LLM
3
+ * across turns so provider prefix caches (DeepSeek, Anthropic, etc.)
4
+ * hit at the maximum possible rate.
5
+ *
6
+ * Two mechanisms:
7
+ *
8
+ * 1. **StablePrefix** — system prompt + tool specs are computed once
9
+ * and frozen. Subsequent turns reuse the exact same byte sequence
10
+ * unless `invalidate()` is called (e.g. after MCP reconnect).
11
+ *
12
+ * 2. **AppendOnlyLog** — messages only grow; prior turns are never
13
+ * re-serialized. Combined with a stable prefix, only the user's new
14
+ * message delta is a cache miss each turn.
15
+ */
16
+
17
+ import type { Context, Message, Tool } from "@linxiraos/pi-ai";
18
+ import { normalizeTools } from "./agent-loop";
19
+ import type { AgentContext } from "./types";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // StablePrefix (formerly ImmutablePrefix)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Frozen system prompt + tool spec snapshot. */
26
+ export interface StablePrefixSnapshot {
27
+ systemPrompt: string[];
28
+ tools: Tool[];
29
+ fingerprint: string;
30
+ }
31
+
32
+ /** Options threaded through `build()` so the snapshot reflects loop-time settings. */
33
+ export interface BuildOptions {
34
+ /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
35
+ intentTracing: boolean;
36
+ /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
37
+ pruneToolDescriptions?: boolean;
38
+ }
39
+
40
+ /**
41
+ * A frozen prefix (system prompt + tools) that produces stable byte
42
+ * sequences across `build()` calls.
43
+ *
44
+ * The first `build()` snapshots the live state. Subsequent calls reuse
45
+ * the cached copy until `invalidate()` is called or the live state's
46
+ * fingerprint changes.
47
+ */
48
+ export class StablePrefix {
49
+ #snapshot: StablePrefixSnapshot | null = null;
50
+ #version = 0;
51
+
52
+ get fingerprint(): string {
53
+ return this.#snapshot?.fingerprint ?? "<unbuilt>";
54
+ }
55
+ get version(): number {
56
+ return this.#version;
57
+ }
58
+ get built(): boolean {
59
+ return this.#snapshot !== null;
60
+ }
61
+
62
+ /**
63
+ * Build or rebuild from live context.
64
+ * Returns `true` if the prefix actually changed (cache miss imminent).
65
+ */
66
+ build(context: AgentContext, options: BuildOptions): boolean {
67
+ const snapshot = takeSnapshot(context, options);
68
+ if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
69
+ return false;
70
+ }
71
+ this.#snapshot = snapshot;
72
+ this.#version++;
73
+ return true;
74
+ }
75
+
76
+ /** Force rebuild on the next `build()` call. */
77
+ invalidate(): void {
78
+ this.#snapshot = null;
79
+ }
80
+
81
+ /**
82
+ * Returns the cached prefix.
83
+ * @throws if `build()` was never called.
84
+ */
85
+ toContext(): { systemPrompt: string[]; tools: Tool[] } {
86
+ const s = this.#snapshot;
87
+ if (!s) throw new Error("StablePrefix.toContext() called before build()");
88
+ return { systemPrompt: s.systemPrompt, tools: s.tools };
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // AppendOnlyLog
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Append-only message log at the `Message[]` (provider-level) layer.
98
+ *
99
+ * The only mutation path is `replaceTail()`, reserved for compaction.
100
+ * Every other operation is append-only.
101
+ */
102
+ export class AppendOnlyLog {
103
+ #entries: Message[] = [];
104
+
105
+ get length(): number {
106
+ return this.#entries.length;
107
+ }
108
+
109
+ append(message: any): void {
110
+ this.#entries.push(message);
111
+ }
112
+
113
+ extend(messages: any[]): void {
114
+ for (const m of messages) this.#entries.push(m);
115
+ }
116
+
117
+ /** Replace the last entry — only legal for compaction. */
118
+ replaceTail(replacement: any): void {
119
+ const idx = this.#entries.length - 1;
120
+ if (idx >= 0) this.#entries[idx] = replacement;
121
+ }
122
+
123
+ /** Returns a shallow copy of all entries. */
124
+ toMessages(): Message[] {
125
+ return this.#entries.slice();
126
+ }
127
+
128
+ /** Direct readonly access for in-place inspection. */
129
+ entries(): readonly Message[] {
130
+ return this.#entries;
131
+ }
132
+
133
+ /** Drop entries past index `count`, keeping the first `count` byte-stable.
134
+ * Used by {@link AppendOnlyContextManager.syncMessages} to preserve the
135
+ * already-on-the-wire prefix when a later message diverges. */
136
+ truncate(count: number): void {
137
+ if (count < 0) count = 0;
138
+ if (count >= this.#entries.length) return;
139
+ this.#entries.length = count;
140
+ }
141
+
142
+ clear(): void {
143
+ this.#entries = [];
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // AppendOnlyContextManager
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Manages a stable prefix + append-only log for the agent loop.
153
+ *
154
+ * Call `build(context)` each turn to get a `Context` with stable
155
+ * `systemPrompt` and `tools` and append-only messages. Call
156
+ * `syncMessages(normalizedMessages)` after `convertToLlm` each
157
+ * turn to keep the log in sync.
158
+ *
159
+ * Example:
160
+ * ```
161
+ * const mgr = new AppendOnlyContextManager();
162
+ * const ctx = mgr.build(context); // first call snapshots prefix
163
+ * mgr.syncMessages(normalized); // grow the log
164
+ * ctx = mgr.build(context); // subsequent calls use cache
165
+ * ```
166
+ */
167
+ export class AppendOnlyContextManager {
168
+ readonly prefix = new StablePrefix();
169
+ readonly log = new AppendOnlyLog();
170
+ /** How many normalized messages were synced into the log as of the last sync. */
171
+ #lastSyncCount = 0;
172
+ /**
173
+ * Per-message digests of the synced log. Lets a deep or tail rewrite
174
+ * (per-turn pruning, image strip, transformContext re-render) preserve
175
+ * the byte-stable prefix instead of re-sending the entire conversation
176
+ * — keeps the provider's prompt-cache hit rate up to the divergence
177
+ * point on every subsequent turn.
178
+ */
179
+ #messageDigests: number[] = [];
180
+
181
+ build(context: AgentContext, options: BuildOptions): Context {
182
+ this.prefix.build(context, options);
183
+ const { systemPrompt, tools } = this.prefix.toContext();
184
+ return { systemPrompt, messages: this.log.toMessages(), tools };
185
+ }
186
+
187
+ /**
188
+ * Sync normalized (provider-level) messages into the append-only log.
189
+ *
190
+ * Three cases:
191
+ *
192
+ * 1. **Append**: same prefix, new tail → push the new entries.
193
+ * 2. **Compaction**: shorter array → clear the log and replay.
194
+ * 3. **In-place rewrite** (per-turn pruning, transformContext re-render,
195
+ * image strip, etc.): find the longest byte-stable prefix between
196
+ * the previously-synced messages and the new ones, drop the log
197
+ * down to that prefix, then append the diverged tail. Earlier
198
+ * revisions cleared the whole log on any digest change, which on
199
+ * llama.cpp / local backends forced a full ~40k-token re-prefill
200
+ * every turn that an extension, prune pass, or steering re-wrap
201
+ * rewrote a single message (#3406). Preserving the stable prefix
202
+ * lets the provider's KV cache stay warm up to the divergence
203
+ * point — the model only re-prefills from the changed message on.
204
+ */
205
+ syncMessages(normalizedMessages: any[]): void {
206
+ // Compaction (array shrunk) — every previously-synced message is gone,
207
+ // so the log can't carry any byte-stable bytes forward.
208
+ if (normalizedMessages.length < this.#lastSyncCount) {
209
+ this.log.clear();
210
+ this.#lastSyncCount = 0;
211
+ this.#messageDigests = [];
212
+ }
213
+
214
+ // In-place rewrite: trim the log down to the longest byte-stable prefix
215
+ // that both the previous sync and the new messages share. Bound it by
216
+ // the current log length because `log.clear()` is public; direct clears
217
+ // (advisor reset) can leave the sync cursor ahead of the physical log.
218
+ // Anything past that point will be re-appended below with the new bytes.
219
+ if (this.#lastSyncCount > 0) {
220
+ const stableCount = Math.min(this.#longestStablePrefix(normalizedMessages), this.log.length);
221
+ if (stableCount < this.#lastSyncCount) {
222
+ this.log.truncate(stableCount);
223
+ this.#lastSyncCount = stableCount;
224
+ this.#messageDigests.length = stableCount;
225
+ }
226
+ }
227
+
228
+ // Append the diverged tail (or the full delta on a normal turn).
229
+ for (let i = this.#lastSyncCount; i < normalizedMessages.length; i++) {
230
+ const msg = normalizedMessages[i];
231
+ this.log.append(msg);
232
+ this.#messageDigests.push(this.#messageDigest(msg));
233
+ }
234
+ this.#lastSyncCount = normalizedMessages.length;
235
+ }
236
+
237
+ /** Reset prefix + log for a model/provider switch while mode stays active. */
238
+ invalidateForModelChange(): void {
239
+ this.prefix.invalidate();
240
+ this.log.clear();
241
+ this.#lastSyncCount = 0;
242
+ this.#messageDigests = [];
243
+ }
244
+
245
+ /** Reset the sync cursor AND clear the log. */
246
+ resetSyncCursor(): void {
247
+ this.log.clear();
248
+ this.#lastSyncCount = 0;
249
+ this.#messageDigests = [];
250
+ }
251
+
252
+ appendMessage(message: any): void {
253
+ this.log.append(message);
254
+ }
255
+
256
+ replaceTailMessage(message: any): void {
257
+ this.log.replaceTail(message);
258
+ }
259
+
260
+ invalidate(): void {
261
+ this.prefix.invalidate();
262
+ }
263
+
264
+ reset(context: AgentContext, options: BuildOptions): void {
265
+ this.prefix.invalidate();
266
+ this.log.clear();
267
+ this.#lastSyncCount = 0;
268
+ this.#messageDigests = [];
269
+ this.prefix.build(context, options);
270
+ }
271
+
272
+ /** Index of the first message whose serialized bytes differ from the
273
+ * previously-synced log; equals `min(lastSyncCount, normalizedMessages.length)`
274
+ * when nothing diverged. */
275
+ #longestStablePrefix(normalizedMessages: readonly unknown[]): number {
276
+ const bound = Math.min(this.#lastSyncCount, normalizedMessages.length);
277
+ for (let i = 0; i < bound; i++) {
278
+ if (this.#messageDigest(normalizedMessages[i]) !== this.#messageDigests[i]) {
279
+ return i;
280
+ }
281
+ }
282
+ return bound;
283
+ }
284
+
285
+ /** Deterministic digest over every field the provider may serialize — role,
286
+ * content, provider-native replay payloads, tool calls (both `toolCalls` and
287
+ * OpenAI-wire `tool_calls`), tool-result ids/names/error flags (both internal
288
+ * camelCase and wire snake_case), and assistant `id` — so an in-place rewrite
289
+ * of *any* of these fields is visible to {@link #longestStablePrefix}. */
290
+ #messageDigest(msg: unknown): number {
291
+ if (!msg || typeof msg !== "object") return 0;
292
+ const m = msg as Record<string, unknown>;
293
+ const payload = JSON.stringify({
294
+ r: m.role ?? null,
295
+ c: m.content ?? null,
296
+ pp: m.providerPayload ?? null,
297
+ tc: m.toolCalls ?? m.tool_calls ?? null,
298
+ tcid: m.toolCallId ?? m.tool_call_id ?? null,
299
+ tn: m.toolName ?? m.name ?? null,
300
+ err: m.isError ?? null,
301
+ id: m.id ?? null,
302
+ });
303
+ let hash = 0;
304
+ for (let j = 0; j < payload.length; j++) {
305
+ hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
306
+ }
307
+ return hash >>> 0;
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Snapshot helpers
313
+ // ---------------------------------------------------------------------------
314
+
315
+ function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
316
+ const systemPrompt = [...context.systemPrompt];
317
+ const tools =
318
+ normalizeTools(context.tools, {
319
+ injectIntent: options.intentTracing,
320
+ pruneDescriptions: options.pruneToolDescriptions,
321
+ }) ?? [];
322
+ return {
323
+ systemPrompt,
324
+ tools,
325
+ fingerprint: computeFingerprint(systemPrompt, tools, options),
326
+ };
327
+ }
328
+
329
+ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
330
+ const payload = JSON.stringify({
331
+ s: systemPrompt,
332
+ t: tools.map(t => ({
333
+ n: t.name,
334
+ d: t.description,
335
+ p: t.parameters,
336
+ s: t.strict,
337
+ cf: t.customFormat,
338
+ cw: t.customWireName,
339
+ })),
340
+ i: options.intentTracing,
341
+ pd: options.pruneToolDescriptions,
342
+ });
343
+ let hash = 0;
344
+ for (let i = 0; i < payload.length; i++) {
345
+ hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
346
+ }
347
+ return (hash >>> 0).toString(36);
348
+ }