sortie-dogs 0.5.17 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.5.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.17)
23
+ Release: [v0.6.1](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.6.1)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -151,7 +151,8 @@ Optional settings in `.opencode/sortie-dogs.json`:
151
151
  "continuation": { "enabled": true, "maxAutoContinues": 10 },
152
152
  "reflection": {
153
153
  "enabled": false,
154
- "layers": { "run": true, "project": true, "global": false }
154
+ "layers": { "run": true, "project": true, "global": false },
155
+ "maxInjectedTokens": 500
155
156
  }
156
157
  }
157
158
  ```
@@ -201,6 +202,10 @@ global file for durable global settings.
201
202
  to enabled after opt-in; the cross-project global storage layer remains
202
203
  disabled unless explicitly enabled. Child and non-coordinator sessions fail
203
204
  closed, and `SORTIE_REFLECTION=0` is an immediate kill switch. The coordinator
205
+ injects the governing `REFLECTION_POLICY` only while reflection is enabled.
206
+ `maxInjectedTokens` budgets the dynamic `SORTIE_PROCESS_REFLECTIONS` heading
207
+ and persisted entry lines; the policy is outside that entry budget.
208
+ The coordinator
204
209
  evaluates it only after a resolved blocker/review defect and at a terminal
205
210
  unit, with a maximum of three records per run; routine bugs and external
206
211
  failures are never journaled.
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.48-recovery-compaction-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.50-observability-reflection-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.48-recovery-compaction-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.50-observability-reflection-v1";
@@ -7,6 +7,7 @@ export interface ReflectionConfiguration {
7
7
  readonly global: boolean;
8
8
  };
9
9
  readonly maxInjectedEntries: number;
10
+ /** Token budget for the dynamic SORTIE_PROCESS_REFLECTIONS heading and persisted entry lines; excludes the governing REFLECTION_POLICY. */
10
11
  readonly maxInjectedTokens: number;
11
12
  }
12
13
  export type ReflectionPolicyInput = Partial<Omit<ReflectionConfiguration, "layers">> & {
@@ -170,10 +170,21 @@ export interface ContinuationHooks {
170
170
  sessionIdle(sessionID: string): Promise<void>;
171
171
  forgetSession(sessionID: string): void;
172
172
  }
173
+ export type ContinuationTransitionType = "continuation.queued" | "continuation.compacted" | "continuation.resumed" | "continuation.not_required";
174
+ export type ContinuationTransitionReason = "continuation-requested" | "compaction-only" | "summarize-accepted" | "prompt-accepted" | "terminal-checkpoint";
175
+ export interface ContinuationTransition {
176
+ readonly type: ContinuationTransitionType;
177
+ readonly sessionID: string;
178
+ readonly epoch: number;
179
+ readonly reason: ContinuationTransitionReason;
180
+ readonly attempts: number;
181
+ readonly resumeAttempts: number;
182
+ }
183
+ export type ContinuationTransitionObserver = (transition: ContinuationTransition) => void;
173
184
  /**
174
185
  * Build the continuation runtime. Every hook fails closed: an unreadable session, an absent client,
175
186
  * or a rejected identity leaves the coordinator exactly where it was, with manual continuation
176
187
  * still available to the user.
177
188
  */
178
- export declare function createContinuationHooks(client: ContinuationClient | undefined, directory: string, policySource: ContinuationPolicySource, timings?: ContinuationTimings, localIdentity?: LocalIdentitySource): ContinuationHooks;
189
+ export declare function createContinuationHooks(client: ContinuationClient | undefined, directory: string, policySource: ContinuationPolicySource, timings?: ContinuationTimings, localIdentity?: LocalIdentitySource, transitionObserver?: ContinuationTransitionObserver): ContinuationHooks;
179
190
  export {};
@@ -214,9 +214,38 @@ function sessionInfo(response) {
214
214
  * or a rejected identity leaves the coordinator exactly where it was, with manual continuation
215
215
  * still available to the user.
216
216
  */
217
- export function createContinuationHooks(client, directory, policySource, timings = DEFAULT_TIMINGS, localIdentity) {
217
+ export function createContinuationHooks(client, directory, policySource, timings = DEFAULT_TIMINGS, localIdentity, transitionObserver) {
218
218
  const sessions = new Map();
219
219
  const warned = new Set();
220
+ function observeTransition(type, sessionID, state, reason) {
221
+ try {
222
+ transitionObserver?.({
223
+ type,
224
+ sessionID,
225
+ epoch: state.rolloverEpoch,
226
+ reason,
227
+ attempts: state.attempts,
228
+ resumeAttempts: state.resumeAttempts,
229
+ });
230
+ }
231
+ catch {
232
+ // Lifecycle telemetry is best effort and must never affect continuation.
233
+ }
234
+ }
235
+ function observeResumed(sessionID, state, epoch) {
236
+ if (state.compactedTransitionEpoch !== epoch || state.resumedTransitionEpoch === epoch)
237
+ return;
238
+ state.resumedTransitionEpoch = epoch;
239
+ observeTransition("continuation.resumed", sessionID, state, "prompt-accepted");
240
+ }
241
+ function observeCompacted(sessionID, state, epoch) {
242
+ if (state.compactedTransitionEpoch === epoch)
243
+ return;
244
+ state.compactedTransitionEpoch = epoch;
245
+ observeTransition("continuation.compacted", sessionID, state, "summarize-accepted");
246
+ if (state.resumeIssuedEpoch === epoch)
247
+ observeResumed(sessionID, state, epoch);
248
+ }
220
249
  /**
221
250
  * A rollover that cannot start is otherwise indistinguishable from a coordinator that never asked
222
251
  * for one, so every abort names its own reason exactly once per session.
@@ -532,6 +561,7 @@ export function createContinuationHooks(client, directory, policySource, timings
532
561
  state.recoverySummaryValidated = false;
533
562
  // The accepted prompt now belongs to the host loop, not this rollover request.
534
563
  state.active = false;
564
+ observeResumed(sessionID, state, epoch);
535
565
  return true;
536
566
  }
537
567
  catch (error) {
@@ -608,6 +638,7 @@ export function createContinuationHooks(client, directory, policySource, timings
608
638
  state.promptPending = false;
609
639
  state.compactedRollover = true;
610
640
  state.lastRollover = Date.now();
641
+ observeCompacted(sessionID, state, operationEpoch);
611
642
  }
612
643
  if (state.resumeIssuingEpoch === operationEpoch ||
613
644
  state.resumeIssuedEpoch === operationEpoch) {
@@ -694,6 +725,7 @@ export function createContinuationHooks(client, directory, policySource, timings
694
725
  if (resume && countAttempt)
695
726
  state.attempts += 1;
696
727
  const epoch = state.rolloverEpoch;
728
+ observeTransition("continuation.queued", sessionID, state, resume ? "continuation-requested" : "compaction-only");
697
729
  // session.idle can be lost when a one-shot CLI host exits. Keep this zero-delay timer referenced
698
730
  // so the rollover reaches the host after the current plugin hook returns but before process exit.
699
731
  setTimeout(async () => {
@@ -835,8 +867,11 @@ export function createContinuationHooks(client, directory, policySource, timings
835
867
  * One-shot CLI hosts can exit as soon as the compaction assistant finishes, before the
836
868
  * compacted event or summarize response. Its text-complete hook is the last awaited boundary
837
869
  * where the summary message already exists and a resume prompt can still join the same loop.
838
- */
870
+ */
839
871
  if (ownedCompactionSummary || trimmed.startsWith(ROLLOVER_TOKEN)) {
872
+ if (ownedCompactionSummary) {
873
+ observeCompacted(input.sessionID, state, state.rolloverEpoch);
874
+ }
840
875
  await arbitrateResume(input.sessionID, state);
841
876
  if (state.resumeIssuedEpoch === state.rolloverEpoch)
842
877
  return;
@@ -850,6 +885,12 @@ export function createContinuationHooks(client, directory, policySource, timings
850
885
  clearTimer(state.stepRecoveryTimer);
851
886
  state.stepRecoveryTimer = undefined;
852
887
  resetRecoveryStall(state);
888
+ if (state.notRequiredRevision !== state.turnRevision &&
889
+ !output.text.includes(ROLLOVER_MARKER) &&
890
+ !output.text.includes(CONTINUATION_MARKER)) {
891
+ state.notRequiredRevision = state.turnRevision;
892
+ observeTransition("continuation.not_required", input.sessionID, state, "terminal-checkpoint");
893
+ }
853
894
  }
854
895
  if (batchCheckpointNeedsContinuation(state.latestCoordinatorReport)) {
855
896
  if (input.allowCheckpointContinuation === false) {
@@ -951,8 +992,11 @@ export function createContinuationHooks(client, directory, policySource, timings
951
992
  },
952
993
  async sessionCompacted(sessionID) {
953
994
  const state = sessions.get(sessionID);
954
- if (state !== undefined)
995
+ if (state?.pendingRollover === true &&
996
+ (state.active || state.promptPending || state.compactingEpoch === state.rolloverEpoch)) {
997
+ observeCompacted(sessionID, state, state.rolloverEpoch);
955
998
  await arbitrateResume(sessionID, state);
999
+ }
956
1000
  },
957
1001
  async compactionAutoContinue(input, output) {
958
1002
  const state = sessions.get(input.sessionID);
@@ -3,12 +3,26 @@ import { type ContinuationClient } from "./continuation.js";
3
3
  import { type ToolExecuteBeforeInput, type ToolExecuteBeforeOutput } from "./gate.js";
4
4
  import { type OpenCodeChatMessageHook, type OpenCodeModelAvailabilityClient } from "./model-routing-hook.js";
5
5
  import { type SessionMessageReader } from "./task-result-repair.js";
6
+ import type { RunMetricsClient } from "./run-metrics.js";
6
7
  export declare const PARALLEL_COMMIT_ARTIFACT_CAPABILITY = "sortie_create_parallel_commit_artifact";
7
8
  export interface OpenCodePluginInput {
8
9
  directory: string;
9
10
  worktree?: string;
10
11
  /** The host SDK client. Absent in hosts that construct the plugin without one. */
11
- client?: SessionMessageReader & ContinuationClient & OpenCodeModelAvailabilityClient & {
12
+ client?: SessionMessageReader & RunMetricsClient & ContinuationClient & OpenCodeModelAvailabilityClient & {
13
+ app?: {
14
+ log?: (request: {
15
+ body: {
16
+ service: string;
17
+ level: "debug" | "info" | "error" | "warn";
18
+ message: string;
19
+ extra?: Record<string, unknown>;
20
+ };
21
+ query?: {
22
+ directory?: string;
23
+ };
24
+ }) => unknown;
25
+ };
12
26
  tui?: {
13
27
  showToast?: (request: {
14
28
  body: {
@@ -17,7 +17,8 @@ import { WriteDeniedError, canonicalManifestReadScopes, canonicalManifestWriteSc
17
17
  import { BACKLOG_DRAIN_CAPABILITY, FastLaneController } from "./fast-lane.js";
18
18
  import { createModelRoutingHook, } from "./model-routing-hook.js";
19
19
  import { createTaskResultRepairHook, lastAssistantText, markConsultationFallbackRetry, taskChildSessionID, } from "./task-result-repair.js";
20
- import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
20
+ import { configRoot, nearestPackageVersion, REFLECTION_POLICY, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
21
+ import { collectRunMetrics, insertRunMetrics, isDoneTerminalText } from "./run-metrics.js";
21
22
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
22
23
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
23
24
  const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -802,6 +803,83 @@ export const SortieDogsPlugin = async (input, options) => {
802
803
  let bootstrapCompleted = false;
803
804
  let assetVersionReported = false;
804
805
  const globalConfig = await readOptionalGlobalConfig();
806
+ const sessionOperationMetrics = new Map();
807
+ function appLogInfo(message, sessionID, extra) {
808
+ const app = input.client?.app;
809
+ const log = app?.log;
810
+ if (log === undefined)
811
+ return;
812
+ try {
813
+ const result = log.call(app, {
814
+ body: {
815
+ service: "sortie-dogs",
816
+ level: "info",
817
+ message,
818
+ extra: { sessionID: sessionID.slice(0, 128), ...extra },
819
+ },
820
+ query: { directory: input.directory },
821
+ });
822
+ void Promise.resolve(result).catch(() => undefined);
823
+ }
824
+ catch {
825
+ // Host lifecycle telemetry is best effort.
826
+ }
827
+ }
828
+ function pruneSessionOperationMetrics(now, reserveSlot = false) {
829
+ for (const [sessionID, metrics] of sessionOperationMetrics) {
830
+ if (metrics.touched + ACTIVE_SESSION_CACHE.ttlMilliseconds <= now) {
831
+ sessionOperationMetrics.delete(sessionID);
832
+ }
833
+ }
834
+ const limit = ACTIVE_SESSION_CACHE.maximum - (reserveSlot ? 1 : 0);
835
+ while (sessionOperationMetrics.size > limit) {
836
+ sessionOperationMetrics.delete(sessionOperationMetrics.keys().next().value);
837
+ }
838
+ }
839
+ function operationMetricsFor(sessionID) {
840
+ const now = Date.now();
841
+ pruneSessionOperationMetrics(now);
842
+ const existing = sessionOperationMetrics.get(sessionID);
843
+ if (existing !== undefined) {
844
+ existing.touched = now;
845
+ sessionOperationMetrics.delete(sessionID);
846
+ sessionOperationMetrics.set(sessionID, existing);
847
+ return existing;
848
+ }
849
+ pruneSessionOperationMetrics(now, true);
850
+ const created = {
851
+ touched: now,
852
+ operations: {
853
+ hostSessionIdentity: { count: 0, elapsedMilliseconds: 0 },
854
+ bootstrapControlState: { count: 0, elapsedMilliseconds: 0 },
855
+ collectRunMetrics: { count: 0, elapsedMilliseconds: 0 },
856
+ },
857
+ };
858
+ sessionOperationMetrics.set(sessionID, created);
859
+ return created;
860
+ }
861
+ async function measureSessionOperation(sessionID, operation, run) {
862
+ const started = performance.now();
863
+ try {
864
+ return await run();
865
+ }
866
+ finally {
867
+ const measurement = operationMetricsFor(sessionID).operations[operation];
868
+ measurement.count += 1;
869
+ measurement.elapsedMilliseconds += Math.max(0, performance.now() - started);
870
+ }
871
+ }
872
+ function operationMetricsSnapshot(sessionID) {
873
+ const operations = sessionOperationMetrics.get(sessionID)?.operations;
874
+ return {
875
+ hostSessionIdentityCount: operations?.hostSessionIdentity.count ?? 0,
876
+ hostSessionIdentityElapsedMilliseconds: Math.round(operations?.hostSessionIdentity.elapsedMilliseconds ?? 0),
877
+ bootstrapControlStateCount: operations?.bootstrapControlState.count ?? 0,
878
+ bootstrapControlStateElapsedMilliseconds: Math.round(operations?.bootstrapControlState.elapsedMilliseconds ?? 0),
879
+ collectRunMetricsCount: operations?.collectRunMetrics.count ?? 0,
880
+ collectRunMetricsElapsedMilliseconds: Math.round(operations?.collectRunMetrics.elapsedMilliseconds ?? 0),
881
+ };
882
+ }
805
883
  // Project config read is required discovery for its opt-in; no reflection storage/version read
806
884
  // occurs unless that resolved config enables reflection. It stays isolated from write-gate load.
807
885
  try {
@@ -843,7 +921,12 @@ export const SortieDogsPlugin = async (input, options) => {
843
921
  */
844
922
  (sessionID) => isCoordinatorSession(sessionID)
845
923
  ? { agent: COORDINATOR_AGENT, parentID: undefined }
846
- : undefined);
924
+ : undefined, (transition) => appLogInfo(transition.type, transition.sessionID, {
925
+ epoch: transition.epoch,
926
+ reason: transition.reason,
927
+ attempts: transition.attempts,
928
+ resumeAttempts: transition.resumeAttempts,
929
+ }));
847
930
  const completedCoordinatorMessages = new Set();
848
931
  const completedCoordinatorParts = new Set();
849
932
  async function ensureLoaded() {
@@ -2360,6 +2443,7 @@ export const SortieDogsPlugin = async (input, options) => {
2360
2443
  }
2361
2444
  function expireSession(sessionID) {
2362
2445
  activeSessions.delete(sessionID);
2446
+ sessionOperationMetrics.delete(sessionID);
2363
2447
  abandonSessionLease(sessionID);
2364
2448
  sessionAuthorizations.delete(sessionID);
2365
2449
  if (!childHasInFlightParentTask(sessionID)) {
@@ -2380,6 +2464,7 @@ export const SortieDogsPlugin = async (input, options) => {
2380
2464
  }
2381
2465
  function evictSession(sessionID) {
2382
2466
  activeSessions.delete(sessionID);
2467
+ sessionOperationMetrics.delete(sessionID);
2383
2468
  abandonSessionLease(sessionID);
2384
2469
  sessionAuthorizations.delete(sessionID);
2385
2470
  bindingPins.delete(sessionID);
@@ -2478,10 +2563,10 @@ export const SortieDogsPlugin = async (input, options) => {
2478
2563
  if (get === undefined)
2479
2564
  return undefined;
2480
2565
  try {
2481
- const response = await get.call(input.client.session, {
2566
+ const response = await measureSessionOperation(sessionID, "hostSessionIdentity", () => get.call(input.client.session, {
2482
2567
  path: { id: sessionID },
2483
2568
  query: { directory: input.directory },
2484
- });
2569
+ }));
2485
2570
  const payload = isRecord(response) && "data" in response ? response.data : response;
2486
2571
  if (!isRecord(payload))
2487
2572
  return undefined;
@@ -2903,6 +2988,16 @@ export const SortieDogsPlugin = async (input, options) => {
2903
2988
  .replaceAll(CONTINUATION_MARKER, "")
2904
2989
  .trimEnd();
2905
2990
  }
2991
+ if ((isCoordinatorSession(textInput.sessionID) || await recoverCoordinatorRoot(textInput.sessionID)) &&
2992
+ isDoneTerminalText(textOutput.text)) {
2993
+ const metrics = await measureSessionOperation(textInput.sessionID, "collectRunMetrics", () => collectRunMetrics(input.client, textInput.sessionID, input.directory).catch(() => undefined));
2994
+ if (metrics !== undefined)
2995
+ textOutput.text = insertRunMetrics(textOutput.text, metrics);
2996
+ appLogInfo("run-metrics.snapshot", textInput.sessionID, {
2997
+ available: metrics !== undefined,
2998
+ ...operationMetricsSnapshot(textInput.sessionID),
2999
+ });
3000
+ }
2906
3001
  await completeContinuationText(textInput.sessionID, textOutput.text, false);
2907
3002
  },
2908
3003
  "experimental.session.compacting": async (compactInput, compactOutput) => {
@@ -3050,22 +3145,33 @@ export const SortieDogsPlugin = async (input, options) => {
3050
3145
  }
3051
3146
  }
3052
3147
  }
3148
+ const heading = "SORTIE_PROCESS_REFLECTIONS";
3149
+ const prefix = `${REFLECTION_POLICY}\n\n${heading}`;
3150
+ if (transformOutput.system !== undefined) {
3151
+ const retained = transformOutput.system.filter((item) => item !== REFLECTION_POLICY && !item.startsWith(`${prefix}\n`));
3152
+ if (retained.length !== transformOutput.system.length)
3153
+ transformOutput.system = retained;
3154
+ }
3053
3155
  if (!reflectionStartup || !(await beginReflection(transformInput.sessionID)))
3054
3156
  return;
3055
3157
  const config = reflectionConfiguration;
3056
3158
  try {
3057
3159
  if (!config)
3058
3160
  return;
3059
- const heading = "SORTIE_PROCESS_REFLECTIONS";
3060
- const buckets = ["run", "project", "global"]
3061
- .filter((layer) => config.layers[layer])
3062
- .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
3063
- const budget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
3064
- const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, budget, reflectionVersion);
3065
- if (text)
3066
- transformOutput.system = [...(transformOutput.system ?? []), `${heading}\n${text}`];
3067
- }
3068
- catch { /* reflection is strictly non-invasive */ }
3161
+ let element = REFLECTION_POLICY;
3162
+ try {
3163
+ const buckets = ["run", "project", "global"]
3164
+ .filter((layer) => config.layers[layer])
3165
+ .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
3166
+ // Historical persisted configs budget the dynamic heading and entry payload, not policy.
3167
+ const entryBudget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
3168
+ const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, entryBudget, reflectionVersion);
3169
+ if (text)
3170
+ element = `${prefix}\n${text}`;
3171
+ }
3172
+ catch { /* persisted entries are best effort; the active policy still applies */ }
3173
+ transformOutput.system = [...(transformOutput.system ?? []), element];
3174
+ }
3069
3175
  finally {
3070
3176
  endReflection(transformInput.sessionID);
3071
3177
  }
@@ -3220,7 +3326,7 @@ export const SortieDogsPlugin = async (input, options) => {
3220
3326
  const bootstrap = bootstrapRequired && !exactCoordinatorDirectOperation &&
3221
3327
  !coordinatorCapability && !sessionGateCapability &&
3222
3328
  !sessionAuthorizations.has(toolInput.sessionID) && (coordinatorRoot || coordinatorRoots.size > 0)
3223
- ? await bootstrapControlState()
3329
+ ? await measureSessionOperation(toolInput.sessionID, "bootstrapControlState", bootstrapControlState)
3224
3330
  : undefined;
3225
3331
  if (coordinatorRoot && bootstrapRequired && !exactCoordinatorDirectOperation &&
3226
3332
  !coordinatorCapability && !sessionGateCapability) {
@@ -3630,7 +3736,7 @@ export const SortieDogsPlugin = async (input, options) => {
3630
3736
  if (event.type === "session.compacted")
3631
3737
  await continuation.sessionCompacted(eventSessionID);
3632
3738
  if (event.type === "session.idle" && isCoordinatorSession(eventSessionID)) {
3633
- const bootstrap = await bootstrapControlState();
3739
+ const bootstrap = await measureSessionOperation(eventSessionID, "bootstrapControlState", bootstrapControlState);
3634
3740
  if (bootstrapRequired && bootstrap?.usable === true && bootstrap.missing.length > 0) {
3635
3741
  if (!bootstrapIdleWarnings.has(eventSessionID)) {
3636
3742
  bootstrapIdleWarnings.add(eventSessionID);
@@ -0,0 +1,40 @@
1
+ export interface RunMetricsClient {
2
+ readonly session?: {
3
+ readonly get?: (request: {
4
+ path: {
5
+ id: string;
6
+ };
7
+ query?: {
8
+ directory?: string;
9
+ };
10
+ }) => Promise<unknown>;
11
+ readonly children?: (request: {
12
+ path: {
13
+ id: string;
14
+ };
15
+ query?: {
16
+ directory?: string;
17
+ };
18
+ }) => Promise<unknown>;
19
+ readonly messages?: (request: {
20
+ path: {
21
+ id: string;
22
+ };
23
+ query?: {
24
+ directory?: string;
25
+ };
26
+ }) => Promise<unknown>;
27
+ };
28
+ }
29
+ export interface RunMetrics {
30
+ readonly durationMilliseconds: number | undefined;
31
+ readonly tokens: number | undefined;
32
+ readonly cost: number | undefined;
33
+ readonly steps: number | undefined;
34
+ readonly sessions: number | undefined;
35
+ readonly cacheRatio: number | undefined;
36
+ }
37
+ export declare function collectRunMetrics(client: RunMetricsClient | undefined, rootSessionID: string, directory?: string, now?: number): Promise<RunMetrics | undefined>;
38
+ export declare function formatRunMetrics(metrics: RunMetrics): string;
39
+ export declare function isDoneTerminalText(text: string): boolean;
40
+ export declare function insertRunMetrics(text: string, metrics: RunMetrics): string;
@@ -0,0 +1,169 @@
1
+ const MAX_SESSIONS = 128;
2
+ function record(value) {
3
+ return value !== null && typeof value === "object" ? value : undefined;
4
+ }
5
+ function unwrap(value) {
6
+ const object = record(value);
7
+ return object !== undefined && "data" in object ? object.data : value;
8
+ }
9
+ function number(value) {
10
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
11
+ }
12
+ function messageTokens(message) {
13
+ const info = record(message.info) ?? message;
14
+ const tokens = record(info.tokens) ?? record(message.tokens);
15
+ if (tokens === undefined)
16
+ return undefined;
17
+ const input = number(tokens.input);
18
+ const output = number(tokens.output);
19
+ const reasoning = number(tokens.reasoning);
20
+ const cache = record(tokens.cache);
21
+ const cacheRead = number(cache?.read) ?? number(tokens.cacheRead) ?? number(tokens.cache_read);
22
+ const cacheWrite = number(cache?.write) ?? number(tokens.cacheWrite) ?? number(tokens.cache_write);
23
+ if (input === undefined || output === undefined || reasoning === undefined || cacheRead === undefined || cacheWrite === undefined)
24
+ return undefined;
25
+ return [input + output + reasoning + cacheRead + cacheWrite, cacheRead];
26
+ }
27
+ function assistantMessages(value) {
28
+ const payload = unwrap(value);
29
+ if (!Array.isArray(payload))
30
+ return undefined;
31
+ return payload.filter((entry) => {
32
+ const item = record(entry);
33
+ const info = item === undefined ? undefined : record(item.info);
34
+ return item !== undefined && (info?.role ?? item.role) === "assistant";
35
+ });
36
+ }
37
+ export async function collectRunMetrics(client, rootSessionID, directory, now = Date.now()) {
38
+ const session = client?.session;
39
+ if (session?.messages === undefined)
40
+ return undefined;
41
+ const ids = [rootSessionID];
42
+ const visited = new Set(ids);
43
+ let hierarchyComplete = session.children !== undefined;
44
+ for (let index = 0; index < ids.length && ids.length < MAX_SESSIONS; index += 1) {
45
+ if (session.children === undefined)
46
+ break;
47
+ try {
48
+ const children = unwrap(await session.children.call(session, { path: { id: ids[index] }, query: { directory } }));
49
+ if (!Array.isArray(children)) {
50
+ hierarchyComplete = false;
51
+ break;
52
+ }
53
+ for (const child of children) {
54
+ const item = record(child);
55
+ const id = typeof item?.id === "string" ? item.id : typeof item?.sessionID === "string" ? item.sessionID : undefined;
56
+ if (id === undefined) {
57
+ hierarchyComplete = false;
58
+ continue;
59
+ }
60
+ if (!visited.has(id) && ids.length < MAX_SESSIONS) {
61
+ visited.add(id);
62
+ ids.push(id);
63
+ }
64
+ else if (!visited.has(id))
65
+ hierarchyComplete = false;
66
+ }
67
+ }
68
+ catch {
69
+ hierarchyComplete = false;
70
+ break;
71
+ }
72
+ }
73
+ if (ids.length >= MAX_SESSIONS)
74
+ hierarchyComplete = false;
75
+ const uniqueMessages = new Set();
76
+ let totalTokens = 0;
77
+ let cacheRead = 0;
78
+ let tokensAvailable = true;
79
+ let messagesComplete = true;
80
+ let steps = 0;
81
+ let cost = 0;
82
+ let costAvailable = true;
83
+ for (const id of ids) {
84
+ try {
85
+ const messages = assistantMessages(await session.messages.call(session, { path: { id }, query: { directory } }));
86
+ if (messages === undefined)
87
+ return undefined;
88
+ for (const message of messages) {
89
+ const info = record(message.info) ?? message;
90
+ const time = record(info.time) ?? record(message.time);
91
+ if (time !== undefined && number(time.completed) === undefined)
92
+ continue;
93
+ const messageID = typeof info.id === "string" ? info.id : typeof message.id === "string" ? message.id : undefined;
94
+ if (messageID === undefined) {
95
+ messagesComplete = false;
96
+ continue;
97
+ }
98
+ if (uniqueMessages.has(messageID))
99
+ continue;
100
+ uniqueMessages.add(messageID);
101
+ steps += 1;
102
+ const tokens = messageTokens(message);
103
+ if (tokens !== undefined) {
104
+ totalTokens += tokens[0];
105
+ cacheRead += tokens[1];
106
+ }
107
+ else
108
+ tokensAvailable = false;
109
+ const reportedCost = number(info.cost) ?? number(message.cost);
110
+ if (reportedCost === undefined)
111
+ costAvailable = false;
112
+ else
113
+ cost += reportedCost;
114
+ }
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ let created;
121
+ if (session.get !== undefined) {
122
+ try {
123
+ const root = record(unwrap(await session.get.call(session, { path: { id: rootSessionID }, query: { directory } })));
124
+ const time = record(root?.time);
125
+ created = number(time?.created);
126
+ }
127
+ catch { /* fallback below */ }
128
+ }
129
+ return {
130
+ durationMilliseconds: created === undefined ? undefined : Math.max(0, now - created),
131
+ tokens: hierarchyComplete && messagesComplete && tokensAvailable ? totalTokens : undefined,
132
+ cost: hierarchyComplete && messagesComplete && costAvailable ? cost : undefined,
133
+ steps: hierarchyComplete && messagesComplete ? steps : undefined,
134
+ sessions: hierarchyComplete ? ids.length : undefined,
135
+ cacheRatio: hierarchyComplete && messagesComplete && tokensAvailable && totalTokens > 0 ? cacheRead / totalTokens : undefined,
136
+ };
137
+ }
138
+ function duration(milliseconds) {
139
+ const seconds = Math.floor(milliseconds / 1000);
140
+ if (seconds < 60)
141
+ return `${seconds}s`;
142
+ const minutes = Math.floor(seconds / 60);
143
+ return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
144
+ }
145
+ export function formatRunMetrics(metrics) {
146
+ const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
147
+ const cost = metrics.cost === undefined ? "cost unavailable" : `$${metrics.cost.toFixed(4)}`;
148
+ const tokens = metrics.tokens === undefined ? "tokens unavailable" : `${metrics.tokens.toLocaleString("en-US")} tokens`;
149
+ const steps = metrics.steps === undefined ? "steps unavailable" : `${metrics.steps} completed assistant model step${metrics.steps === 1 ? "" : "s"}`;
150
+ const sessions = metrics.sessions === undefined ? "sessions unavailable" : `${metrics.sessions} session${metrics.sessions === 1 ? "" : "s"}`;
151
+ const cache = metrics.cacheRatio === undefined ? "cache ratio unavailable" : `${(metrics.cacheRatio * 100).toFixed(1)}% cache ratio`;
152
+ return `**Run:** pre-terminal host snapshot · ${elapsed} · ${tokens} · ${cost} · ${steps} · ${sessions} · ${cache}`;
153
+ }
154
+ export function isDoneTerminalText(text) {
155
+ const first = text.split(/\r?\n/u).find((line) => line.trim().length > 0) ?? "";
156
+ return /^✅\s+\*\*DONE\*\*(?:\s|$)/u.test(first) || /^status:\s*DONE(?:\s|$)/u.test(first);
157
+ }
158
+ export function insertRunMetrics(text, metrics) {
159
+ if (/\*\*Run:\*\*/u.test(text) || !isDoneTerminalText(text))
160
+ return text;
161
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
162
+ const statusStart = text.search(/^(?:✅\s+\*\*DONE\*\*|status:\s*DONE).*$/mu);
163
+ if (statusStart < 0)
164
+ return text;
165
+ const statusEnd = text.indexOf(newline, statusStart);
166
+ if (statusEnd < 0)
167
+ return `${text}${newline}${newline}${formatRunMetrics(metrics)}`;
168
+ return `${text.slice(0, statusEnd + newline.length)}${newline}${formatRunMetrics(metrics)}${newline}${text.slice(statusEnd + newline.length)}`;
169
+ }
@@ -1,3 +1,4 @@
1
1
  export { DEFAULT_REFLECTION, configRoot, nearestPackageVersion, projectKey, reflectionEnabled } from "./config.js";
2
+ export { REFLECTION_POLICY } from "./policy.js";
2
3
  export { ReflectionStore, ReflectionError, estimateInjectionTokens, normalizeField } from "./store.js";
3
4
  export type { ReflectionLayer } from "./config.js";
@@ -1,2 +1,3 @@
1
1
  export { DEFAULT_REFLECTION, configRoot, nearestPackageVersion, projectKey, reflectionEnabled } from "./config.js";
2
+ export { REFLECTION_POLICY } from "./policy.js";
2
3
  export { ReflectionStore, ReflectionError, estimateInjectionTokens, normalizeField } from "./store.js";