switchroom 0.20.11 → 0.20.12

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 (35) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +27 -24
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1608 -1068
  5. package/dist/host-control/main.js +28 -25
  6. package/dist/vault/approvals/kernel-server.js +27 -24
  7. package/dist/vault/broker/server.js +27 -24
  8. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  9. package/package.json +1 -1
  10. package/skills/switchroom-architecture/telegram.md +0 -1
  11. package/skills/switchroom-cli/SKILL.md +0 -1
  12. package/telegram-plugin/README.md +2 -11
  13. package/telegram-plugin/bridge/bridge.ts +0 -12
  14. package/telegram-plugin/chat-lock.ts +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  16. package/telegram-plugin/dist/gateway/gateway.js +54 -63
  17. package/telegram-plugin/dist/server.js +0 -12
  18. package/telegram-plugin/gateway/gateway.ts +24 -59
  19. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  20. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  21. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  22. package/telegram-plugin/gateway/stream-render.ts +4 -4
  23. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  24. package/telegram-plugin/hooks/hooks.json +13 -12
  25. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  27. package/telegram-plugin/status-pin.ts +2 -5
  28. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  29. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  30. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  31. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  32. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  33. package/telegram-plugin/worker-activity-feed.ts +1 -1
  34. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -22036,7 +22036,7 @@ var init_schema = __esm(() => {
22036
22036
  reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
22037
22037
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
22038
22038
  }).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
22039
- env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing; and " + "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS \u2014 the max chars of query text " + "dateparser.search_dates() is handed during temporal analysis (0 = " + "unlimited), made live by switchroom's temporal-offload image patch " + "(which moved the synchronous extraction off the shared asyncio loop onto " + "a single-worker thread and bounds its input, ending multi-second loop " + "stalls on multi-KB consolidation queries); unset means the image's baked " + "2000, set 0 to restore an unbounded full scan), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22039
+ env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing; and " + "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS \u2014 the max chars of query text " + "dateparser.search_dates() is handed during temporal analysis (0 = " + "unlimited), made live by switchroom's temporal-offload image patch " + "(which moved the synchronous extraction off the shared asyncio loop onto " + "a single-worker thread and bounds its input, ending multi-second loop " + "stalls on multi-KB consolidation queries); unset means the image's baked " + "2000, set 0 to restore an unbounded full scan); and " + "HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE \u2014 the Postgres " + "text-search regconfig the native BM25 arms and the search_vector index " + "write run to_tsvector/to_tsquery against; unset means upstream's " + "always-safe `english`, set `hindsight_english` to use switchroom's " + "junk-stopping config (dict + stopword file provisioned durably by the " + "image entrypoint, recall degrading to semantic-only if it is ever " + "missing); plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22040
22040
  });
22041
22041
  MicrosoftWorkspaceConfigSchema = exports_external.object({
22042
22042
  microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
@@ -22489,7 +22489,9 @@ var init_schema = __esm(() => {
22489
22489
  path: exports_external.string().regex(/^[a-zA-Z0-9~._\-/]+$/, "config_repo.path must not contain shell-special characters ($, `, \", ', \\, etc.)").default("~/.switchroom-config").describe("Filesystem path to the operator's private config repo. Must be a git " + "repo (else `switchroom doctor` FAILs). Tilde-expanded at read time."),
22490
22490
  push: exports_external.boolean().default(true).describe("When true, `config-repo sync` pushes after committing (subject to the " + "`require_private` gate). When false, it commits locally only \u2014 useful " + "for offline hosts or a review-before-push workflow."),
22491
22491
  remote: exports_external.string().min(1).default("origin").describe("Git remote name to push to. Default `origin`."),
22492
- require_private: exports_external.boolean().default(true).describe("Refuse to PUSH unless the GitHub API confirms the remote repo is " + "private. On a public remote \u2014 or when the API is unreachable \u2014 the " + "push is skipped (commits still land locally) and a WARN is emitted. " + "Fail-safe against exfiltrating memory files / workspace state to a " + "repo that has been flipped public. Default true.")
22492
+ require_private: exports_external.boolean().default(true).describe("Refuse to PUSH unless the GitHub API confirms the remote repo is " + "private. On a public remote \u2014 or when the API is unreachable \u2014 the " + "push is skipped (commits still land locally) and a WARN is emitted. " + "Fail-safe against exfiltrating memory files / workspace state to a " + "repo that has been flipped public. Default true."),
22493
+ interval_minutes: exports_external.number().int().min(5).max(59).default(30).describe("Cadence (minutes) of the scheduled `config-repo sync` tick installed " + "by `switchroom config-repo --install-cron`. Rendered as a cron " + "`*/N * * * *` minute field, so it must be 5..59 (the 5-min floor " + "matches the rest of the fleet's cron floor). Default 30 (Ken's " + "approved cadence). Changing it takes effect on the next " + "`--install-cron` / `switchroom update` reconcile."),
22494
+ include_vault_backup: exports_external.enum(["off", "daily", "every_tick"]).default("daily").describe("Whether the scheduled tick also runs `switchroom vault backup` " + "(an encrypted vault/memory snapshot into <path>/vault-backups/) " + "before the sync. `off`: sync only. `daily`: an extra once-per-day " + "cron leg runs `vault backup && config-repo sync` (the 30-min legs " + "stay sync-only). `every_tick`: every tick runs the backup first. " + "Default `daily` \u2014 the first automated vault backup on this host.")
22493
22495
  });
22494
22496
  SwitchroomConfigSchema = exports_external.object({
22495
22497
  switchroom: exports_external.object({
@@ -49357,7 +49359,7 @@ function createWorkerActivityFeed(opts) {
49357
49359
  const floodWaitRemainingMs = opts.floodWaitRemainingMs ?? (() => 0);
49358
49360
  const minEditInterval = opts.minEditIntervalMs ?? 2500;
49359
49361
  const elapsedRefreshMs = Math.max(minEditInterval, Math.floor(opts.elapsedRefreshMs ?? 15000));
49360
- const firstPaintMin = opts.firstPaintMinMs ?? 8000;
49362
+ const firstPaintMin = opts.firstPaintMinMs ?? 4000;
49361
49363
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000;
49362
49364
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8));
49363
49365
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60000));
@@ -79336,7 +79338,6 @@ function isStructuralNarration(text4, followedByToolUse) {
79336
79338
  var EPHEMERAL_TOOLS = new Set([
79337
79339
  "react",
79338
79340
  "send_typing",
79339
- "pin_message",
79340
79341
  "delete_message",
79341
79342
  "edit_message"
79342
79343
  ]);
@@ -82650,6 +82651,12 @@ function decideTurnEndGate(snapshot) {
82650
82651
  }
82651
82652
 
82652
82653
  // gateway/turn-record-status.ts
82654
+ function computeTurnDurationMs(startedAt, now) {
82655
+ if (typeof startedAt !== "number" || !Number.isFinite(startedAt) || startedAt <= 0) {
82656
+ return 0;
82657
+ }
82658
+ return Math.max(0, now - startedAt);
82659
+ }
82653
82660
  function backstopSendOutcomeGated(args) {
82654
82661
  if (args.threw)
82655
82662
  return "failed";
@@ -83605,7 +83612,7 @@ function handleSessionEvent(deps, ev) {
83605
83612
  chatId,
83606
83613
  threadId,
83607
83614
  turnId: turn.turnId,
83608
- turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
83615
+ turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
83609
83616
  reactionCount: reactionTransitionCounts.get(tKey) ?? 0
83610
83617
  });
83611
83618
  }
@@ -83623,7 +83630,7 @@ function handleSessionEvent(deps, ev) {
83623
83630
  finalizeStatusReaction(chatId, threadId, "done");
83624
83631
  {
83625
83632
  const sKey = streamKey2(chatId, threadId);
83626
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
83633
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now());
83627
83634
  logStreamingEvent2({
83628
83635
  kind: "turn_end",
83629
83636
  chatId,
@@ -83857,7 +83864,7 @@ function handleSessionEvent(deps, ev) {
83857
83864
  finalizeStatusReaction(chatId, threadId, terminalReason);
83858
83865
  {
83859
83866
  const sKey = streamKey2(chatId, threadId);
83860
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
83867
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now());
83861
83868
  logStreamingEvent2({
83862
83869
  kind: "turn_end",
83863
83870
  chatId,
@@ -87743,6 +87750,27 @@ init_peercred();
87743
87750
  // ../src/agents/scaffold.ts
87744
87751
  init_generation_stamp();
87745
87752
 
87753
+ // ../src/setup/hindsight-recall-passthrough.ts
87754
+ var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
87755
+ var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
87756
+ var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
87757
+ budget: "low",
87758
+ maxTokens: 1024,
87759
+ preferObservations: true,
87760
+ contextTurns: 2,
87761
+ roles: ["user", "assistant"],
87762
+ promptPreamble: HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT,
87763
+ tags: [],
87764
+ tagsMatch: "any",
87765
+ tagGroups: {},
87766
+ tagWeights: HINDSIGHT_RECALL_TAG_WEIGHT_SEED,
87767
+ additionalBankFilters: {},
87768
+ transcriptFallback: true,
87769
+ transcriptTailBytes: 262144,
87770
+ maxQueryChars: 800,
87771
+ parallel: true
87772
+ });
87773
+
87746
87774
  // ../src/setup/hindsight-recall-tunables.ts
87747
87775
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
87748
87776
  var MIN_RECALL_HOOK_TIMEOUT_SECONDS = RECALL_DEADLINE_HEADROOM_SECONDS + 1;
@@ -88018,7 +88046,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
88018
88046
  "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
88019
88047
  "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S",
88020
88048
  "HINDSIGHT_API_TEMPORAL_LANGUAGES",
88021
- "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS"
88049
+ "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS",
88050
+ "HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE"
88022
88051
  ]);
88023
88052
  var HINDSIGHT_WORKER_SLOT_TYPES = [
88024
88053
  "consolidation",
@@ -88440,27 +88469,6 @@ init_vault();
88440
88469
  // ../src/setup/onboarding.ts
88441
88470
  init_paths();
88442
88471
 
88443
- // ../src/setup/hindsight-recall-passthrough.ts
88444
- var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
88445
- var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
88446
- var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
88447
- budget: "low",
88448
- maxTokens: 1024,
88449
- preferObservations: true,
88450
- contextTurns: 2,
88451
- roles: ["user", "assistant"],
88452
- promptPreamble: HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT,
88453
- tags: [],
88454
- tagsMatch: "any",
88455
- tagGroups: {},
88456
- tagWeights: HINDSIGHT_RECALL_TAG_WEIGHT_SEED,
88457
- additionalBankFilters: {},
88458
- transcriptFallback: true,
88459
- transcriptTailBytes: 262144,
88460
- maxQueryChars: 800,
88461
- parallel: true
88462
- });
88463
-
88464
88472
  // ../src/repos/bare-clone.ts
88465
88473
  init_paths();
88466
88474
 
@@ -95142,6 +95150,12 @@ function maybeRotate(path3, fs3, maxBytes = TURNS_JSONL_MAX_BYTES) {
95142
95150
  }
95143
95151
 
95144
95152
  // gateway/turn-record-status.ts
95153
+ function computeTurnDurationMs2(startedAt, now) {
95154
+ if (typeof startedAt !== "number" || !Number.isFinite(startedAt) || startedAt <= 0) {
95155
+ return 0;
95156
+ }
95157
+ return Math.max(0, now - startedAt);
95158
+ }
95145
95159
  function computeTurnStatus(turn) {
95146
95160
  switch (turn.deliveryOutcome) {
95147
95161
  case "failed":
@@ -95170,7 +95184,7 @@ function buildTurnRecord(turn, endedAt) {
95170
95184
  return {
95171
95185
  ts: Math.floor(endedAt / 1000),
95172
95186
  agent: turn.agent,
95173
- duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
95187
+ duration_ms: computeTurnDurationMs2(turn.startedAt, endedAt),
95174
95188
  tools: turn.toolCallCount ?? 0,
95175
95189
  status: computeTurnStatus(turn),
95176
95190
  turn_id: turn.turnId,
@@ -96933,7 +96947,7 @@ function buildSilencePokeOptions(deps) {
96933
96947
  const tearsDownLiveTurn = turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn);
96934
96948
  const turnStartedAt = activeTurnStartedAt.get(fbKey);
96935
96949
  if (turnStartedAt != null) {
96936
- const turnDurationMs = Date.now() - turnStartedAt;
96950
+ const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now());
96937
96951
  const outboundMetrics = getOutboundMetrics2(fbKey);
96938
96952
  emitRuntimeMetric2({
96939
96953
  kind: "turn_ended",
@@ -103323,10 +103337,10 @@ function startOutboxSweep(deps) {
103323
103337
  }
103324
103338
 
103325
103339
  // ../src/build-info.ts
103326
- var VERSION2 = "0.20.11";
103327
- var COMMIT_SHA = "e083a99d";
103328
- var COMMIT_DATE = "2026-08-06T04:35:43Z";
103329
- var LATEST_PR = 4449;
103340
+ var VERSION2 = "0.20.12";
103341
+ var COMMIT_SHA = "298e1e15";
103342
+ var COMMIT_DATE = "2026-08-06T12:14:41Z";
103343
+ var LATEST_PR = 4468;
103330
103344
  var COMMITS_AHEAD_OF_TAG = 0;
103331
103345
 
103332
103346
  // gateway/boot-version.ts
@@ -108773,10 +108787,6 @@ function clearQueuedCard(key, messageId) {
108773
108787
  }
108774
108788
  var bannerPinPersistEnabled = !STATIC;
108775
108789
  var toolPinPersistEnabled = !STATIC;
108776
- var TOOL_PIN_TTL_MS = (() => {
108777
- const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS);
108778
- return Number.isFinite(v) && v > 0 ? v : 604800000;
108779
- })();
108780
108790
  function statusPinApi() {
108781
108791
  return createStatusPinApi(() => lockedBot, robustApiCall);
108782
108792
  }
@@ -110377,7 +110387,6 @@ var ALLOWED_TOOLS = new Set([
110377
110387
  "download_attachment",
110378
110388
  "edit_message",
110379
110389
  "send_typing",
110380
- "pin_message",
110381
110390
  "delete_message",
110382
110391
  "forward_message",
110383
110392
  "get_recent_messages",
@@ -110411,8 +110420,6 @@ async function executeToolCall(tool, args, callerAgentName = null) {
110411
110420
  return executeEditMessage(args);
110412
110421
  case "send_typing":
110413
110422
  return executeSendTyping(args);
110414
- case "pin_message":
110415
- return executePinMessage(args);
110416
110423
  case "delete_message":
110417
110424
  return executeDeleteMessage(args);
110418
110425
  case "forward_message":
@@ -111218,27 +111225,6 @@ async function executeSendTyping(args) {
111218
111225
  }
111219
111226
  return { content: [{ type: "text", text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] };
111220
111227
  }
111221
- async function executePinMessage(args) {
111222
- if (!args.chat_id)
111223
- throw new Error("pin_message: chat_id is required");
111224
- if (!args.message_id)
111225
- throw new Error("pin_message: message_id is required");
111226
- const pinChatId = String(args.chat_id ?? "");
111227
- assertAllowedChat(pinChatId);
111228
- const pinMsgId = Number(args.message_id);
111229
- await robustApiCall(() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), { chat_id: pinChatId, verb: "pin_message" });
111230
- statusPinRightsCache.clear(pinChatId);
111231
- if (toolPinPersistEnabled) {
111232
- const toolPinKey = `tool:${pinChatId}:${pinMsgId}`;
111233
- mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
111234
- pinKey: toolPinKey,
111235
- chatId: pinChatId,
111236
- messageId: pinMsgId,
111237
- expiresAt: Date.now() + TOOL_PIN_TTL_MS
111238
- });
111239
- }
111240
- return { content: [{ type: "text", text: `pinned message ${args.message_id}` }] };
111241
- }
111242
111228
  async function executeDeleteMessage(args) {
111243
111229
  if (!args.chat_id)
111244
111230
  throw new Error("delete_message: chat_id is required");
@@ -117137,6 +117123,10 @@ async function startGateway() {
117137
117123
  const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS);
117138
117124
  return Number.isInteger(raw) && raw > 0 ? raw : undefined;
117139
117125
  })();
117126
+ const workerFeedFirstPaintMs = (() => {
117127
+ const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS);
117128
+ return Number.isInteger(raw) && raw > 0 ? raw : undefined;
117129
+ })();
117140
117130
  const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== "0";
117141
117131
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS);
117142
117132
  if (workerActivityFeed != null) {
@@ -117159,6 +117149,7 @@ async function startGateway() {
117159
117149
  },
117160
117150
  floodWaitRemainingMs: probeFloodWaitRemainingMs,
117161
117151
  maxRows: workerFeedMaxRows,
117152
+ firstPaintMinMs: workerFeedFirstPaintMs,
117162
117153
  staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
117163
117154
  absoluteRowLifetimeCapMs: resolveInflightTerminalCapMs() * WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE,
117164
117155
  groupMessageLifetimeCapMs: WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS,
@@ -24962,18 +24962,6 @@ var init_bridge = __esm(async () => {
24962
24962
  required: ["chat_id"]
24963
24963
  }
24964
24964
  },
24965
- {
24966
- name: "pin_message",
24967
- description: "Pin a message in a Telegram chat. Useful for important outputs the user wants to find later. Requires admin rights in groups.",
24968
- inputSchema: {
24969
- type: "object",
24970
- properties: {
24971
- chat_id: { type: "string" },
24972
- message_id: { type: "string" }
24973
- },
24974
- required: ["chat_id", "message_id"]
24975
- }
24976
- },
24977
24965
  {
24978
24966
  name: "delete_message",
24979
24967
  description: "Delete a message the bot previously sent. Prefer edit_message if you just want to update text \u2014 delete_message is for true removal.",
@@ -8370,8 +8370,7 @@ const statusPinClaims = new Map<string, StatusPinClaim>()
8370
8370
  // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
8371
8371
  // failed with the permanent "not enough rights to manage pinned messages" 400.
8372
8372
  // Per-process only — a restart clears it so a later-granted pin right re-enables
8373
- // auto-pin. The explicit `pin_message` MCP tool deliberately does NOT consult
8374
- // this cache (it always attempts and surfaces the error to the agent).
8373
+ // auto-pin.
8375
8374
  const statusPinRightsCache = new PinRightsCache()
8376
8375
 
8377
8376
  // Durable snapshot of the pin claim set on the persistent per-agent volume
@@ -8466,21 +8465,16 @@ const BANNER_PIN_KEY = 'banner:owner'
8466
8465
  // run → no-op). The boot-cleanup gate below is widened to cover this.
8467
8466
  const bannerPinPersistEnabled = !STATIC
8468
8467
 
8469
- // `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
8470
- // shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
8471
- // with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
8472
- // no "work finished" event, so a restart does NOT reset it boot cleanup
8473
- // keeps unexpired tool rows and only unpins them once the TTL lapses (the
8474
- // backstop against agent-pinned messages accumulating forever). Independent
8475
- // of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
8468
+ // LEGACY `tool:` pin drain (#3001; the `pin_message` MCP tool was retired in
8469
+ // #4452). No new `tool:<chatId>:<messageId>` rows are ever written now that the
8470
+ // tool is gone, but rows an earlier build persisted may still sit in the shared
8471
+ // status-pins.json with a TTL (`expiresAt`). This flag keeps the boot cleanup /
8472
+ // stale-pin sweep aware of those legacy rows so they are still expired and
8473
+ // drained correctly on upgrade an unexpired legacy row is preserved across
8474
+ // boots (never reaped as if it were a work-scoped card), then unpinned once its
8475
+ // TTL lapses, exactly as before. Independent of PIN_STATUS_WHILE_WORKING.
8476
+ // Self-clears within one TTL window since nothing writes new tool rows.
8476
8477
  const toolPinPersistEnabled = !STATIC
8477
- // 7 days: generous — an agent-pinned message the operator still cares about
8478
- // after a week has usually been re-pinned or acted on; anything older is the
8479
- // stale-pin long tail this issue exists to clear. Override for tuning.
8480
- const TOOL_PIN_TTL_MS = (() => {
8481
- const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
8482
- return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
8483
- })()
8484
8478
 
8485
8479
  // Persist (or drop) the slot-banner's pin row into the shared store. Routes
8486
8480
  // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
@@ -8950,8 +8944,7 @@ async function reconcileStatusPin(
8950
8944
  // "not enough rights to manage pinned messages" 400 in a supergroup took the
8951
8945
  // whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
8952
8946
  // must NEVER be able to crash the gateway. Any throw here is logged and
8953
- // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
8954
- // as a normal tool-error — that path is `executePinMessage`, not this one.)
8947
+ // absorbed.
8955
8948
  try {
8956
8949
  // Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
8957
8950
  // in-memory claim map at its top, so overlapping same-key reconciles must
@@ -11808,7 +11801,7 @@ if (isGatewayMain && !STATIC) {
11808
11801
  * bridge from calling arbitrary functions by name. */
11809
11802
  const ALLOWED_TOOLS = new Set([
11810
11803
  'reply', 'progress_update', 'react', 'download_attachment',
11811
- 'edit_message', 'send_typing', 'pin_message', 'delete_message',
11804
+ 'edit_message', 'send_typing', 'delete_message',
11812
11805
  'forward_message', 'get_recent_messages',
11813
11806
  'send_checklist', 'update_checklist',
11814
11807
  'ask_user',
@@ -11844,8 +11837,6 @@ async function executeToolCall(
11844
11837
  return executeEditMessage(args)
11845
11838
  case 'send_typing':
11846
11839
  return executeSendTyping(args)
11847
- case 'pin_message':
11848
- return executePinMessage(args)
11849
11840
  case 'delete_message':
11850
11841
  return executeDeleteMessage(args)
11851
11842
  case 'forward_message':
@@ -13195,44 +13186,6 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
13195
13186
  return { content: [{ type: 'text', text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] }
13196
13187
  }
13197
13188
 
13198
- async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
13199
- if (!args.chat_id) throw new Error('pin_message: chat_id is required')
13200
- if (!args.message_id) throw new Error('pin_message: message_id is required')
13201
- const pinChatId = String(args.chat_id ?? '')
13202
- assertAllowedChat(pinChatId)
13203
- // #1075: wrap through robustApiCall so flood-wait / transient network
13204
- // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
13205
- // agent as a tool-error — pinning a vanished message is genuinely a
13206
- // failure the agent should see.
13207
- const pinMsgId = Number(args.message_id)
13208
- await robustApiCall(
13209
- () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
13210
- { chat_id: pinChatId, verb: 'pin_message' },
13211
- )
13212
- // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
13213
- // this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
13214
- // status-pin path resumes immediately rather than waiting for a restart. The
13215
- // explicit tool itself never consults the cache; a failure above still
13216
- // surfaces to the agent as a normal tool error (robustApiCall rethrows).
13217
- statusPinRightsCache.clear(pinChatId)
13218
- // #3001: register the tool pin in the shared status-pin store under a
13219
- // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13220
- // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
13221
- // NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
13222
- // (likely long-forgotten) message. Best-effort fire-and-forget: a store
13223
- // failure must never fail the tool call the pin already landed for.
13224
- if (toolPinPersistEnabled) {
13225
- const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13226
- void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
13227
- pinKey: toolPinKey,
13228
- chatId: pinChatId,
13229
- messageId: pinMsgId,
13230
- expiresAt: Date.now() + TOOL_PIN_TTL_MS,
13231
- })
13232
- }
13233
- return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
13234
- }
13235
-
13236
13189
  async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
13237
13190
  if (!args.chat_id) throw new Error('delete_message: chat_id is required')
13238
13191
  if (!args.message_id) throw new Error('delete_message: message_id is required')
@@ -23874,6 +23827,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23874
23827
  const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS)
23875
23828
  return Number.isInteger(raw) && raw > 0 ? raw : undefined
23876
23829
  })()
23830
+ // First-paint hold for a prose-silent sub-agent card. Unset /
23831
+ // non-positive → the feed's built-in default (4000ms). Operator
23832
+ // override (SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS) lets first
23833
+ // paint be retuned/reverted (e.g. back to 8000) without a redeploy.
23834
+ const workerFeedFirstPaintMs = (() => {
23835
+ const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS)
23836
+ return Number.isInteger(raw) && raw > 0 ? raw : undefined
23837
+ })()
23877
23838
  // Model A — foreground sub-agent nesting in the parent's live
23878
23839
  // activity draft. ON by default; this edits the SAME activity-
23879
23840
  // summary message the tool_label feed already owns (not the
@@ -23947,6 +23908,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23947
23908
  // channels.telegram.worker_feed.max_rows via the config cascade
23948
23909
  // (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
23949
23910
  maxRows: workerFeedMaxRows,
23911
+ // First-paint hold for a prose-silent worker's initial card.
23912
+ // Unset → the feed's built-in default (4000ms); the operator
23913
+ // override SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS retunes it.
23914
+ firstPaintMinMs: workerFeedFirstPaintMs,
23950
23915
  // Backstop TTL for the feed's stale-row reaper, DERIVED in code
23951
23916
  // from the watcher's effective in-flight terminal cap (same env /
23952
23917
  // default the watcher itself resolves) plus a margin — NOT a
@@ -21,6 +21,7 @@ import * as silencePoke from '../silence-poke.js'
21
21
  import * as signalTracker from '../turn-signal-tracker.js'
22
22
  import * as pendingProgress from '../pending-work-progress.js'
23
23
  import { emitRuntimeMetric } from '../runtime-metrics.js'
24
+ import { computeTurnDurationMs } from './turn-record-status.js'
24
25
  import { logStreamingEvent } from '../streaming-metrics.js'
25
26
  import { clearSilentEndState } from '../silent-end.js'
26
27
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
@@ -377,7 +378,11 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
377
378
  turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
378
379
  const turnStartedAt = activeTurnStartedAt.get(fbKey)
379
380
  if (turnStartedAt != null) {
380
- const turnDurationMs = Date.now() - turnStartedAt
381
+ // Guard shared with buildTurnRecord + the stream-render turn_ended paths:
382
+ // a 0 / bogus start must never emit `Date.now() - 0` (an absolute epoch
383
+ // value) as a duration. This path previously did a bare subtraction and
384
+ // poisoned the turn_ended dataset when `activeTurnStartedAt` held 0.
385
+ const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now())
381
386
  const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
382
387
  emitRuntimeMetric({
383
388
  kind: 'turn_ended',
@@ -352,9 +352,10 @@ export function collectSweepTargets(input: {
352
352
 
353
353
  /**
354
354
  * The messageIds in `chatId` that must SURVIVE a drain because they belong to
355
- * deliberately-retained store rows: unexpired time-scoped `tool:` pins (the
356
- * `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup` intentionally
357
- * KEEPS across restarts. Work-scoped rows (no `expiresAt`) are stale by
355
+ * deliberately-retained store rows: unexpired time-scoped `tool:` pins (legacy
356
+ * rows from the retired `pin_message` MCP tool, #3001 / #4452 — no new ones are
357
+ * written), which `runStatusPinBootCleanup` intentionally KEEPS across restarts
358
+ * until they expire. Work-scoped rows (no `expiresAt`) are stale by
358
359
  * definition after a restart and are NOT re-pin candidates; expired
359
360
  * time-scoped rows are due for sweeping.
360
361
  */
@@ -17,7 +17,8 @@
17
17
  * — the turn it represented is over or crashed), dropping rows only after a
18
18
  * successful unpin (failed ones are retained with an attempt counter for a
19
19
  * next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
20
- * (the `pin_message` MCP tool, #3001) survive boots until their `expiresAt`.
20
+ * (legacy pins from the retired `pin_message` MCP tool, #3001 / #4452 no new
21
+ * ones are written) survive boots until their `expiresAt`.
21
22
  * It does NOT re-adopt or re-pin — it only cleans up.
22
23
  *
23
24
  * Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
@@ -78,10 +79,10 @@ export interface PersistedStatusPin {
78
79
  /** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
79
80
  * Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
80
81
  * moment their owning session dies, so boot cleanup unpins them
81
- * unconditionally. Rows WITH it (the `tool:` pins written by the
82
- * `pin_message` MCP tool, #3001) represent deliberate agent pins that have
83
- * no "work finished" event: they SURVIVE restarts and are only swept once
84
- * expired. */
82
+ * unconditionally. Rows WITH it (legacy `tool:` pins from the retired
83
+ * `pin_message` MCP tool, #3001 / #4452 no new ones are written)
84
+ * represented deliberate agent pins with no "work finished" event: they
85
+ * SURVIVE restarts and are only swept once expired. */
85
86
  expiresAt?: number
86
87
  /** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
87
88
  * unpin fails (flood-wait exhausted / transient 5xx); the row is retained
@@ -282,10 +283,10 @@ export function pinnedMessageIsOurs(
282
283
  * record whose pin MAY have landed in Telegram, so we must treat it exactly
283
284
  * like a confirmed one and unpin it.
284
285
  *
285
- * TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP tool, carrying
286
- * `expiresAt`) have no "work finished" event, so a restart does NOT reset
287
- * them: an unexpired row is RETAINED untouched across boots and only unpinned
288
- * once `now >= expiresAt`.
286
+ * TIME-SCOPED rows (legacy `tool:` pins from the retired `pin_message` MCP
287
+ * tool, #4452, carrying `expiresAt`) have no "work finished" event, so a
288
+ * restart does NOT reset them: an unexpired row is RETAINED untouched across
289
+ * boots and only unpinned once `now >= expiresAt`.
289
290
  *
290
291
  * RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
291
292
  * failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
@@ -90,7 +90,7 @@ import { formatTurnLifecycle } from './status-surface-log.js'
90
90
  import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
91
91
  import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
92
92
  import { decideTurnEndGate } from './turn-end-gate.js'
93
- import { finalizeBackstopSendGated } from './turn-record-status.js'
93
+ import { finalizeBackstopSendGated, computeTurnDurationMs } from './turn-record-status.js'
94
94
  import type { SilentEndDeps } from '../silent-end.js'
95
95
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
96
96
  import type { SessionEvent } from '../session-tail.js'
@@ -2174,7 +2174,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2174
2174
  chatId,
2175
2175
  threadId,
2176
2176
  turnId: turn.turnId,
2177
- turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
2177
+ turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
2178
2178
  reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
2179
2179
  })
2180
2180
  }
@@ -2214,7 +2214,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2214
2214
  // still appear in turn-duration graphs.
2215
2215
  {
2216
2216
  const sKey = streamKey(chatId, threadId)
2217
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2217
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2218
2218
  logStreamingEvent({
2219
2219
  kind: 'turn_end',
2220
2220
  chatId,
@@ -2799,7 +2799,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2799
2799
  finalizeStatusReaction(chatId, threadId, terminalReason)
2800
2800
  {
2801
2801
  const sKey = streamKey(chatId, threadId)
2802
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2802
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2803
2803
  logStreamingEvent({
2804
2804
  kind: 'turn_end',
2805
2805
  chatId,
@@ -24,6 +24,37 @@
24
24
  */
25
25
  export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
26
26
 
27
+ /**
28
+ * The ONE way a turn's `duration_ms` is computed, shared by every emitter
29
+ * (`buildTurnRecord` → turns.jsonl, and all three `turn_ended` runtime-metric
30
+ * sites: the two stream-render turn-end paths + the liveness framework_fallback).
31
+ *
32
+ * Why a single helper: the three `turn_ended` emitters each hand-rolled the
33
+ * subtraction, and one drifted. `stream-render.ts` guarded with
34
+ * `startedAt > 0 ? now - startedAt : 0`; the framework_fallback path in
35
+ * `liveness-wiring.ts` did a bare `Date.now() - turnStartedAt` with only a
36
+ * `!= null` presence check. When `activeTurnStartedAt` held `0` (or any
37
+ * non-positive / non-finite value), that path emitted `duration_ms = now - 0`,
38
+ * i.e. the absolute Unix-epoch-ms — a ~56,000-year "duration". This poisoned the
39
+ * analysed turn_ended dataset (observed: 110 rows where `duration_ms === ts`),
40
+ * making every latency aggregate unusable.
41
+ *
42
+ * The invariant this helper enforces: a `duration_ms` is ALWAYS a non-negative
43
+ * elapsed-milliseconds value derived from a positive, finite start stamp — never
44
+ * an absolute epoch value, never negative (clock step back), never NaN. A start
45
+ * that is missing / zero / bogus yields `0` (a bounded, filterable sentinel)
46
+ * instead of a value that destroys aggregates.
47
+ */
48
+ export function computeTurnDurationMs(
49
+ startedAt: number | null | undefined,
50
+ now: number,
51
+ ): number {
52
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || startedAt <= 0) {
53
+ return 0
54
+ }
55
+ return Math.max(0, now - startedAt)
56
+ }
57
+
27
58
  /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
59
  export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
60
 
@@ -261,7 +292,7 @@ export function buildTurnRecord(
261
292
  return {
262
293
  ts: Math.floor(endedAt / 1000),
263
294
  agent: turn.agent,
264
- duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
295
+ duration_ms: computeTurnDurationMs(turn.startedAt, endedAt),
265
296
  tools: turn.toolCallCount ?? 0,
266
297
  status: computeTurnStatus(turn),
267
298
  turn_id: turn.turnId,