@tea-agent/loop-agent 0.29.1 → 0.29.3

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 (42) hide show
  1. package/CHANGELOG.md +36 -16
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/commands/client-recovery.js +2 -10
  4. package/dist/commands/init.js +1 -1
  5. package/dist/executors/dag-pi-executor.js +35 -10
  6. package/dist/executors/pi-executor.js +5 -3
  7. package/dist/executors/pi-playwright-cli-tool.js +9 -14
  8. package/dist/executors/pi-sdk-executor.js +16 -0
  9. package/dist/shared/operator/capabilities.js +9 -9
  10. package/dist/shared/pi-retry-settings.js +23 -0
  11. package/dist/worker/console/chat/pi-runtime.js +167 -41
  12. package/dist/worker/console/chat/routes.js +21 -2
  13. package/dist/worker/console/chat/runtime-context.js +50 -8
  14. package/dist/worker/console/chat/tool-preview.js +90 -0
  15. package/dist/worker/console/chat/turn-process.js +178 -0
  16. package/dist/worker/console/chat/usage.js +144 -16
  17. package/dist/worker/console/night-aux-ticker.js +141 -0
  18. package/dist/worker/console/operation-runner.js +21 -4
  19. package/dist/worker/console/operator-user-error.js +12 -0
  20. package/dist/worker/console/recovery-cta.js +6 -6
  21. package/dist/worker/console/routes.js +61 -0
  22. package/dist/worker/console/server.js +7 -0
  23. package/dist/worker/console/static/assets/index-D9gnJn_l.js +29 -0
  24. package/dist/worker/console/static/assets/index-rajoXwkM.css +1 -0
  25. package/dist/worker/console/static/index.html +2 -2
  26. package/dist/worker/console/static-src/night-prepare-result.js +118 -0
  27. package/dist/worker/scheduler/clock-install/win32-schtasks.js +70 -15
  28. package/dist/workflows/dag/backend-test-writer-completeness.js +98 -14
  29. package/dist/workflows/dag/frontend-test-case-checklist.js +3 -3
  30. package/dist/workflows/dag/init-hybrid.js +21 -12
  31. package/dist/workflows/dag/node-execution.js +41 -2
  32. package/docs/templates/frontend-test-case-checklist.md +1 -1
  33. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
  34. package/docs/templates/frontend-test-dag.json +1 -1
  35. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  36. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  37. package/docs/templates/init-managed-agents.md +6 -3
  38. package/package.json +1 -1
  39. package/skills/playwright-cli/SKILL.md +1 -1
  40. package/skills/playwright-cli-case-generator/SKILL.md +1 -1
  41. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +0 -29
  42. package/dist/worker/console/static/assets/index-Yyn3ynVv.css +0 -1
@@ -23,8 +23,9 @@
23
23
  * `createDefaultPiSdkBindings()`.
24
24
  */
25
25
  import { randomBytes } from "node:crypto";
26
- import { projectCompactSnapshot } from "./chat-event-store.js";
26
+ import { access } from "node:fs/promises";
27
27
  import path from "node:path";
28
+ import { projectCompactSnapshot, } from "./chat-event-store.js";
28
29
  import { extractUsageSample } from "./usage.js";
29
30
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
30
31
  import { createOperatorChatResourceLoader, } from "./resource-loader.js";
@@ -46,7 +47,7 @@ import { filterActiveInterviewTools } from "../interview/tools.js";
46
47
  *
47
48
  * Instruction skills are appended AFTER this as read-only context (plan D1).
48
49
  */
49
- const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
50
+ export const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
50
51
  "You are the General Operator Chat for loop-agent / agent-worker.",
51
52
  "You are an OPERATOR first: orchestrate and inspect via operator_* tools, and you also have full Pi repository tools (read, write, edit, bash, grep, find, ls) plus optional safe-read/safe-grep/git-status/git-diff.",
52
53
  "Capability honesty (ADR 0011): read/write/edit/bash ARE available. Prefer governed loop-agent / Agent DAG paths (implement-pi / repair-pi) and Human Gate for large refactors, public contracts, credentials, or production-risk changes — treat direct write/edit/bash as soft-disciplined, not as a second DAG kernel.",
@@ -55,7 +56,18 @@ const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
55
56
  "High-risk mutations still use prepare + browser Human Gate. Prepare contract via interview then apply via contractApply, prepare DAG runs via prepareDagConfirmation, prepare other mutations via prepareMutationGate, then confirm in the browser Human Gate. When a high-risk action (contractApply / runDag / dagRerun / etc.) fails on missing prepared state, diagnose with status/doctor/dagReport/inspect/contractShow/read/safe-read/safe-grep.",
56
57
  "You cannot self-confirm a DAG run: confirmDagConfirmation is a human-only action (executed by the browser with a server-signed confirmation token). You may only prepare it via prepareDagConfirmation; the user must confirm in the UI.",
57
58
  "Prefer read-only diagnosis (status, doctor, dagReport, inspect, contractShow, read, safe-read, safe-grep, git-status) before mutating.",
59
+ "Recovery default order (save tokens/time; do NOT open a new task-id for provider flake): (1) dagReport/doctor/status — read primaryFailure.nodeId + primaryRecovery; (2) for completed failed|partial_failed standalone runs prefer dagRerunPlan then prepareMutationGate/dagRerun from the failed or safe upstream node (plan → planHash → human gate); (3) paused → approve path / resume, never treat resume as failure retry; (4) only when plan is ineligible (writer/decision/fingerprint) or contract/source truly changed: standaloneTaskRerun or same-task task advance — never invent a fresh task-id without cause; (5) Worker-owned failures use workerTaskRetry, not dagRerun. Never use direct write/edit as a substitute for repair-pi.",
58
60
  ].join("\n");
61
+ /** Compose the inspectable system prompt actually injected into Operator Chat. */
62
+ export function composeOperatorChatSystemPrompt(input) {
63
+ return [
64
+ OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
65
+ ...(input.skillFragment?.trim() ? [input.skillFragment.trim()] : []),
66
+ ...(input.systemPromptSuffix?.trim()
67
+ ? [input.systemPromptSuffix.trim()]
68
+ : []),
69
+ ].join("\n\n");
70
+ }
59
71
  /**
60
72
  * Built-in SDK tool names registered for every Chat session (ADR 0011).
61
73
  * Full Pi builtins: read/write/edit/bash/grep/find/ls. safe-* custom tools
@@ -125,9 +137,7 @@ async function buildCustomOperatorTools(options) {
125
137
  default:
126
138
  t = Type.Object({}, { additionalProperties: true });
127
139
  }
128
- acc[param.name] = param.required
129
- ? t
130
- : Type.Optional(t);
140
+ acc[param.name] = param.required ? t : Type.Optional(t);
131
141
  return acc;
132
142
  }, {}), { additionalProperties: false });
133
143
  return defineTool({
@@ -141,7 +151,13 @@ async function buildCustomOperatorTools(options) {
141
151
  if (!decision.ok) {
142
152
  return {
143
153
  content: [
144
- { type: "text", text: JSON.stringify({ error: decision.message, code: decision.code }) },
154
+ {
155
+ type: "text",
156
+ text: JSON.stringify({
157
+ error: decision.message,
158
+ code: decision.code,
159
+ }),
160
+ },
145
161
  ],
146
162
  details: { error: decision.message, code: decision.code },
147
163
  };
@@ -228,7 +244,10 @@ async function buildExploreCustomTools(options) {
228
244
  }),
229
245
  },
230
246
  ],
231
- details: { error: err.message ?? String(e), code: err.code ?? "READ_FAILED" },
247
+ details: {
248
+ error: err.message ?? String(e),
249
+ code: err.code ?? "READ_FAILED",
250
+ },
232
251
  };
233
252
  }
234
253
  },
@@ -266,7 +285,10 @@ async function buildExploreCustomTools(options) {
266
285
  }),
267
286
  },
268
287
  ],
269
- details: { error: err.message ?? String(e), code: err.code ?? "GREP_FAILED" },
288
+ details: {
289
+ error: err.message ?? String(e),
290
+ code: err.code ?? "GREP_FAILED",
291
+ },
270
292
  };
271
293
  }
272
294
  },
@@ -281,7 +303,12 @@ async function buildExploreCustomTools(options) {
281
303
  const r = safeGit(["status", "--porcelain"]);
282
304
  if (!r.ok) {
283
305
  return {
284
- content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
306
+ content: [
307
+ {
308
+ type: "text",
309
+ text: JSON.stringify({ error: r.message, code: r.code }),
310
+ },
311
+ ],
285
312
  details: { error: r.message, code: r.code },
286
313
  };
287
314
  }
@@ -299,7 +326,9 @@ async function buildExploreCustomTools(options) {
299
326
  description: "Typed `git diff` (fixed argv: --stat or a single -- path; no shell). Returns scrubbed diff. path must be repo-relative and not sensitive.",
300
327
  promptSnippet: "git-diff: diff (read-only, scrubbed)",
301
328
  parameters: Type.Object({
302
- path: Type.Optional(Type.String({ description: "optional repo-relative path to diff (default: whole tree --stat)" })),
329
+ path: Type.Optional(Type.String({
330
+ description: "optional repo-relative path to diff (default: whole tree --stat)",
331
+ })),
303
332
  }, { additionalProperties: false }),
304
333
  async execute(_toolCallId, params) {
305
334
  let rawPath = params.path ? String(params.path) : undefined;
@@ -311,20 +340,26 @@ async function buildExploreCustomTools(options) {
311
340
  if (!g.ok) {
312
341
  return {
313
342
  content: [
314
- { type: "text", text: JSON.stringify({ error: g.message, code: g.code }) },
343
+ {
344
+ type: "text",
345
+ text: JSON.stringify({ error: g.message, code: g.code }),
346
+ },
315
347
  ],
316
348
  details: { error: g.message, code: g.code },
317
349
  };
318
350
  }
319
351
  rawPath = g.repoRelative;
320
352
  }
321
- const args = rawPath
322
- ? ["diff", "--", rawPath]
323
- : ["diff", "--stat"];
353
+ const args = rawPath ? ["diff", "--", rawPath] : ["diff", "--stat"];
324
354
  const r = safeGit(args);
325
355
  if (!r.ok) {
326
356
  return {
327
- content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
357
+ content: [
358
+ {
359
+ type: "text",
360
+ text: JSON.stringify({ error: r.message, code: r.code }),
361
+ },
362
+ ],
328
363
  details: { error: r.message, code: r.code },
329
364
  };
330
365
  }
@@ -338,7 +373,15 @@ async function buildExploreCustomTools(options) {
338
373
  });
339
374
  return [safeReadTool, safeGrepTool, gitStatusTool, gitDiffTool];
340
375
  }
341
- export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
376
+ export const THINKING_LEVELS = [
377
+ "off",
378
+ "minimal",
379
+ "low",
380
+ "medium",
381
+ "high",
382
+ "xhigh",
383
+ "max",
384
+ ];
342
385
  /**
343
386
  * Extract the latest assistant text fragment from an SDK message object.
344
387
  * Tolerant of multiple message shapes the SDK emits.
@@ -361,6 +404,25 @@ export function extractAssistantText(message) {
361
404
  }
362
405
  return segments.join("");
363
406
  }
407
+ function readSessionManagerId(manager) {
408
+ if (!manager || typeof manager !== "object")
409
+ return undefined;
410
+ const record = manager;
411
+ if (typeof record.getSessionId === "function") {
412
+ try {
413
+ const value = record.getSessionId();
414
+ if (typeof value === "string" && value.trim())
415
+ return value;
416
+ }
417
+ catch {
418
+ // ignore
419
+ }
420
+ }
421
+ if (typeof record.sessionId === "string" && record.sessionId.trim()) {
422
+ return record.sessionId;
423
+ }
424
+ return undefined;
425
+ }
364
426
  /**
365
427
  * Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
366
428
  * tool enforcement. Session-per-sessionId; cross-request reuse via open().
@@ -377,8 +439,7 @@ export class ConsolePiRuntime {
377
439
  constructor(options) {
378
440
  this.options = options;
379
441
  this.loader = createOperatorChatResourceLoader();
380
- this.bindings =
381
- options.bindings ?? createDefaultPiSdkBindings();
442
+ this.bindings = options.bindings ?? createDefaultPiSdkBindings();
382
443
  }
383
444
  get sessionDir() {
384
445
  return this.options.sessionDir;
@@ -388,8 +449,17 @@ export class ConsolePiRuntime {
388
449
  return safeGetAgentDir(this.bindings);
389
450
  }
390
451
  async testConfiguredModel(model) {
391
- const { services } = await this.bindings.createServices({ cwd: this.options.cwd, agentDir: await this.getAgentDir() });
392
- return { available: Boolean(this.bindings.resolveModel({ modelRuntime: services.modelRuntime, provider: model.provider, modelId: model.modelId })) };
452
+ const { services } = await this.bindings.createServices({
453
+ cwd: this.options.cwd,
454
+ agentDir: await this.getAgentDir(),
455
+ });
456
+ return {
457
+ available: Boolean(this.bindings.resolveModel({
458
+ modelRuntime: services.modelRuntime,
459
+ provider: model.provider,
460
+ modelId: model.modelId,
461
+ })),
462
+ };
393
463
  }
394
464
  setDisabledInstructionSkills(names) {
395
465
  this.disabledInstructionSkills = new Set(names);
@@ -559,9 +629,7 @@ export class ConsolePiRuntime {
559
629
  "coding_chat",
560
630
  "shell",
561
631
  ],
562
- ...(customTools && customTools.length > 0
563
- ? { customTools }
564
- : {}),
632
+ ...(customTools && customTools.length > 0 ? { customTools } : {}),
565
633
  ...(resolvedModel ? { model: resolvedModel } : {}),
566
634
  });
567
635
  // Gate 2: re-pin active tools (defensive — some SDK paths may seed
@@ -589,12 +657,21 @@ export class ConsolePiRuntime {
589
657
  targetSessionDir: this.options.sessionDir,
590
658
  targetSessionId: sessionId,
591
659
  });
592
- const built = await this.buildSessionWithServices({ sessionManager, model: init.model });
660
+ const built = await this.buildSessionWithServices({
661
+ sessionManager,
662
+ model: init.model,
663
+ });
593
664
  built.session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
594
665
  this.sessions.set(sessionId, built.session);
595
666
  this.sessionManagers.set(sessionId, sessionManager);
596
667
  this.modelRuntimes.set(sessionId, built.modelRuntime);
597
- return { sessionId, sessionFile: built.session.sessionFile, createdAt: new Date().toISOString(), model: init.model, activeTools: built.session.getActiveToolNames() };
668
+ return {
669
+ sessionId,
670
+ sessionFile: built.session.sessionFile,
671
+ createdAt: new Date().toISOString(),
672
+ model: init.model,
673
+ activeTools: built.session.getActiveToolNames(),
674
+ };
598
675
  }
599
676
  async branchSession(input) {
600
677
  const session = this.sessions.get(input.sessionId);
@@ -610,7 +687,10 @@ export class ConsolePiRuntime {
610
687
  const entry = input.summary
611
688
  ? manager.branchWithSummary?.(input.branchFromEntryId, input.summary)
612
689
  : (manager.branch(input.branchFromEntryId), undefined);
613
- return { branchedFromEntryId: input.branchFromEntryId, ...(entry?.id ? { branchSummaryEntryId: entry.id } : {}) };
690
+ return {
691
+ branchedFromEntryId: input.branchFromEntryId,
692
+ ...(entry?.id ? { branchSummaryEntryId: entry.id } : {}),
693
+ };
614
694
  }
615
695
  switchToMainline(sessionId) {
616
696
  const manager = this.sessionManagers.get(sessionId);
@@ -623,7 +703,10 @@ export class ConsolePiRuntime {
623
703
  const manager = this.sessionManagers.get(sessionId);
624
704
  if (!manager)
625
705
  throw new Error(`chat session not active: ${sessionId}`);
626
- return { leafId: manager.getLeafId?.() ?? null, entries: manager.getBranch?.() ?? [] };
706
+ return {
707
+ leafId: manager.getLeafId?.() ?? null,
708
+ entries: manager.getBranch?.() ?? [],
709
+ };
627
710
  }
628
711
  async listModels(sessionId) {
629
712
  const modelRuntime = this.modelRuntimes.get(sessionId);
@@ -638,11 +721,18 @@ export class ConsolePiRuntime {
638
721
  throw new Error(`chat session not active: ${sessionId}`);
639
722
  if (!session.setModel)
640
723
  throw new Error("MODEL_SWITCH_UNSUPPORTED");
641
- const resolved = this.bindings.resolveModel({ modelRuntime, provider: model.provider, modelId: model.modelId });
724
+ const resolved = this.bindings.resolveModel({
725
+ modelRuntime,
726
+ provider: model.provider,
727
+ modelId: model.modelId,
728
+ });
642
729
  if (!resolved)
643
730
  throw new Error("MODEL_NOT_AVAILABLE");
644
731
  await session.setModel(resolved);
645
- return { provider: session.model?.provider ?? model.provider, modelId: session.model?.id ?? session.model?.modelId ?? model.modelId };
732
+ return {
733
+ provider: session.model?.provider ?? model.provider,
734
+ modelId: session.model?.id ?? session.model?.modelId ?? model.modelId,
735
+ };
646
736
  }
647
737
  applyThinkingLevel(sessionId, level) {
648
738
  const session = this.sessions.get(sessionId);
@@ -659,7 +749,13 @@ export class ConsolePiRuntime {
659
749
  return { activeTools: [] };
660
750
  const provider = session.model?.provider;
661
751
  const modelId = session.model?.id ?? session.model?.modelId;
662
- return { ...(provider && modelId ? { model: { provider, modelId } } : {}), ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), activeTools: session.getActiveToolNames() };
752
+ return {
753
+ ...(provider && modelId ? { model: { provider, modelId } } : {}),
754
+ ...(session.thinkingLevel
755
+ ? { thinkingLevel: session.thinkingLevel }
756
+ : {}),
757
+ activeTools: session.getActiveToolNames(),
758
+ };
663
759
  }
664
760
  /**
665
761
  * Re-open a previously persisted Chat session after Console restart or
@@ -704,7 +800,15 @@ export class ConsolePiRuntime {
704
800
  return {
705
801
  ok: false,
706
802
  code: "REOPEN_FAILED",
707
- message: `Pi session file not openable: ${init.sessionFile}`,
803
+ message: `Pi session file missing or not openable: ${init.sessionFile}`,
804
+ };
805
+ }
806
+ const managerSessionId = readSessionManagerId(sessionManager);
807
+ if (managerSessionId && managerSessionId !== sessionId) {
808
+ return {
809
+ ok: false,
810
+ code: "REOPEN_FAILED",
811
+ message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${managerSessionId}`,
708
812
  };
709
813
  }
710
814
  try {
@@ -714,10 +818,14 @@ export class ConsolePiRuntime {
714
818
  systemPromptSuffix: init.systemPromptSuffix,
715
819
  });
716
820
  const session = built.session;
717
- // The reopened session MUST report the SAME sessionId as the durable
718
- // record, otherwise we'd be serving a different conversation under a
719
- // stale id. Mismatch is a fail-closed reopen failure.
720
- if (session.sessionId !== sessionId) {
821
+ // Prefer the SessionManager id (from the JSONL header). Some SDK
822
+ // builds expose a different agent-session UUID on `session.sessionId`
823
+ // even when the manager correctly reopened our operator-chat file
824
+ // map by the durable Console sessionId in that case.
825
+ const reportedId = session.sessionId ?? managerSessionId;
826
+ if (reportedId &&
827
+ reportedId !== sessionId &&
828
+ managerSessionId !== sessionId) {
721
829
  try {
722
830
  session.dispose();
723
831
  }
@@ -727,7 +835,7 @@ export class ConsolePiRuntime {
727
835
  return {
728
836
  ok: false,
729
837
  code: "REOPEN_FAILED",
730
- message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${session.sessionId}`,
838
+ message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${reportedId}`,
731
839
  };
732
840
  }
733
841
  session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
@@ -865,6 +973,15 @@ export class ConsolePiRuntime {
865
973
  const mapped = mapSdkEvent(sessionId, event);
866
974
  if (mapped)
867
975
  onEvent(mapped);
976
+ // Pi puts per-assistant-message usage on `message.usage` ({ input, output,
977
+ // cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
978
+ // multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
979
+ // repeats the final message and must not double-count.
980
+ if (event.type === "message_end") {
981
+ const usage = extractUsageSample(event);
982
+ if (usage)
983
+ onEvent({ type: "usage", sessionId, usage });
984
+ }
868
985
  });
869
986
  const onAbort = () => {
870
987
  // M1 T08: Stop must abort only the current turn, NOT dispose the
@@ -956,11 +1073,15 @@ function mapSdkEvent(sessionId, event) {
956
1073
  text: extractAssistantText(event.message),
957
1074
  };
958
1075
  case "message_end":
959
- return { type: "message_end", sessionId, text: extractAssistantText(event.message) };
960
- case "turn_end": {
961
- const usage = extractUsageSample(event);
962
- return usage ? { type: "usage", sessionId, usage } : undefined;
963
- }
1076
+ return {
1077
+ type: "message_end",
1078
+ sessionId,
1079
+ text: extractAssistantText(event.message),
1080
+ };
1081
+ case "turn_end":
1082
+ // Usage is emitted from message_end (see prompt subscribe). turn_end
1083
+ // carries the same final assistant message and would double-count.
1084
+ return undefined;
964
1085
  case "tool_execution_start":
965
1086
  return {
966
1087
  type: "tool_call",
@@ -1085,6 +1206,11 @@ export function createDefaultPiSdkBindings() {
1085
1206
  openSessionManager: async (opts) => {
1086
1207
  const sdk = (await loadSdk());
1087
1208
  try {
1209
+ // Pi SessionManager.open(missingPath) does NOT throw — it creates a
1210
+ // fresh in-memory session with a random UUID. That would reopen the
1211
+ // wrong conversation under the durable Console sessionId. Fail closed
1212
+ // before open when the persisted file is absent.
1213
+ await access(opts.sessionFile);
1088
1214
  return sdk.SessionManager.open(opts.sessionFile);
1089
1215
  }
1090
1216
  catch {
@@ -17,7 +17,7 @@ import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_
17
17
  import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS, OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
18
18
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
19
19
  import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
20
- import { THINKING_LEVELS } from "./pi-runtime.js";
20
+ import { THINKING_LEVELS, composeOperatorChatSystemPrompt, } from "./pi-runtime.js";
21
21
  import { ComposerDraftStore } from "./composer-draft-store.js";
22
22
  import { walkRepoFiles } from "./repo-walk.js";
23
23
  import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
@@ -936,7 +936,26 @@ async function handleRuntimeContext(res, deps, sessionId) {
936
936
  }
937
937
  const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
938
938
  const selection = deps.runtime.getRuntimeSelection(sessionId);
939
- sendJson(res, 200, { ok: true, context: projectRuntimeContext({ systemPrompt: "Operator Chat isolated system prompt", skills: skills.loaded, model: selection.model ?? (record.modelProvider && record.modelId ? { provider: record.modelProvider, modelId: record.modelId } : undefined), thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel, activeTools: selection.activeTools }) });
939
+ const skillFragment = composeInstructionSkillsPrompt(skills.loaded);
940
+ const systemPrompt = composeOperatorChatSystemPrompt({ skillFragment });
941
+ sendJson(res, 200, {
942
+ ok: true,
943
+ context: projectRuntimeContext({
944
+ systemPrompt,
945
+ skills: skills.loaded.map((skill) => ({
946
+ name: skill.name,
947
+ description: skill.description,
948
+ charCount: skill.charCount,
949
+ bodyText: skill.bodyText,
950
+ })),
951
+ model: selection.model ??
952
+ (record.modelProvider && record.modelId
953
+ ? { provider: record.modelProvider, modelId: record.modelId }
954
+ : undefined),
955
+ thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel,
956
+ activeTools: selection.activeTools,
957
+ }),
958
+ });
940
959
  }
941
960
  async function handleModels(req, res, deps, sessionId) {
942
961
  if (!await requireChatSession(res, deps, sessionId))
@@ -1,24 +1,66 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { scrubSecrets } from "./explore-tools.js";
3
- function summary(text, max = 320) {
4
- if (!text)
5
- return undefined;
3
+ const DEFAULT_SUMMARY_MAX = 320;
4
+ /** Hard cap for inspectable full text returned to the browser. */
5
+ export const RUNTIME_CONTEXT_TEXT_MAX = 120_000;
6
+ function redact(text) {
6
7
  return scrubSecrets(text).scrubbed
7
8
  .replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
8
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED]")
9
- .slice(0, max);
9
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED]");
10
+ }
11
+ function summary(text, max = DEFAULT_SUMMARY_MAX) {
12
+ if (!text)
13
+ return undefined;
14
+ const cleaned = redact(text).replace(/\s+/g, " ").trim();
15
+ if (!cleaned)
16
+ return undefined;
17
+ return cleaned.length > max ? `${cleaned.slice(0, max)}…` : cleaned;
18
+ }
19
+ function fullText(text, max = RUNTIME_CONTEXT_TEXT_MAX) {
20
+ if (!text)
21
+ return undefined;
22
+ const cleaned = redact(text);
23
+ if (cleaned.length <= max)
24
+ return cleaned;
25
+ return `${cleaned.slice(0, max)}\n\n…[truncated ${cleaned.length - max} chars for inspect UI]`;
10
26
  }
11
27
  export function projectRuntimeContext(input) {
28
+ const systemText = fullText(input.systemPrompt) ?? "";
29
+ const suffixText = fullText(input.systemPromptSuffix);
12
30
  return {
13
31
  readOnly: true,
14
32
  systemPrompt: {
15
33
  hash: createHash("sha256").update(input.systemPrompt).digest("hex"),
16
34
  summary: summary(input.systemPrompt),
35
+ text: systemText,
36
+ charCount: input.systemPrompt.length,
37
+ },
38
+ skills: input.skills.map((skill) => ({
39
+ name: skill.name,
40
+ description: summary(skill.description, 200) ?? "",
41
+ charCount: skill.charCount,
42
+ ...(skill.bodyText
43
+ ? { body: fullText(skill.bodyText, 40_000) }
44
+ : {}),
45
+ })),
46
+ resources: {
47
+ mode: "closed",
48
+ noContextFiles: true,
49
+ noSkills: true,
50
+ noExtensions: true,
51
+ hasBash: true,
52
+ activeToolCount: input.activeTools.length,
53
+ activeTools: [...input.activeTools],
17
54
  },
18
- skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
19
- resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: true, activeToolCount: input.activeTools.length },
20
55
  model: input.model,
21
56
  thinkingLevel: input.thinkingLevel,
22
- suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
57
+ suffix: input.systemPromptSuffix
58
+ ? {
59
+ present: true,
60
+ summary: summary(input.systemPromptSuffix),
61
+ ...(suffixText ? { text: suffixText } : {}),
62
+ charCount: input.systemPromptSuffix.length,
63
+ }
64
+ : { present: false },
23
65
  };
24
66
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Human-readable one-line preview for tool call arguments (pi-web getToolPreview).
3
+ */
4
+ const PREVIEW_KEYS = [
5
+ "command",
6
+ "path",
7
+ "file_path",
8
+ "filePath",
9
+ "pattern",
10
+ "query",
11
+ "glob",
12
+ "url",
13
+ ];
14
+ const DEFAULT_PREVIEW_MAX = 120;
15
+ const DEFAULT_RESULT_MAX = 800;
16
+ function asRecord(value) {
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ return undefined;
19
+ return value;
20
+ }
21
+ function parseArgs(args) {
22
+ if (args === undefined || args === null)
23
+ return undefined;
24
+ if (typeof args === "string") {
25
+ const trimmed = args.trim();
26
+ if (!trimmed)
27
+ return undefined;
28
+ try {
29
+ return asRecord(JSON.parse(trimmed));
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ return asRecord(args);
36
+ }
37
+ /** One-line summary for a tool-call header; empty when nothing useful. */
38
+ export function getToolPreview(args, options) {
39
+ const maxLen = options?.maxLen ?? DEFAULT_PREVIEW_MAX;
40
+ const input = parseArgs(args);
41
+ if (!input) {
42
+ if (typeof args === "string") {
43
+ const s = args.replace(/\s+/g, " ").trim();
44
+ if (!s || s === "{}")
45
+ return "";
46
+ return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
47
+ }
48
+ return "";
49
+ }
50
+ for (const key of PREVIEW_KEYS) {
51
+ const value = input[key];
52
+ if (typeof value === "string" && value.trim()) {
53
+ const s = value.replace(/\s+/g, " ").trim();
54
+ return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
55
+ }
56
+ }
57
+ const keys = Object.keys(input);
58
+ if (keys.length === 0)
59
+ return "";
60
+ const first = input[keys[0]];
61
+ const s = String(first ?? "").replace(/\s+/g, " ").trim();
62
+ if (!s)
63
+ return "";
64
+ return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
65
+ }
66
+ export function formatToolArgsDisplay(args) {
67
+ if (args === undefined || args === null)
68
+ return "";
69
+ try {
70
+ return typeof args === "string" ? args : JSON.stringify(args, null, 2);
71
+ }
72
+ catch {
73
+ return String(args);
74
+ }
75
+ }
76
+ export function truncateToolResult(text, options) {
77
+ if (text === undefined)
78
+ return { text: "", truncated: false };
79
+ const maxLen = options?.maxLen ?? DEFAULT_RESULT_MAX;
80
+ if (text.length <= maxLen)
81
+ return { text, truncated: false };
82
+ return { text: `${text.slice(0, maxLen)}…`, truncated: true };
83
+ }
84
+ export function toolCallDurationSeconds(createdAt, finishedAt) {
85
+ if (finishedAt === undefined || !Number.isFinite(createdAt) || !Number.isFinite(finishedAt)) {
86
+ return undefined;
87
+ }
88
+ const secs = Math.round((finishedAt - createdAt) / 1000);
89
+ return secs > 0 ? secs : undefined;
90
+ }