@linxiraos/pi-agent-core 1.0.9 → 1.1.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 (51) hide show
  1. package/CHANGELOG.md +2 -1283
  2. package/LICENSE +23 -0
  3. package/package.json +77 -78
  4. package/src/agent-loop.ts +17 -2
  5. package/src/agent.ts +25 -4
  6. package/src/append-only-context.ts +28 -2
  7. package/src/compaction/branch-summarization.ts +15 -8
  8. package/src/compaction/compaction-v2-streaming.ts +2 -0
  9. package/src/compaction/compaction.ts +234 -162
  10. package/src/compaction/entries.ts +11 -0
  11. package/src/compaction/errors.ts +2 -2
  12. package/src/compaction/index.ts +1 -0
  13. package/src/compaction/message-cache.ts +33 -34
  14. package/src/compaction/messages.ts +37 -6
  15. package/src/compaction/openai.ts +54 -20
  16. package/src/compaction/prompts/handoff-document.md +2 -0
  17. package/src/compaction/prompts/handoff-summary-context.md +9 -0
  18. package/src/compaction/prompts/summarization-system.md +2 -0
  19. package/src/compaction/pruning.ts +25 -13
  20. package/src/compaction/shake.ts +18 -16
  21. package/src/compaction/transcript-tokens.ts +111 -0
  22. package/src/compaction/utils.ts +9 -2
  23. package/src/tokenizer.ts +273 -18
  24. package/src/types.ts +1 -1
  25. package/dist/types/agent-loop.d.ts +0 -137
  26. package/dist/types/agent.d.ts +0 -465
  27. package/dist/types/append-only-context.d.ts +0 -131
  28. package/dist/types/compaction/branch-summarization.d.ts +0 -101
  29. package/dist/types/compaction/compaction-v2-streaming.d.ts +0 -85
  30. package/dist/types/compaction/compaction.d.ts +0 -321
  31. package/dist/types/compaction/entries.d.ts +0 -111
  32. package/dist/types/compaction/errors.d.ts +0 -38
  33. package/dist/types/compaction/index.d.ts +0 -13
  34. package/dist/types/compaction/message-cache.d.ts +0 -23
  35. package/dist/types/compaction/messages.d.ts +0 -79
  36. package/dist/types/compaction/openai.d.ts +0 -117
  37. package/dist/types/compaction/pruning.d.ts +0 -105
  38. package/dist/types/compaction/shake.d.ts +0 -99
  39. package/dist/types/compaction/tool-protection.d.ts +0 -19
  40. package/dist/types/compaction/utils.d.ts +0 -61
  41. package/dist/types/compaction.d.ts +0 -1
  42. package/dist/types/index.d.ts +0 -13
  43. package/dist/types/pause.d.ts +0 -46
  44. package/dist/types/proxy.d.ts +0 -91
  45. package/dist/types/replay-policy.d.ts +0 -5
  46. package/dist/types/run-collector.d.ts +0 -196
  47. package/dist/types/telemetry.d.ts +0 -625
  48. package/dist/types/thinking.d.ts +0 -18
  49. package/dist/types/tokenizer.d.ts +0 -2
  50. package/dist/types/types.d.ts +0 -786
  51. package/dist/types/utils/yield.d.ts +0 -71
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+ Copyright (c) 2025-2026 Can Bölük
5
+ Copyright (c) 2026 Stencil Labs, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/package.json CHANGED
@@ -1,79 +1,78 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-agent-core",
4
- "version": "1.0.9",
5
- "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
- "homepage": "https://linxira-os.github.io",
7
- "author": "Can Boluk",
8
- "contributors": [
9
- "Mario Zechner"
10
- ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/agent"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/can1357/oh-my-pi/issues"
19
- },
20
- "keywords": [
21
- "ai",
22
- "agent",
23
- "llm",
24
- "transport",
25
- "state-management"
26
- ],
27
- "main": "./src/index.ts",
28
- "types": "./dist/types/index.d.ts",
29
- "scripts": {
30
- "check": "biome check . && bun run check:types",
31
- "check:types": "tsgo -p tsconfig.json --noEmit",
32
- "lint": "biome lint .",
33
- "test": "bun test --parallel",
34
- "fix": "biome check --write --unsafe .",
35
- "fmt": "biome format --write ."
36
- },
37
- "dependencies": {
38
- "@linxiraos/pi-ai": "1.0.9",
39
- "@linxiraos/pi-catalog": "1.0.9",
40
- "@linxiraos/pi-natives": "1.0.9",
41
- "@linxiraos/pi-utils": "1.0.9",
42
- "@linxiraos/pi-wire": "1.0.9",
43
- "@opentelemetry/api": "^1.9.1",
44
- "@linxiraos/pi-snapcompact": "1.0.9"
45
- },
46
- "devDependencies": {
47
- "@linxiraos/pi-omptype": "1.0.9",
48
- "@opentelemetry/context-async-hooks": "^2.9.0",
49
- "@opentelemetry/sdk-trace-base": "^2.9.0",
50
- "@types/bun": "^1.3.14"
51
- },
52
- "engines": {
53
- "bun": ">=1.3.14"
54
- },
55
- "files": [
56
- "src",
57
- "README.md",
58
- "CHANGELOG.md",
59
- "dist/types"
60
- ],
61
- "exports": {
62
- ".": {
63
- "types": "./dist/types/index.d.ts",
64
- "import": "./src/index.ts"
65
- },
66
- "./compaction": {
67
- "types": "./dist/types/compaction.d.ts",
68
- "import": "./src/compaction.ts"
69
- },
70
- "./compaction/*": {
71
- "types": "./dist/types/compaction/*.d.ts",
72
- "import": "./src/compaction/*.ts"
73
- },
74
- "./*": {
75
- "types": "./dist/types/*.d.ts",
76
- "import": "./src/*.ts"
77
- }
78
- }
79
- }
2
+ "type": "module",
3
+ "name": "@linxiraos/pi-agent-core",
4
+ "version": "1.1.0",
5
+ "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
+ "homepage": "https://linxira-os.github.io/zeta/",
7
+ "author": "Can Boluk",
8
+ "contributors": [
9
+ "Mario Zechner"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
+ "directory": "packages/agent"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
19
+ },
20
+ "keywords": [
21
+ "ai",
22
+ "agent",
23
+ "llm",
24
+ "transport",
25
+ "state-management"
26
+ ],
27
+ "main": "./src/index.ts",
28
+ "types": "./src/index.ts",
29
+ "scripts": {
30
+ "check": "biome check . && bun run check:types",
31
+ "check:types": "tsgo -p tsconfig.json --noEmit",
32
+ "lint": "biome lint .",
33
+ "test": "bun test --parallel",
34
+ "fix": "biome check --write --unsafe .",
35
+ "fmt": "biome format --write ."
36
+ },
37
+ "dependencies": {
38
+ "@linxiraos/pi-ai": "catalog:",
39
+ "@linxiraos/pi-catalog": "catalog:",
40
+ "@linxiraos/pi-natives": "catalog:",
41
+ "@linxiraos/pi-utils": "catalog:",
42
+ "@linxiraos/pi-wire": "catalog:",
43
+ "@linxiraos/pi-snapcompact": "catalog:",
44
+ "@opentelemetry/api": "catalog:"
45
+ },
46
+ "devDependencies": {
47
+ "@linxiraos/pi-omptype": "catalog:",
48
+ "@opentelemetry/context-async-hooks": "catalog:",
49
+ "@opentelemetry/sdk-trace-base": "catalog:",
50
+ "@types/bun": "catalog:"
51
+ },
52
+ "engines": {
53
+ "bun": ">=1.3.14"
54
+ },
55
+ "files": [
56
+ "src",
57
+ "README.md",
58
+ "CHANGELOG.md"
59
+ ],
60
+ "exports": {
61
+ ".": {
62
+ "types": "./src/index.ts",
63
+ "import": "./src/index.ts"
64
+ },
65
+ "./compaction": {
66
+ "types": "./src/compaction.ts",
67
+ "import": "./src/compaction.ts"
68
+ },
69
+ "./compaction/*": {
70
+ "types": "./src/compaction/*.ts",
71
+ "import": "./src/compaction/*.ts"
72
+ },
73
+ "./*": {
74
+ "types": "./src/*.ts",
75
+ "import": "./src/*.ts"
76
+ }
77
+ }
78
+ }
package/src/agent-loop.ts CHANGED
@@ -623,6 +623,15 @@ function buildAgentEndEvent(
623
623
  * Push a `turn_end` event and run the awaited per-turn hook when the run is
624
624
  * still healthy. The hook is skipped for externally aborted or errored turns so
625
625
  * a user interrupt does not hang on a background backlog wait.
626
+ *
627
+ * A {@link TERMINAL_TOOL_RESULT_ABORT_REASON} abort is the exception: it is a
628
+ * graceful yield (e.g. a subagent's final `yield` tool), not a user interrupt.
629
+ * The completed tool batch is persisted and the turn must still reach
630
+ * `onTurnEnd` so per-turn bookkeeping — notably advisor review of the yield
631
+ * delta (#9505) — runs exactly as it does for a plain end-of-turn message. The
632
+ * hook receives no signal in that case so downstream waits (advisor catch-up)
633
+ * behave identically to a normal final turn instead of short-circuiting on the
634
+ * spent abort.
626
635
  */
627
636
  async function emitTurnEnd(
628
637
  stream: EventStream<AgentEvent, AgentMessage[]>,
@@ -635,10 +644,16 @@ async function emitTurnEnd(
635
644
  runHookOnAbortedMessage = false,
636
645
  ): Promise<void> {
637
646
  stream.push({ type: "turn_end", message, toolResults });
647
+ const terminalYield = signal?.reason === TERMINAL_TOOL_RESULT_ABORT_REASON;
638
648
  const isAbortedOrError =
639
649
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
640
- if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
641
- await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
650
+ if ((signal?.aborted && !terminalYield) || (isAbortedOrError && !runHookOnAbortedMessage)) return;
651
+ await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
652
+ message,
653
+ toolResults,
654
+ willContinue: false,
655
+ ...context,
656
+ });
642
657
  }
643
658
 
644
659
  function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
package/src/agent.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from "./agent-loop";
38
38
  import type { AppendOnlyContextManager } from "./append-only-context";
39
39
  import { isProviderRefusalMessage } from "./replay-policy";
40
+ import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
40
41
  import type {
41
42
  AgentBeforeModelCall,
42
43
  AgentContext,
@@ -363,7 +364,7 @@ export class Agent {
363
364
  pendingToolCalls: new Set<string>(),
364
365
  error: undefined,
365
366
  };
366
-
367
+ #tokenizer = new Tokenizer(this.#state.model);
367
368
  #listeners = new Set<(e: AgentEvent) => void>();
368
369
  #abortController?: AbortController;
369
370
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
@@ -459,6 +460,7 @@ export class Agent {
459
460
  if (opts.initialState?.messages) this.#state.messages = opts.initialState.messages.slice();
460
461
  if (opts.initialState?.pendingToolCalls)
461
462
  this.#state.pendingToolCalls = new Set(opts.initialState.pendingToolCalls);
463
+ this.#syncTokenizer(this.#state.model);
462
464
  this.#convertToLlm = opts.convertToLlm || defaultConvertToLlm;
463
465
  this.#transformContext = opts.transformContext;
464
466
  this.#steeringMode = opts.steeringMode || "one-at-a-time";
@@ -720,11 +722,29 @@ export class Agent {
720
722
  set maxRetryDelayMs(value: number | undefined) {
721
723
  this.#maxRetryDelayMs = value;
722
724
  }
723
-
724
725
  get state(): AgentState {
725
726
  return this.#state;
726
727
  }
727
728
 
729
+ /**
730
+ * Tokenizer for the active model. The instance is replaced whenever the
731
+ * active model's encoding changes (see {@link setModel}), so callers must
732
+ * not cache it across model switches.
733
+ */
734
+ get tokenizer(): Tokenizer {
735
+ return this.#tokenizer;
736
+ }
737
+
738
+ /**
739
+ * Swap the tokenizer only when the encoding actually changes, so the warm
740
+ * per-message memo survives same-encoding model switches.
741
+ */
742
+ #syncTokenizer(model: Model | null | undefined): void {
743
+ if (tokenizerEncodingForModel(model) !== this.#tokenizer.encoding) {
744
+ this.#tokenizer = new Tokenizer(model);
745
+ }
746
+ }
747
+
728
748
  get appendOnlyContext(): AppendOnlyContextManager | undefined {
729
749
  return this.#appendOnlyContext;
730
750
  }
@@ -902,8 +922,9 @@ export class Agent {
902
922
  this.#state.systemPrompt = typeof v === "string" ? [v] : v;
903
923
  }
904
924
 
905
- setModel(m: Model) {
906
- this.#state.model = m;
925
+ setModel(model: Model) {
926
+ this.#state.model = model;
927
+ this.#syncTokenizer(model);
907
928
  }
908
929
 
909
930
  setThinkingLevel(l: Effort | undefined) {
@@ -16,7 +16,8 @@
16
16
 
17
17
  import type { Context, Message, Tool } from "@linxiraos/pi-ai";
18
18
  import { normalizeTools } from "./agent-loop";
19
- import type { AgentContext } from "./types";
19
+ import { messageEstimateVersion } from "./compaction/message-cache";
20
+ import type { AgentContext, AgentMessage } from "./types";
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // StablePrefix (formerly ImmutablePrefix)
@@ -177,6 +178,26 @@ export class AppendOnlyContextManager {
177
178
  * point on every subsequent turn.
178
179
  */
179
180
  #messageDigests: number[] = [];
181
+ /**
182
+ * Digests memoized by message object identity, validated by the message's
183
+ * estimate version ({@link messageEstimateVersion}). Synced message objects
184
+ * are stable between calls: converted fragments are cached per session
185
+ * message identity and handed back unchanged on every call, so an unchanged
186
+ * history re-hits this memo instead of re-serializing every previously-
187
+ * synced message on each LLM call.
188
+ *
189
+ * Owner-side rewrites (prune/shake/strip-images) mutate messages IN PLACE
190
+ * under stable identity — for assistant pass-through fragments the log
191
+ * aliases the very object being mutated — so a bare identity memo would
192
+ * serve pre-mutation bytes forever. Those owners MUST call
193
+ * `invalidateMessageCache`, which bumps the symbol-keyed version tag
194
+ * this memo validates before every hit; a version mismatch recomputes from
195
+ * actual bytes and the sync diverges exactly as if a fresh object had
196
+ * arrived. Mutating a synced message without that bump violates the
197
+ * cache-coherence contract shared with the tokenizer and convert caches
198
+ * (see `compaction/message-cache.ts`) and is unsupported.
199
+ */
200
+ #digestMemo = new WeakMap<object, { version: number; digest: number }>();
180
201
 
181
202
  build(context: AgentContext, options: BuildOptions): Context {
182
203
  this.prefix.build(context, options);
@@ -290,6 +311,9 @@ export class AppendOnlyContextManager {
290
311
  #messageDigest(msg: unknown): number {
291
312
  if (!msg || typeof msg !== "object") return 0;
292
313
  const m = msg as Record<string, unknown>;
314
+ const version = messageEstimateVersion(msg as AgentMessage);
315
+ const cached = this.#digestMemo.get(m);
316
+ if (cached !== undefined && cached.version === version) return cached.digest;
293
317
  const payload = JSON.stringify({
294
318
  r: m.role ?? null,
295
319
  c: m.content ?? null,
@@ -304,7 +328,9 @@ export class AppendOnlyContextManager {
304
328
  for (let j = 0; j < payload.length; j++) {
305
329
  hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
306
330
  }
307
- return hash >>> 0;
331
+ const hash32 = hash >>> 0;
332
+ this.#digestMemo.set(m, { version, digest: hash32 });
333
+ return hash32;
308
334
  }
309
335
  }
310
336
 
@@ -9,8 +9,8 @@ import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions
9
9
  import { preferredDialect } from "@linxiraos/pi-catalog/identity";
10
10
  import { prompt } from "@linxiraos/pi-utils";
11
11
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
12
+ import { Tokenizer } from "../tokenizer";
12
13
  import type { AgentMessage } from "../types";
13
- import { estimateTokens } from "./compaction";
14
14
  import type { ReadonlySessionManager, SessionEntry } from "./entries";
15
15
  import {
16
16
  type ConvertToLlm,
@@ -191,7 +191,9 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
191
191
  return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
192
192
 
193
193
  case "compaction":
194
- return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp, entry.shortSummary);
194
+ return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp, {
195
+ shortSummary: entry.shortSummary,
196
+ });
195
197
 
196
198
  // These don't contribute to conversation content
197
199
  case "thinking_level_change":
@@ -206,14 +208,14 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
206
208
  }
207
209
  }
208
210
 
209
- function estimateBranchSummaryTokens(message: AgentMessage): number {
210
- if (message.role !== "toolResult") return estimateTokens(message);
211
+ function estimateBranchSummaryTokens(message: AgentMessage, tokenizer: Tokenizer): number {
212
+ if (message.role !== "toolResult") return tokenizer.countMessage(message);
211
213
  const text = message.content
212
214
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
213
215
  .map(c => c.text)
214
216
  .join("");
215
217
  if (!text) return 0;
216
- return estimateTokens({
218
+ return tokenizer.countMessage({
217
219
  ...message,
218
220
  content: [{ type: "text", text: truncateToolResultForSummary(text) }],
219
221
  });
@@ -232,7 +234,11 @@ function estimateBranchSummaryTokens(message: AgentMessage): number {
232
234
  * @param entries - Entries in chronological order
233
235
  * @param tokenBudget - Maximum tokens to include (0 = no limit)
234
236
  */
235
- export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
237
+ export function prepareBranchEntries(
238
+ entries: SessionEntry[],
239
+ tokenizer: Tokenizer,
240
+ tokenBudget: number = 0,
241
+ ): BranchPreparation {
236
242
  const messages: AgentMessage[] = [];
237
243
  const fileOps = createFileOps();
238
244
  let totalTokens = 0;
@@ -264,7 +270,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
264
270
  // Extract file ops from assistant messages (tool calls)
265
271
  extractFileOpsFromMessage(message, fileOps);
266
272
 
267
- const tokens = estimateBranchSummaryTokens(message);
273
+ const tokens = estimateBranchSummaryTokens(message, tokenizer);
268
274
 
269
275
  // Check budget before adding
270
276
  if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
@@ -309,8 +315,9 @@ export async function generateBranchSummary(
309
315
  // Token budget = context window minus reserved space for prompt + response
310
316
  const contextWindow = model.contextWindow || 128000;
311
317
  const tokenBudget = contextWindow - reserveTokens;
318
+ const tokenizer = new Tokenizer(model);
312
319
 
313
- const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
320
+ const { messages, fileOps } = prepareBranchEntries(entries, tokenizer, tokenBudget);
314
321
 
315
322
  if (messages.length === 0) {
316
323
  return { summary: "No content to summarize" };
@@ -25,6 +25,7 @@ import {
25
25
  } from "@linxiraos/pi-ai/providers/openai-shared";
26
26
  import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
27
27
  import {
28
+ applyCodexResidencyHeader,
28
29
  CODEX_BASE_URL,
29
30
  getCodexAccountId,
30
31
  OPENAI_HEADER_VALUES,
@@ -416,6 +417,7 @@ function buildCompactionV2Headers(
416
417
  if (accountId) {
417
418
  headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
418
419
  }
420
+ applyCodexResidencyHeader(headers, apiKey);
419
421
  if (routingSessionId) {
420
422
  headers[OPENAI_HEADERS.CONVERSATION_ID] = routingSessionId;
421
423
  headers[OPENAI_HEADERS.SESSION_ID] = routingSessionId;