negotium 0.2.15 → 0.2.17

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 (43) hide show
  1. package/dist/agent-helpers.js +220 -32
  2. package/dist/agent-helpers.js.map +12 -12
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/{chunk-5g5a63vv.js → chunk-5cpdbbeh.js} +56 -6
  6. package/dist/{chunk-5g5a63vv.js.map → chunk-5cpdbbeh.js.map} +5 -5
  7. package/dist/hosted-agent.js +119 -9
  8. package/dist/hosted-agent.js.map +8 -8
  9. package/dist/main.js +931 -419
  10. package/dist/main.js.map +25 -24
  11. package/dist/mcp-factories.js +289 -42
  12. package/dist/mcp-factories.js.map +13 -13
  13. package/dist/prompts.js.map +1 -1
  14. package/dist/query-runtime.js.map +1 -1
  15. package/dist/registry.js +3 -3
  16. package/dist/registry.js.map +2 -2
  17. package/dist/rollout.js +1 -1
  18. package/dist/runtime/src/agents/codex-provider.ts +53 -2
  19. package/dist/runtime/src/agents/compaction-support.ts +1 -1
  20. package/dist/runtime/src/agents/maestro-provider.ts +3 -0
  21. package/dist/runtime/src/agents/rollout/codex.ts +62 -0
  22. package/dist/runtime/src/index.ts +1 -0
  23. package/dist/runtime/src/mcp/factories/token-stats.ts +33 -11
  24. package/dist/runtime/src/mcp/runtime-spec.ts +58 -1
  25. package/dist/runtime/src/node-host.ts +1 -0
  26. package/dist/runtime/src/runtime/turn-event-stream.ts +43 -2
  27. package/dist/runtime/src/runtime/turn-runner.ts +20 -9
  28. package/dist/runtime/src/storage/token-stats.ts +200 -19
  29. package/dist/runtime/src/types.ts +6 -0
  30. package/dist/runtime/src/version.ts +1 -1
  31. package/dist/runtime-helpers.js.map +1 -1
  32. package/dist/storage.js +112 -11
  33. package/dist/storage.js.map +4 -4
  34. package/dist/types/packages/core/src/agents/codex-provider.d.ts +13 -0
  35. package/dist/types/packages/core/src/agents/compaction-support.d.ts +1 -0
  36. package/dist/types/packages/core/src/agents/rollout/codex.d.ts +9 -0
  37. package/dist/types/packages/core/src/mcp/factories/token-stats.d.ts +4 -2
  38. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +4 -0
  39. package/dist/types/packages/core/src/storage/token-stats.d.ts +35 -6
  40. package/dist/types/packages/core/src/types.d.ts +6 -0
  41. package/dist/types/packages/core/src/version.d.ts +1 -1
  42. package/dist/vault.js.map +1 -1
  43. package/package.json +2 -2
@@ -28,10 +28,12 @@ import {
28
28
  hostedMcpServers,
29
29
  } from "#agents/execution-host";
30
30
  import {
31
+ type CodexTokenTotals,
31
32
  migrateCodexRolloutNativeMultiAgentMetadata,
32
33
  readCodexPatchCallIds,
33
34
  readLatestCodexContextUsage,
34
35
  readLatestCodexPatchPreview,
36
+ readLatestCodexTokenTotals,
35
37
  } from "#agents/rollout/codex";
36
38
  import { buildNumberedDiffSummary } from "#agents/tool-format";
37
39
  import { extractFileEvents } from "#media/file-events";
@@ -44,6 +46,46 @@ import {
44
46
  } from "#platform/mcp-config";
45
47
  import type { AgentQueryOptions, EffortLevel, UnifiedEvent } from "#types";
46
48
 
49
+ type CodexEventUsage = {
50
+ input_tokens: number;
51
+ output_tokens: number;
52
+ cached_input_tokens?: number;
53
+ cache_write_input_tokens?: number;
54
+ };
55
+
56
+ function sameCodexUsage(usage: CodexEventUsage, total: CodexTokenTotals): boolean {
57
+ return (
58
+ usage.input_tokens === total.inputTokens &&
59
+ usage.output_tokens === total.outputTokens &&
60
+ (usage.cached_input_tokens ?? 0) === total.cachedInputTokens &&
61
+ (usage.cache_write_input_tokens ?? 0) === total.cacheWriteInputTokens
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Some Codex CLI builds expose the thread-lifetime counter on turn.completed
67
+ * despite the SDK declaring turn-local usage. Only subtract a rollout baseline
68
+ * when the event exactly matches the rollout's cumulative counter.
69
+ */
70
+ export function normalizeCodexTurnUsage(
71
+ usage: CodexEventUsage,
72
+ baseline: CodexTokenTotals | undefined,
73
+ rolloutTotal: CodexTokenTotals | undefined,
74
+ ): CodexEventUsage {
75
+ if (!baseline || !rolloutTotal || !sameCodexUsage(usage, rolloutTotal)) return usage;
76
+ const delta = (current: number, previous: number) =>
77
+ current >= previous ? current - previous : current;
78
+ return {
79
+ input_tokens: delta(rolloutTotal.inputTokens, baseline.inputTokens),
80
+ output_tokens: delta(rolloutTotal.outputTokens, baseline.outputTokens),
81
+ cached_input_tokens: delta(rolloutTotal.cachedInputTokens, baseline.cachedInputTokens),
82
+ cache_write_input_tokens: delta(
83
+ rolloutTotal.cacheWriteInputTokens,
84
+ baseline.cacheWriteInputTokens,
85
+ ),
86
+ };
87
+ }
88
+
47
89
  /**
48
90
  * Pass Otium's EffortLevel through to Codex's ModelReasoningEffort.
49
91
  *
@@ -696,6 +738,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
696
738
  };
697
739
 
698
740
  let currentSessionId = opts.sessionId;
741
+ let usageBaseline = currentSessionId ? readLatestCodexTokenTotals(currentSessionId) : undefined;
699
742
 
700
743
  // Codex SDK has no systemPrompt option. A resumed thread may have been
701
744
  // synthesized from a captured shell, so always refresh the current runtime
@@ -782,6 +825,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
782
825
  thread = codex.startThread(threadOptions);
783
826
  prompt = promptForThread(opts, true);
784
827
  currentSessionId = undefined;
828
+ usageBaseline = undefined;
785
829
  startResult = await startStreamedWithTracking(
786
830
  thread,
787
831
  prompt,
@@ -827,6 +871,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
827
871
  input_tokens: number;
828
872
  output_tokens: number;
829
873
  cached_input_tokens?: number;
874
+ cache_write_input_tokens?: number;
830
875
  };
831
876
  error?: { message?: string };
832
877
  };
@@ -931,9 +976,14 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
931
976
  }
932
977
 
933
978
  case "turn.completed": {
934
- const usage = event.usage;
935
- if (!usage) break;
979
+ const rawUsage = event.usage;
980
+ if (!rawUsage) break;
936
981
  attemptCompleted = true;
982
+ const usage = normalizeCodexTurnUsage(
983
+ rawUsage,
984
+ usageBaseline,
985
+ currentSessionId ? readLatestCodexTokenTotals(currentSessionId) : undefined,
986
+ );
937
987
  const contextUsage = currentSessionId
938
988
  ? readLatestCodexContextUsage(currentSessionId)
939
989
  : undefined;
@@ -944,6 +994,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
944
994
  usage: {
945
995
  inputTokens: usage.input_tokens,
946
996
  outputTokens: usage.output_tokens,
997
+ cacheCreationInputTokens: usage.cache_write_input_tokens,
947
998
  cacheReadInputTokens: usage.cached_input_tokens,
948
999
  ...contextUsage,
949
1000
  },
@@ -54,7 +54,7 @@ function isCjkCodePoint(code: number): boolean {
54
54
  );
55
55
  }
56
56
 
57
- function estimateTextTokens(text: string): number {
57
+ export function estimateTextTokens(text: string): number {
58
58
  let cjkChars = 0;
59
59
  let cjkUnits = 0;
60
60
  for (let index = 0; index < text.length; index++) {
@@ -256,6 +256,9 @@ export function maestroProvider(opts: AgentQueryOptions): AsyncGenerator<Unified
256
256
  ...opts,
257
257
  enableToolSearch: !opts.toolPolicy && opts.enableToolSearch !== false,
258
258
  toolResultTruncation: buildMaestroToolResultTruncation(opts),
259
+ // Invocation-only runtime facts stay out of the stable system prompt and
260
+ // the SDK's persisted session projections.
261
+ ephemeralSystemPrompt: opts.ephemeralSystemPrompt,
259
262
  // Resolve this after spreading caller options so untrusted runtime input
260
263
  // cannot replace a topic owner's Vault credentials with another key.
261
264
  apiKeyOverrides: resolveMaestroApiKeyOverrides(userId),
@@ -145,6 +145,13 @@ export interface CodexContextUsage {
145
145
  contextWindow: number;
146
146
  }
147
147
 
148
+ export interface CodexTokenTotals {
149
+ inputTokens: number;
150
+ outputTokens: number;
151
+ cachedInputTokens: number;
152
+ cacheWriteInputTokens: number;
153
+ }
154
+
148
155
  export interface CodexPatchChangePreview {
149
156
  path: string;
150
157
  before?: string;
@@ -389,6 +396,50 @@ export function extractLatestCodexContextUsage(jsonl: string): CodexContextUsage
389
396
  return undefined;
390
397
  }
391
398
 
399
+ /** Latest cumulative token counter persisted in a native Codex rollout. */
400
+ export function extractLatestCodexTokenTotals(jsonl: string): CodexTokenTotals | undefined {
401
+ const lines = jsonl.trimEnd().split("\n");
402
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
403
+ try {
404
+ const entry = JSON.parse(lines[index] ?? "") as {
405
+ type?: string;
406
+ payload?: {
407
+ type?: string;
408
+ info?: {
409
+ total_token_usage?: {
410
+ input_tokens?: number;
411
+ output_tokens?: number;
412
+ cached_input_tokens?: number;
413
+ cache_write_input_tokens?: number;
414
+ };
415
+ };
416
+ };
417
+ };
418
+ if (entry.type !== "event_msg" || entry.payload?.type !== "token_count") continue;
419
+ const usage = entry.payload.info?.total_token_usage;
420
+ if (!usage) continue;
421
+ const values = [
422
+ usage.input_tokens,
423
+ usage.output_tokens,
424
+ usage.cached_input_tokens,
425
+ usage.cache_write_input_tokens,
426
+ ];
427
+ if (values.some((value) => value !== undefined && (!Number.isFinite(value) || value < 0))) {
428
+ continue;
429
+ }
430
+ return {
431
+ inputTokens: usage.input_tokens ?? 0,
432
+ outputTokens: usage.output_tokens ?? 0,
433
+ cachedInputTokens: usage.cached_input_tokens ?? 0,
434
+ cacheWriteInputTokens: usage.cache_write_input_tokens ?? 0,
435
+ };
436
+ } catch {
437
+ // A concurrently appended final line may be incomplete; keep scanning.
438
+ }
439
+ }
440
+ return undefined;
441
+ }
442
+
392
443
  /** Resolve a thread's current rollout and return its latest context measurement. */
393
444
  export function readLatestCodexContextUsage(threadId: string): CodexContextUsage | undefined {
394
445
  const path = latestCodexRolloutPath(threadId);
@@ -401,6 +452,17 @@ export function readLatestCodexContextUsage(threadId: string): CodexContextUsage
401
452
  }
402
453
  }
403
454
 
455
+ export function readLatestCodexTokenTotals(threadId: string): CodexTokenTotals | undefined {
456
+ const path = latestCodexRolloutPath(threadId);
457
+ if (!path) return undefined;
458
+ try {
459
+ return extractLatestCodexTokenTotals(readFileSync(path, "utf8"));
460
+ } catch (error) {
461
+ logger.debug({ error, threadId }, "codex token totals: rollout read failed");
462
+ return undefined;
463
+ }
464
+ }
465
+
404
466
  /**
405
467
  * Old and metadata-free Codex rollouts resume as native multi-agent v1 even
406
468
  * when the feature flag is false. Rewrite their runtime metadata in place so
@@ -11,6 +11,7 @@ import "#platform/maestro-bootstrap-env";
11
11
  // ── Agents ──────────────────────────────────────────────────────────
12
12
  export { checkAgentAuth, checkAgentModelAuth } from "#agents/auth-check";
13
13
  export { killOwnedCodexTreesForShutdown } from "#agents/codex-tree-kill";
14
+ export { estimateTextTokens } from "#agents/compaction-support";
14
15
  export type { ForkHandle } from "#agents/fork";
15
16
  export { cleanupAgentFork, forkAgentSession } from "#agents/fork";
16
17
  export { runAgent, SUPPORTED_AGENTS } from "#agents/index";
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { type Bucket, calcCost, getStats } from "#storage/token-stats";
3
+ import { type Bucket, type CurrentSessionUsage, calcCost, getStats } from "#storage/token-stats";
4
4
 
5
5
  export interface TokenStatsMcpContext {
6
6
  userId: string;
@@ -10,6 +10,8 @@ export interface TokenStatsSnapshot {
10
10
  total: Bucket;
11
11
  byHour: Record<string, Bucket>;
12
12
  bySession: Record<string, Bucket>;
13
+ currentSessions?: CurrentSessionUsage[];
14
+ ignoredLegacyRecords?: number;
13
15
  estimatedCostUsd: number;
14
16
  }
15
17
 
@@ -18,7 +20,11 @@ export interface TokenStatsMcpHost {
18
20
  calcCost(
19
21
  bucket: Pick<
20
22
  Bucket,
21
- "inputTokens" | "outputTokens" | "cacheCreationInputTokens" | "cacheReadInputTokens"
23
+ | "inputTokens"
24
+ | "outputTokens"
25
+ | "cacheCreationInputTokens"
26
+ | "cacheReadInputTokens"
27
+ | "estimatedCostUsd"
22
28
  >,
23
29
  ): number;
24
30
  extraSummaryLines?(userId: string): readonly string[];
@@ -38,7 +44,7 @@ export function createTokenStatsMcpServer(
38
44
  server.tool(
39
45
  "get_usage_stats",
40
46
  [
41
- "Claude 사용량 통계를 조회합니다. 완료된 쿼리마다 세션/시간 정보가 기록됩니다. 현재 실행 중인 turn은 완료 후 집계됩니다.",
47
+ "Claude/Codex/Maestro 사용량과 provider session의 최신 context 점유율을 조회합니다. 현재 실행 중인 turn은 완료 후 집계됩니다.",
42
48
  "",
43
49
  "from/to 생략 시 전체 기간. 자연어 시간 표현을 ISO 형식으로 변환해서 넘겨주세요.",
44
50
  " 예: '지난 3시간' → from = 현재 -3h",
@@ -53,22 +59,38 @@ export function createTokenStatsMcpServer(
53
59
  groupBy: z.enum(["hour", "session"]).optional().describe("집계 기준. 기본값: hour"),
54
60
  },
55
61
  async ({ from, to, groupBy = "hour" }) => {
56
- const { total, byHour, bySession, estimatedCostUsd } = host.getStats(
57
- context.userId,
58
- from,
59
- to,
60
- );
62
+ const snapshot = host.getStats(context.userId, from, to);
63
+ const {
64
+ total,
65
+ byHour,
66
+ bySession,
67
+ currentSessions = [],
68
+ ignoredLegacyRecords = 0,
69
+ estimatedCostUsd,
70
+ } = snapshot;
61
71
  const lines: string[] = [
62
- "📊 Claude 사용량",
72
+ "📊 Agent 사용량",
63
73
  from || to ? `기간: ${from ?? "전체"} ~ ${to ?? "현재"}` : "기간: 전체",
64
74
  "",
65
75
  `쿼리 횟수: ${total.queries.toLocaleString()}회`,
66
- `입력 토큰: ${total.inputTokens.toLocaleString()}`,
76
+ `캐시 미적중 입력: ${total.inputTokens.toLocaleString()}`,
67
77
  `출력 토큰: ${total.outputTokens.toLocaleString()}`,
68
78
  `캐시 쓰기: ${total.cacheCreationInputTokens.toLocaleString()}`,
69
79
  `캐시 읽기: ${total.cacheReadInputTokens.toLocaleString()}`,
70
80
  `예상 비용: $${estimatedCostUsd.toFixed(4)} USD`,
71
81
  ];
82
+ if (ignoredLegacyRecords > 0) {
83
+ lines.push(`구형 레코드 제외: ${ignoredLegacyRecords.toLocaleString()}건`);
84
+ }
85
+ if (currentSessions.length > 0) {
86
+ lines.push("", `📐 현재 context (${currentSessions.length}개 provider session)`);
87
+ for (const current of currentSessions) {
88
+ const percent = (current.contextTokens / current.contextWindow) * 100;
89
+ lines.push(
90
+ ` ${current.topicTitle} · ${current.agent}/${current.model} ${current.contextTokens.toLocaleString()} / ${current.contextWindow.toLocaleString()} (${percent.toFixed(1)}%)`,
91
+ );
92
+ }
93
+ }
72
94
  const extraSummaryLines = host.extraSummaryLines?.(context.userId) ?? [];
73
95
  if (extraSummaryLines.length > 0) lines.push("", ...extraSummaryLines);
74
96
 
@@ -79,7 +101,7 @@ export function createTokenStatsMcpServer(
79
101
  lines.push("", `🗂 세션별 사용량 (${sorted.length}개)`);
80
102
  for (const session of sorted) {
81
103
  lines.push(
82
- ` ${session.name} 쿼리 ${session.queries}회 입력 ${session.inputTokens.toLocaleString()} / 출력 ${session.outputTokens.toLocaleString()} $${session.cost.toFixed(4)}`,
104
+ ` ${session.name} 쿼리 ${session.queries}회 캐시 미적중 입력 ${session.inputTokens.toLocaleString()} / 캐시 읽기 ${session.cacheReadInputTokens.toLocaleString()} / 출력 ${session.outputTokens.toLocaleString()} $${session.cost.toFixed(4)}`,
83
105
  );
84
106
  }
85
107
  } else {
@@ -8,7 +8,7 @@
8
8
  * `mcp/runtime-server.ts` (transport-agnostic parts only).
9
9
  */
10
10
 
11
- import { createHmac, timingSafeEqual } from "node:crypto";
11
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
12
12
  import { NEGOTIUM_PORT, RUNTIME_MCP_SECRET } from "#platform/config";
13
13
  import { type AgentKind, isAgentKind, type PeerRuntimeBridgeContext } from "#types";
14
14
 
@@ -233,9 +233,57 @@ export function buildRuntimeMcpSpec(
233
233
  type: "sse" as const,
234
234
  url: `${base}/sse?${query}`,
235
235
  timeout: CLAUDE_MCP_TOOL_TIMEOUT_MS,
236
+ ...(agent === "maestro" ? { lifecycle: "turn" as const } : {}),
236
237
  };
237
238
  }
238
239
 
240
+ function hostedMcpCacheIdentity(surface: HostedMcpSurface, ctx: HostedMcpContext): string {
241
+ let semanticContext: unknown;
242
+ switch (surface) {
243
+ case "system-health":
244
+ semanticContext = {};
245
+ break;
246
+ case "token-stats":
247
+ case "agent-health":
248
+ semanticContext = { userId: ctx.userId };
249
+ break;
250
+ case "task":
251
+ semanticContext = {
252
+ userId: ctx.userId,
253
+ topicTitle: ctx.topicTitle,
254
+ topicId: ctx.topicId ?? null,
255
+ };
256
+ break;
257
+ case "wiki":
258
+ case "skills":
259
+ semanticContext = {
260
+ userId: ctx.userId,
261
+ topicId: ctx.wikiTopicId ?? ctx.topicId ?? null,
262
+ };
263
+ break;
264
+ case "vault":
265
+ semanticContext = { userId: ctx.userId, cwd: ctx.cwd, agent: ctx.agent };
266
+ break;
267
+ case "session-comm":
268
+ semanticContext = {
269
+ userId: ctx.userId,
270
+ topicTitle: ctx.topicTitle,
271
+ topicId: ctx.topicId ?? null,
272
+ subagentParentTopicId: ctx.subagentParentTopicId ?? null,
273
+ depth: ctx.depth ?? 0,
274
+ silent: ctx.silent ?? false,
275
+ agent: ctx.agent,
276
+ peerBridge: ctx.peerBridge ?? null,
277
+ };
278
+ break;
279
+ }
280
+ const digest = createHash("sha256")
281
+ .update(JSON.stringify([surface, semanticContext]))
282
+ .digest("hex")
283
+ .slice(0, 24);
284
+ return `hosted:${surface}:${digest}`;
285
+ }
286
+
239
287
  /** Build an agent transport spec for one logical MCP surface on the shared runtime process. */
240
288
  export function buildHostedMcpSpec(
241
289
  agent: AgentKind,
@@ -246,9 +294,18 @@ export function buildHostedMcpSpec(
246
294
  const base = `http://127.0.0.1:${runtimePort}${RUNTIME_MCP_BASE_PATH}/${surface}`;
247
295
  const query = `token=${encodeURIComponent(token)}`;
248
296
  if (agent === "codex") return { url: `${base}/mcp?${query}` };
297
+ const queryBound =
298
+ surface === "session-comm" && (ctx.silent === true || ctx.peerBridge !== undefined);
299
+ const lifecycle = queryBound ? "turn" : surface === "session-comm" ? "session" : "process";
249
300
  return {
250
301
  type: "sse" as const,
251
302
  url: `${base}/sse?${query}`,
252
303
  timeout: CLAUDE_MCP_TOOL_TIMEOUT_MS,
304
+ ...(agent === "maestro"
305
+ ? {
306
+ lifecycle,
307
+ cacheKey: hostedMcpCacheIdentity(surface, ctx),
308
+ }
309
+ : {}),
253
310
  };
254
311
  }
@@ -74,6 +74,7 @@ export {
74
74
  listRuntimeEventsAfter,
75
75
  } from "#storage/runtime-events";
76
76
  export { acquireRuntimeProcessLease } from "#storage/runtime-process-leases";
77
+ export { getTopicStats } from "#storage/token-stats";
77
78
  export {
78
79
  getVisibleTopics,
79
80
  isParticipant,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { realpathSync, statSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
+ import { estimateTextTokens } from "#agents/compaction-support";
4
5
  import { cleanupAgentFork } from "#agents/fork";
5
6
  import { scheduleIdleArchiveForTopic } from "#agents/idle-archiver";
6
7
  import { cancelPendingAskUserQuestions } from "#agents/mcp-tools/ask-user";
@@ -69,7 +70,13 @@ import {
69
70
  markRuntimeUserTurnProviderSessionObserved,
70
71
  } from "#storage/runtime-turn-requests";
71
72
  import { recordUsage } from "#storage/token-stats";
72
- import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext, UnifiedEvent } from "#types";
73
+ import type {
74
+ AgentKind,
75
+ EffortLevel,
76
+ PeerRuntimeBridgeContext,
77
+ TokenUsage,
78
+ UnifiedEvent,
79
+ } from "#types";
73
80
  import type { MessageDto } from "#types/api";
74
81
 
75
82
  export type StreamAgentOutcome =
@@ -139,6 +146,9 @@ export async function runTurnEventStream(
139
146
  let pendingSawDelta = false;
140
147
  let accumulatedText = "";
141
148
  let pendingText = "";
149
+ let assistantContextTokens = 0;
150
+ let toolContextTokens = 0;
151
+ let lastContextProgressAt = 0;
142
152
  let lastVisibleMessageId: string | null = null;
143
153
  const visibleMessageIds: string[] = [];
144
154
  let lastTaskPanelText: string | null = null;
@@ -149,6 +159,26 @@ export async function runTurnEventStream(
149
159
  const toolSummaryOptions = {
150
160
  cwd: control.injectParams?.cwd ?? workspaceCwdFor(topicId),
151
161
  };
162
+ const recordEventUsage = (usage: TokenUsage): void => {
163
+ recordUsage(userId, topicTitle, usage, {
164
+ topicId,
165
+ ...(control.sessionId ? { providerSessionId: control.sessionId } : {}),
166
+ agent: agentType,
167
+ model,
168
+ });
169
+ };
170
+ const broadcastContextProgress = (force = false): void => {
171
+ if (silent) return;
172
+ const now = Date.now();
173
+ if (!force && lastContextProgressAt > 0 && now - lastContextProgressAt < 250) return;
174
+ lastContextProgressAt = now;
175
+ hub.broadcastAiStatus(topicId, {
176
+ kind: "context_progress",
177
+ queryId,
178
+ assistantTokens: assistantContextTokens,
179
+ toolTokens: toolContextTokens,
180
+ });
181
+ };
152
182
  const nextSyntheticToolUseId = () => `tool-${queryId}-${++syntheticToolCounter}`;
153
183
  const bindToolUseId = (providerToolUseId?: string, fallback?: string): string => {
154
184
  const providerId = normalizeToolUseId(providerToolUseId);
@@ -301,6 +331,8 @@ export async function runTurnEventStream(
301
331
  const incoming = event.content;
302
332
  accumulatedText += incoming;
303
333
  pendingText += incoming;
334
+ assistantContextTokens = estimateTextTokens(accumulatedText);
335
+ broadcastContextProgress();
304
336
  break;
305
337
  }
306
338
  case "text":
@@ -308,6 +340,8 @@ export async function runTurnEventStream(
308
340
  const incoming = event.content;
309
341
  accumulatedText += incoming;
310
342
  pendingText += incoming;
343
+ assistantContextTokens = estimateTextTokens(accumulatedText);
344
+ broadcastContextProgress();
311
345
  }
312
346
  break;
313
347
  case "tool_use":
@@ -316,6 +350,10 @@ export async function runTurnEventStream(
316
350
  // original assistant → tool → assistant ordering instead of a block
317
351
  // of tools followed by one consolidated answer at turn completion.
318
352
  emitPendingAssistantMessage();
353
+ toolContextTokens += estimateTextTokens(
354
+ `${event.name}\n${JSON.stringify(event.input ?? {})}`,
355
+ );
356
+ broadcastContextProgress(true);
319
357
  // Match both the bare name (Codex reports MCP server/tool separately)
320
358
  // and each provider's public MCP-name form.
321
359
  if (isVisualsShowHtmlTool(event.name)) {
@@ -529,6 +567,8 @@ export async function runTurnEventStream(
529
567
  }
530
568
  break;
531
569
  case "tool_result":
570
+ toolContextTokens += estimateTextTokens(event.content);
571
+ broadcastContextProgress(true);
532
572
  if (isVisualToolResultHandled(event.toolUseId)) break;
533
573
  if (!silent) {
534
574
  hub.broadcastToolOutput(
@@ -578,7 +618,7 @@ export async function runTurnEventStream(
578
618
  }
579
619
  break;
580
620
  case "result":
581
- if (event.usage) recordUsage(userId, topicTitle, event.usage);
621
+ if (event.usage) recordEventUsage(event.usage);
582
622
  {
583
623
  const usage: MessageDto["usage"] = event.usage
584
624
  ? {
@@ -648,6 +688,7 @@ export async function runTurnEventStream(
648
688
 
649
689
  break;
650
690
  case "error":
691
+ if (event.usage) recordEventUsage(event.usage);
651
692
  logger.warn(
652
693
  { topicId, queryId, agentType, model, silent, error: event.content },
653
694
  "ai: provider returned error",
@@ -139,6 +139,17 @@ export function withDefaultPlaywright(configuredMcp: string[], isManager: boolea
139
139
  return [...enabled];
140
140
  }
141
141
 
142
+ export function buildTurnReminderQuery(
143
+ agent: AgentKind,
144
+ prompt: string,
145
+ reminders: string[],
146
+ ): { prompt: string; ephemeralSystemPrompt?: string } {
147
+ const reminderPrompt = reminders.join("\n\n");
148
+ if (!reminderPrompt) return { prompt };
149
+ if (agent === "maestro") return { prompt, ephemeralSystemPrompt: reminderPrompt };
150
+ return { prompt: `${prompt}\n\n${reminderPrompt}` };
151
+ }
152
+
142
153
  // dequeueAll() is the merge primitive; this short gate makes the first reply
143
154
  // to an idle caller wait long enough for sibling ask replies to join it.
144
155
  const askReplyInjectBatcher = new DeferredInjectBatcher({
@@ -1424,8 +1435,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1424
1435
  }
1425
1436
 
1426
1437
  /**
1427
- * Per-turn `<system-reminder>` blocks, appended to the *user* prompt rather
1428
- * than the system prompt.
1438
+ * Per-turn `<system-reminder>` blocks kept out of the stable system prompt.
1429
1439
  *
1430
1440
  * These facts flip between turns on the same model — whether the browser MCP
1431
1441
  * came up, whether this turn is an auto-resume. A prompt cache invalidates
@@ -1433,8 +1443,9 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1433
1443
  * every flip re-reads the entire prompt: measured against the live DeepSeek
1434
1444
  * API, one flip at turn 15 of a 20-turn conversation cost 17,852 uncached
1435
1445
  * tokens (hit=0) versus 1,215 with the reminder on the user turn — 40% more
1436
- * uncached tokens across the run. On the user turn the divergence lands at
1437
- * the tail, where the text is new anyway.
1446
+ * uncached tokens across the run. Claude/Codex receive them at the user prompt
1447
+ * tail. Maestro projects them onto its invocation-start user wire message via
1448
+ * `ephemeralSystemPrompt`, fixed across that invocation's tool iterations.
1438
1449
  *
1439
1450
  * Applied after the conversation log is written, so a reminder never becomes
1440
1451
  * part of the recorded user message (it would then persist into synthesized
@@ -1574,15 +1585,15 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1574
1585
  "<system-reminder>Playwright browser tools are UNAVAILABLE this turn. The `mcp__playwright__*` tools have been removed from this turn's catalog because the long-lived browser MCP could not be prepared. Do not attempt to call browser tools. If browser interaction is required, ask the user to retry shortly or use a non-browser alternative.</system-reminder>",
1575
1586
  );
1576
1587
  }
1577
- // The system prompt is now byte-identical across turns of a topic, so the
1578
- // provider's prefix cache survives; per-turn facts ride the prompt tail.
1579
- const effectivePrompt =
1580
- turnReminders.length > 0 ? `${agentPrompt}\n\n${turnReminders.join("\n\n")}` : agentPrompt;
1588
+ // Build this after conversation recording: transient runtime facts must be
1589
+ // supplied again by the host on a later external turn, never resumed from
1590
+ // Negotium's canonical conversation state.
1591
+ const reminderQuery = buildTurnReminderQuery(agentKind, agentPrompt, turnReminders);
1581
1592
 
1582
1593
  try {
1583
1594
  yield* runAgent({
1584
1595
  agent: agentKind,
1585
- prompt: effectivePrompt,
1596
+ ...reminderQuery,
1586
1597
  attachments: promptAttachments,
1587
1598
  cwd: workspaceCwd,
1588
1599
  systemPrompt,