mixdog 0.9.18 → 0.9.20

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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,401 @@
1
+ // Session ingest runtime extracted from index.mjs.
2
+ //
3
+ // The stateful ingest cluster: per-session serialization chains, the WeakMap
4
+ // identity-fields cache, the post-ingest raw-embedding flush chain, and the
5
+ // ingest_session entrypoint. Pure helpers (role normalization, source-ref
6
+ // hashing, turn allocation, text cleaning) and flushRawEmbeddings are imported
7
+ // directly; the live DB handle, log sink, and parseTsToMs (owned by the
8
+ // transcript-ingest instance) are injected so this module holds only the
9
+ // ingest-local chains/caches and the facade keeps ownership of `db`.
10
+
11
+ import {
12
+ normalizeIngestRole,
13
+ stableSessionSourceRef,
14
+ createIngestTurnAllocator,
15
+ sessionMessageContentForIngest,
16
+ shouldExcludeIngestMessage,
17
+ } from './session-ingest.mjs'
18
+ import { cleanMemoryText } from './memory.mjs'
19
+ import { flushRawEmbeddings } from './memory-cycle.mjs'
20
+ import { resolveProjectScope } from './project-id-resolver.mjs'
21
+ import crypto from 'node:crypto'
22
+
23
+ // Ingest now WAITS for its embedding flush (bounded) so freshly ingested rows
24
+ // are dense-searchable the moment ingest_session returns. On a cold ONNX
25
+ // model the flush can stall on warmup; the race below caps the wait and lets
26
+ // the flush finish in the background (tick fallback still sweeps stragglers).
27
+ const INGEST_EMBED_WAIT_MS = 15_000
28
+
29
+ // Short, content-derived key for the durable untimestamped high-water map so the
30
+ // persisted blob stores a fixed-width hash per duplicate identity rather than raw
31
+ // (possibly large) message content.
32
+ function _identityHash(occKey) {
33
+ return crypto.createHash('sha256').update(occKey).digest('hex').slice(0, 24)
34
+ }
35
+
36
+ export function createSessionIngestRuntime({
37
+ getDb,
38
+ log,
39
+ parseTsToMs,
40
+ // Durable per-session untimestamped high-water store (reviewer item 1). Injected
41
+ // by the facade (DB `meta` kv). Defaults to no-op so the runtime still works
42
+ // (in-memory only) when a caller does not wire durability.
43
+ loadOrdinalHighWater = async () => null,
44
+ saveOrdinalHighWater = async () => {},
45
+ }) {
46
+ // Post-ingest raw-embedding flush chain (facade-local). The checkCycles tick
47
+ // keeps its OWN independent guard inside the scheduler; this chain serializes
48
+ // flushes kicked from ingestSessionMessages so ingest bursts never stack
49
+ // embedding work, while still guaranteeing every ingest's rows get their own
50
+ // flush pass (the old boolean guard silently SKIPPED bursts, leaving rows
51
+ // embedding-less until the ~60s tick — a recall (no results) window).
52
+ let _rawEmbedFlushChain = Promise.resolve()
53
+ // Per-session ingest serialization. Concurrent ingest_session calls for the
54
+ // SAME session raced on MAX(source_turn) → duplicate turn allocation. Chain
55
+ // same-session ingests so the MAX read + insert loop for one session never
56
+ // overlaps another for the same session. Different sessions stay parallel.
57
+ const _ingestSessionChains = new Map()
58
+ // ── Untimestamped-repeat ordinal state (per session) ──────────────────────
59
+ // `occNext[identity]` = next FREE occurrence ordinal assigned for a
60
+ // (role,content) identity in this session. It disambiguates textually
61
+ // identical UNTIMESTAMPED turns (a timestamped turn carries a durable ts and
62
+ // never needs it). Monotonic and compaction-independent so a genuine appended
63
+ // repeat lands ABOVE every already-persisted copy rather than colliding with
64
+ // one under ON CONFLICT DO NOTHING.
65
+ //
66
+ // In-memory only, LRU-bounded (_touchOrdinalState) so it cannot grow without
67
+ // bound across session ids. Correctness does NOT depend on it surviving
68
+ // eviction or a process restart: on a miss the high-water is rebuilt from the
69
+ // WeakMap of already-assigned ordinals for the messages STILL in the array
70
+ // (survivors advance occNext, so a following genuine append still lands above
71
+ // them — this is what makes LRU eviction safe mid-session), and any truly-cold
72
+ // walk falls back to POSITIONAL ordinals, which reproduce a fresh full/subset
73
+ // re-ingest exactly and therefore never mint a duplicate row. See the
74
+ // DURABILITY note at the assignment site for why a persisted high-water cannot
75
+ // recover more than this for indistinguishable untimestamped identicals.
76
+ const _sessionIngestOrdinalState = new Map()
77
+ const _ORDINAL_STATE_MAX_SESSIONS = 2048
78
+ // Touch (LRU) + create-on-miss. Re-inserting on a hit moves the entry to the
79
+ // Map tail; when the cap is exceeded the oldest (head) session is evicted.
80
+ // Eviction is safe because the state is reconstructible (see above).
81
+ function _touchOrdinalState(sessionId) {
82
+ const existing = _sessionIngestOrdinalState.get(sessionId)
83
+ if (existing) {
84
+ _sessionIngestOrdinalState.delete(sessionId)
85
+ _sessionIngestOrdinalState.set(sessionId, existing)
86
+ return existing
87
+ }
88
+ const st = { occNext: new Map(), seeded: false }
89
+ _sessionIngestOrdinalState.set(sessionId, st)
90
+ while (_sessionIngestOrdinalState.size > _ORDINAL_STATE_MAX_SESSIONS) {
91
+ const oldest = _sessionIngestOrdinalState.keys().next().value
92
+ if (oldest === undefined) break
93
+ _sessionIngestOrdinalState.delete(oldest)
94
+ }
95
+ return st
96
+ }
97
+ // Ordinal assigned to a given session-message OBJECT at first sight, reused on
98
+ // every later hydrate so its source_ref stays stable and ON CONFLICT dedupes
99
+ // it. This is the survivor-vs-new-append signal — VERIFIED valid in
100
+ // production: ingest_session runs IN-PROCESS (runtime-core executor →
101
+ // memoryMod.handleToolCall(args), session-runtime/runtime-core.mjs), NOT over
102
+ // an HTTP/JSON boundary, so the SAME message objects arrive across calls for a
103
+ // live session. WeakMap: entries vanish when a message is GC'd (compaction
104
+ // drops it from the array). Across a process restart (or LRU eviction) the
105
+ // signal is gone; the walk then falls back to positional ordinals.
106
+ const _ingestedMessageOrdinal = new WeakMap()
107
+ // Assign/reuse the occurrence ordinal for message `m` under identity `occKey`.
108
+ // A re-presented object reuses its recorded ordinal AND advances the session
109
+ // high-water past it (rebuilding occNext from survivors after an eviction);
110
+ // a first-seen object consumes the next free ordinal and records it. `floor`
111
+ // (>0 only for a WARM first-seen untimestamped turn) lifts a genuine
112
+ // post-restart/eviction append above the durable persisted high-water even when
113
+ // the cold replay only counted the survivors currently in the array.
114
+ function _assignOccurrence(occNext, occKey, m, floor = 0) {
115
+ if (_ingestedMessageOrdinal.has(m)) {
116
+ const ord = _ingestedMessageOrdinal.get(m)
117
+ if (ord + 1 > (occNext.get(occKey) ?? 0)) occNext.set(occKey, ord + 1)
118
+ return ord
119
+ }
120
+ let ord = occNext.get(occKey) ?? 0
121
+ if (floor > ord) ord = floor
122
+ occNext.set(occKey, ord + 1)
123
+ _ingestedMessageOrdinal.set(m, ord)
124
+ return ord
125
+ }
126
+ // Cache of (role, cleaned content) per session message OBJECT, keyed by
127
+ // identity (WeakMap — entries vanish once the message is GC'd, e.g. after
128
+ // compaction drops the array). The subset-reingest ordinal fix below must
129
+ // replay cleanMemoryText/shouldExcludeIngestMessage over messages[0, start)
130
+ // on every call; without caching that turned an O(limit)-bounded ingest into
131
+ // an O(full transcript) regex pass on every hydrate. Message objects are
132
+ // immutable transcript entries reused by reference across calls (recall-
133
+ // fasttrack re-ingests the same in-memory array as the session grows), so
134
+ // once a message's identity fields are computed they never change — caching
135
+ // makes every call after the first pay only for genuinely NEW messages,
136
+ // restoring the limit-bounded cost in steady state.
137
+ const _ingestIdentityFieldsCache = new WeakMap()
138
+ // Return { role, content } for a session message's dedup identity, or null if
139
+ // the message would be skipped by ingest (no role / excluded / empty content
140
+ // after cleaning). Cached per message object so repeated calls over the same
141
+ // (unchanged) transcript prefix never re-run the expensive clean/normalize
142
+ // regex pipeline.
143
+ function _ingestIdentityFields(m) {
144
+ if (_ingestIdentityFieldsCache.has(m)) return _ingestIdentityFieldsCache.get(m)
145
+ let fields = null
146
+ const role = normalizeIngestRole(m.role)
147
+ if (role && !shouldExcludeIngestMessage(m)) {
148
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
149
+ if (content && content.trim()) fields = { role, content }
150
+ }
151
+ _ingestIdentityFieldsCache.set(m, fields)
152
+ return fields
153
+ }
154
+
155
+ async function ingestSessionMessages(args = {}) {
156
+ const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
157
+ // Serialize per-session so the MAX(source_turn) read + insert loop for one
158
+ // session never overlaps a concurrent ingest for the SAME session (they
159
+ // otherwise race the turn allocator and double-allocate). Distinct sessions
160
+ // run in parallel.
161
+ const prev = _ingestSessionChains.get(sessionId) ?? Promise.resolve()
162
+ const run = prev.catch(() => {}).then(() => _ingestSessionMessagesImpl(sessionId, args))
163
+ _ingestSessionChains.set(sessionId, run.catch(() => {}))
164
+ const tail = _ingestSessionChains.get(sessionId)
165
+ try {
166
+ return await run
167
+ } finally {
168
+ // Best-effort GC: drop the map entry only if no later call chained after us.
169
+ if (_ingestSessionChains.get(sessionId) === tail) _ingestSessionChains.delete(sessionId)
170
+ }
171
+ }
172
+
173
+ async function _ingestSessionMessagesImpl(sessionId, args = {}) {
174
+ const db = getDb()
175
+ const messages = Array.isArray(args.messages) ? args.messages : []
176
+ // Recall fast-track hydrates the current session before compaction; allow
177
+ // callers to ingest the full in-memory transcript instead of silently
178
+ // clipping long sessions at 500 turns. Default remains conservative.
179
+ const limit = Math.max(1, Math.min(5000, Number(args.limit) || 200))
180
+ const start = Math.max(0, messages.length - limit)
181
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
182
+ let considered = 0
183
+ let inserted = 0
184
+ // Ids of rows THIS call actually inserted (ON CONFLICT skips return no id).
185
+ // The post-ingest flush is scoped to exactly these so ingest_session never
186
+ // synchronously inherits other calls'/sessions' raw-embedding backlog.
187
+ const insertedIds = []
188
+ // Monotonic ingest order, independent of the current (post-compaction)
189
+ // array index. source_turn used to be `i+1`, but after compaction shrinks /
190
+ // reindexes session.messages a NEWLY appended turn gets a LOW i and thus a
191
+ // LOW source_turn — and since dump_session_roots / recall order by
192
+ // source_turn first, it would sort BEFORE older pre-compaction rows. Seed a
193
+ // running counter from the current max source_turn for this session so every
194
+ // new row is assigned a turn strictly greater than all previously-ingested
195
+ // ones (true continuation order). Re-ingested (ON CONFLICT) rows keep their
196
+ // original turn and do not consume a new one.
197
+ let prevMaxTurn = 0
198
+ try {
199
+ const maxRow = await db.query(
200
+ `SELECT COALESCE(MAX(source_turn), 0) AS max_turn FROM entries WHERE session_id = $1`,
201
+ [sessionId],
202
+ )
203
+ prevMaxTurn = Number(maxRow.rows?.[0]?.max_turn) || 0
204
+ } catch { prevMaxTurn = 0 }
205
+ const turnAllocator = createIngestTurnAllocator(prevMaxTurn)
206
+ // ── Untimestamped-repeat ordinal: re-ingest vs genuine-append distinction ─
207
+ // The ordinal folded into stableSessionSourceRef must satisfy BOTH: a
208
+ // full/subset re-ingest of the SAME messages reproduces the exact ordinal →
209
+ // same source_ref → ON CONFLICT dedupes; and a genuinely NEW untimestamped
210
+ // turn appended after compaction dropped an earlier identical copy lands on a
211
+ // FREE ordinal above every persisted copy (else it reproduces a persisted
212
+ // ordinal and is silently dropped).
213
+ //
214
+ // Rule (see module-level notes on _assignOccurrence): a re-presented message
215
+ // (same object) reuses its recorded ordinal AND advances the session
216
+ // high-water; a first-seen turn takes the next free high-water ordinal, so an
217
+ // appended identical turn lands above every persisted copy (invariant 1). On
218
+ // a fresh/evicted/restarted state the prefix [0,start) is replayed ONCE to
219
+ // rebuild occNext (positional + any surviving recorded ordinals) so a SUBSET
220
+ // re-ingest reproduces the refs a full re-ingest would (invariants 2 & 3); a
221
+ // warm state skips the replay, keeping steady-state ingest O(new messages)
222
+ // (invariant 4).
223
+ //
224
+ // DURABILITY (reviewer item 1): a genuine untimestamped append arriving in a
225
+ // LATER (warm) call after a restart/eviction IS distinguishable from the
226
+ // cold-replay survivors — the cold replay seeded occNext = survivor-count T,
227
+ // but the DB holds K>T persisted copies, so a warm first-seen turn drawing T
228
+ // would silently collide. `ordinalState.durable` (hash → next-ordinal,
229
+ // persisted per session for untimestamped identities that reached
230
+ // occurrence>0) restores that K: a WARM first-seen untimestamped turn draws
231
+ // max(occNext, durableK) so it lands ABOVE every persisted copy. The COLD
232
+ // positional replay path is NOT consulted for durable (floor=0 there), so a
233
+ // fresh full/subset re-ingest stays pure-positional and dedupes even with a
234
+ // stale or deleted state file (invariants 2 & 3 hold). The only residual
235
+ // (unavoidable) collapse is an identical append that is ALREADY inside the
236
+ // cold-replay array on a compacted restart — indistinguishable from a
237
+ // survivor (identical content, no per-message id); it never DUPLICATES.
238
+ const ordinalState = _touchOrdinalState(sessionId)
239
+ const occNext = ordinalState.occNext
240
+ // A first-seen turn is a genuine new append (eligible for the durable floor)
241
+ // only in a WARM call — one whose ordinal state was already established by an
242
+ // earlier ingest. In the establishing (cold) call every array message is
243
+ // positional-replayed, so the floor must NOT apply.
244
+ const warmCall = ordinalState.seeded
245
+ if (!ordinalState.durableLoaded) {
246
+ // Load once per (re)established state; on LRU eviction the state is dropped
247
+ // and reloaded here, so the high-water survives eviction too. Best-effort.
248
+ ordinalState.durable = new Map()
249
+ try {
250
+ const raw = await loadOrdinalHighWater(sessionId)
251
+ if (raw && typeof raw === 'object') {
252
+ for (const [k, v] of Object.entries(raw)) {
253
+ const n = Number(v)
254
+ if (Number.isFinite(n) && n > 0) ordinalState.durable.set(k, Math.floor(n))
255
+ }
256
+ }
257
+ } catch { /* absent/corrupt state file → in-memory only (invariant 2 safe) */ }
258
+ ordinalState.durableLoaded = true
259
+ }
260
+ if (!ordinalState.seeded) {
261
+ // Replay identity/exclude logic over messages[0, start) so occNext reflects
262
+ // full-array position (and any surviving recorded ordinals) even when this
263
+ // call ingests only a later window. Runs once per fresh/evicted state; a
264
+ // warm state already carries the counts, so re-seeding would double-count.
265
+ for (let i = 0; i < start; i += 1) {
266
+ const m = messages[i]
267
+ if (!m || typeof m !== 'object') continue
268
+ // Cached (WeakMap-keyed) so a repeated hydrate over the SAME prefix
269
+ // objects only pays the clean/normalize cost once per message.
270
+ const fields = _ingestIdentityFields(m)
271
+ if (!fields) continue
272
+ _assignOccurrence(occNext, `${fields.role}\u0000${fields.content}`, m)
273
+ }
274
+ ordinalState.seeded = true
275
+ }
276
+ for (let i = start; i < messages.length; i += 1) {
277
+ const m = messages[i]
278
+ if (!m || typeof m !== 'object') continue
279
+ const role = normalizeIngestRole(m.role)
280
+ // ingest_session persists user/assistant only; system/developer/tool rows
281
+ // are dropped so recall-fasttrack summaries stay conversation-focused and
282
+ // do not re-inject content already in the protected system prefix.
283
+ if (!role) continue
284
+ // Exclude synthetic / non-conversation rows entirely (reference-files
285
+ // injections, compaction summaries, protected-context `.` acks, internal
286
+ // runtime nudges). These are mechanical noise, not conversation.
287
+ if (shouldExcludeIngestMessage(m)) continue
288
+ // Pure-conversation shaping: only the human/model prose text. The ingest
289
+ // shaper NEVER inlines tool_call / tool_result traces and strips the
290
+ // deterministic manager.mjs user-turn prefix envelopes (# Session /
291
+ // # Additional context / # Prefetch / # Task) so only the real human
292
+ // prompt remains. cleanMemoryText then removes the <system-reminder> block
293
+ // and residual transcript noise.
294
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
295
+ if (!content || !content.trim()) continue
296
+ considered += 1
297
+ const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
298
+ // Assign the next monotonic turn BEFORE building the source_ref so identical
299
+ // untimestamped repeats get distinct identities (peekNext is stable until a
300
+ // row is actually inserted → next()).
301
+ const assignedTurn = turnAllocator.peekNext()
302
+ // Stable occurrence index for this identity (pre-hash; role+content is a
303
+ // sufficient discriminator for the duplicate case the ordinal disambiguates).
304
+ // See the distinction-rule comment above and _assignOccurrence. For an
305
+ // UNTIMESTAMPED turn a WARM first-seen assignment is floored by the durable
306
+ // per-identity high-water so a genuine post-restart/eviction append lands
307
+ // above every persisted copy; the durable high-water is then advanced.
308
+ const occKey = `${role}\u0000${content}`
309
+ const rawTs = m.ts ?? m.timestamp
310
+ const untimestamped = !((typeof rawTs === 'number' && Number.isFinite(rawTs))
311
+ || (typeof rawTs === 'string' && rawTs.trim()))
312
+ const idHash = untimestamped ? _identityHash(occKey) : null
313
+ const floor = (warmCall && untimestamped) ? (ordinalState.durable.get(idHash) ?? 0) : 0
314
+ const occurrence = _assignOccurrence(occNext, occKey, m, floor)
315
+ if (untimestamped && occurrence >= 1) {
316
+ // Duplicate untimestamped identity (occurrence>0): record/raise its durable
317
+ // next-ordinal (write-behind persisted below). Monotonic — never regresses
318
+ // a loaded K, so a cold replay counting only survivors can't shrink it.
319
+ const cur = ordinalState.durable.get(idHash) ?? 0
320
+ if (occurrence + 1 > cur) { ordinalState.durable.set(idHash, occurrence + 1); ordinalState.dirty = true }
321
+ }
322
+ // Stable per-message identity. The previous `session:${id}#${i+1}` key was
323
+ // positional, so after compaction shrinks/reindexes session.messages a
324
+ // later turn could reuse an old index and be silently skipped by
325
+ // ON CONFLICT DO NOTHING. stableSessionSourceRef hashes only durable
326
+ // fields (role, original ts if present, tool ids, content) — never the
327
+ // synthesized tsMs fallback or the loop index. For untimestamped turns the
328
+ // monotonic ordinal is folded in so genuine repeats persist (not collapsed).
329
+ const sourceRef = stableSessionSourceRef(sessionId, m, role, content, occurrence)
330
+ const result = await db.query(`
331
+ INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
332
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
333
+ ON CONFLICT DO NOTHING
334
+ RETURNING id
335
+ `, [tsMs, role, content, sourceRef, sessionId, assignedTurn, projectId])
336
+ const rowInserted = Number(result.rowCount ?? result.affectedRows ?? 0) || 0
337
+ if (rowInserted > 0) {
338
+ inserted += rowInserted
339
+ const newId = result.rows?.[0]?.id
340
+ if (newId != null && Number.isFinite(Number(newId))) insertedIds.push(Number(newId))
341
+ turnAllocator.next()
342
+ }
343
+ }
344
+ // Write-behind persist of the durable untimestamped high-water. Not awaited:
345
+ // ingest latency is unchanged and a persist failure only degrades a rare
346
+ // post-restart append (never correctness of THIS call). Serialized per
347
+ // session by the chain, so writes cannot interleave for one session.
348
+ if (ordinalState.dirty) {
349
+ ordinalState.dirty = false
350
+ const snapshot = Object.fromEntries(ordinalState.durable)
351
+ Promise.resolve()
352
+ .then(() => saveOrdinalHighWater(sessionId, snapshot))
353
+ .catch((err) => log(`[ingest] untimestamped high-water persist failed: ${err?.message || err}\n`))
354
+ }
355
+ // Always-on post-ingest raw embedding: freshly ingested rows become
356
+ // dense-searchable immediately (autoclear/recall-fasttrack hydration,
357
+ // recall empty-fallback), without waiting for cycle1 chunking or the
358
+ // ~60s background tick. Local ONNX only — no LLM cost, so it runs
359
+ // regardless of the recap toggle.
360
+ //
361
+ // Two-tier flush so ingest never synchronously inherits OTHER calls'/
362
+ // sessions' raw-embedding backlog:
363
+ // 1) AWAITED (bounded) — scoped to exactly THIS call's insertedIds, so
364
+ // ingest_session resolves once its own rows are embedded and an
365
+ // immediately following recall sees them in the dense leg. A 15s cap
366
+ // keeps a cold ONNX warmup from wedging ingest. Id-scoped, so
367
+ // concurrent ingests embed disjoint row sets (SKIP LOCKED) without
368
+ // stacking redundant work — no chain serialization needed here.
369
+ // 2) BACKGROUND (not awaited) — an unscoped sweep of any PRE-EXISTING
370
+ // backlog (rows left by earlier calls / other sessions / the flush
371
+ // cap). Serialized on _rawEmbedFlushChain so bursts don't stack full
372
+ // backlog scans; the ~60s tick still sweeps whatever this misses.
373
+ if (insertedIds.length > 0) {
374
+ const run = flushRawEmbeddings(db, { limit: 200, ids: insertedIds })
375
+ .then((r) => {
376
+ if (r.attempted > 0) log(`[embed] post-ingest raw flush (own) attempted=${r.attempted} embedded=${r.embedded}\n`)
377
+ return r
378
+ })
379
+ .catch((err) => log(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
380
+ let timer
381
+ await Promise.race([
382
+ run,
383
+ new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
384
+ ]).finally(() => clearTimeout(timer))
385
+ }
386
+ // Background backlog sweep — kicked, never awaited. Runs even when THIS
387
+ // call inserted 0 rows, so pre-existing backlog is not left waiting for
388
+ // the next scheduler tick.
389
+ _rawEmbedFlushChain = _rawEmbedFlushChain
390
+ .catch(() => {}) // never let a previous flush failure poison the chain
391
+ .then(() => flushRawEmbeddings(db, { limit: 200 }))
392
+ .then((r) => {
393
+ if (r.attempted > 0) log(`[embed] post-ingest raw flush (backlog) attempted=${r.attempted} embedded=${r.embedded}\n`)
394
+ return r
395
+ })
396
+ .catch((err) => log(`[embed] post-ingest raw backlog flush failed: ${err?.message || err}\n`))
397
+ return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
398
+ }
399
+
400
+ return { ingestSessionMessages }
401
+ }
@@ -234,7 +234,7 @@ function stripUserTurnPrefixEnvelopes(text) {
234
234
  // `# Session\n` are EXACTLY the fixed fields buildSessionStartBlock emits
235
235
  // (`Cwd: `, `Model: `, `Workflow: `, each on its own line) before the
236
236
  // blank-line terminator. A human doc that merely STARTS with a `# Session`
237
- // heading followed by free prose (e.g. `프로젝트 회의록입니다`) does NOT match
237
+ // heading followed by free prose in any language (e.g. `meeting notes`) does NOT match
238
238
  // — its next line is not a `Cwd:/Model:/Workflow:` field — so it is
239
239
  // preserved verbatim (zero-loss). Anchored ^.
240
240
  out = out.replace(/^# Session\n(?:(?:Cwd|Model|Workflow): [^\n]*\n)+(?:\n|$)/, '')
@@ -75,7 +75,13 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
75
75
  }
76
76
  try {
77
77
  const st = statSync(lockPath);
78
- if (Date.now() - st.mtimeMs > staleMs) {
78
+ // Dead-owner fast path: if the lock's recorded owner pid is gone,
79
+ // force-release immediately instead of waiting out staleMs. A crashed
80
+ // owner would otherwise hold the path until the 30s stale window,
81
+ // starving waiters past the 8s acquire timeout (the restart stall).
82
+ const ownerPidEarly = _readLockOwnerPid(lockPath);
83
+ const ownerDead = ownerPidEarly !== null && _pidIsDead(ownerPidEarly);
84
+ if (ownerDead || Date.now() - st.mtimeMs > staleMs) {
79
85
  // Only steal a stale lock when we can prove the owner is
80
86
  // gone. Reading the pid from the lock file and probing it
81
87
  // with kill(pid, 0) protects a slow-but-live holder from
@@ -96,14 +102,14 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
96
102
  // path unlink is not a portable unlink-if-token-still-matches
97
103
  // primitive, so live-lock safety rests on the lockPath token
98
104
  // reverify immediately before the lockPath unlink below.
99
- const deadPid = _readLockOwnerPid(lockPath);
100
- if (deadPid !== null && _pidIsDead(deadPid)) {
105
+ const deadPid = ownerPidEarly;
106
+ if (ownerDead) {
101
107
  const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
102
108
  if (reclaim !== null) {
103
109
  let reclaimed = false;
104
110
  try {
105
111
  const currentSt = statSync(lockPath);
106
- if (Date.now() - currentSt.mtimeMs > staleMs) {
112
+ if (ownerDead || Date.now() - currentSt.mtimeMs > staleMs) {
107
113
  const currentPid = _readLockOwnerPid(lockPath);
108
114
  if (currentPid === deadPid && _pidIsDead(currentPid)) {
109
115
  try { unlinkSync(lockPath); reclaimed = true; } catch {}
@@ -48,8 +48,10 @@ export function resolveExecutionMode(args = {}, defaultMode = 'sync') {
48
48
  }
49
49
 
50
50
  export function executionModeSchemaDescription(defaultMode = 'sync') {
51
- const defaultText = defaultMode === 'async' ? 'Default async.' : 'Default sync.';
52
- return `sync = inline result; async = task_id + completion notification. ${defaultText}`;
51
+ if (defaultMode === 'async') {
52
+ return 'sync = inline result; async = task_id + completion notification. Default async.';
53
+ }
54
+ return 'Runs sync; long-running auto-promotes to a background task_id + completion notification. async forces background.';
53
55
  }
54
56
 
55
57
  export function taskIdFromArgs(args = {}) {
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Async returns task_id; completion notification follows. status/read/wait are recovery/blocking only.';
5
+ 'Runs sync; long-running auto-promotes to a task_id + completion notification. async forces background. status/read/wait are recovery/blocking only.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
8
  'wait/read/status/cancel are for explicit blocking or recovery only.';
@@ -74,7 +74,7 @@ export function formatLineDelta(totals) {
74
74
  }
75
75
 
76
76
  export function parseUpdateSummary(text) {
77
- const match = /^(Updated|Created|Deleted)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
77
+ const match = /^(Updated|Created|Deleted|Checked)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
78
78
  if (!match) return null;
79
79
  const action = titleWord(match[1]);
80
80
  const target = match[2].trim();
@@ -703,7 +703,36 @@ export function formatAggregateDetail(summaries) {
703
703
  }
704
704
 
705
705
  const update = parseUpdateSummary(text);
706
- if (update) {
706
+ // Dry-run patch checks ("Checked foo.js · +7 -5") are validations, not
707
+ // edits: their line delta must NEVER be summed into the real edit total.
708
+ // They get their own metric so repeated checks still merge; the preview
709
+ // delta is shown only when the card has no real edit delta it could be
710
+ // confused with. Delta-less "Checked ..." texts (task/memory summaries)
711
+ // fall through to extras unchanged.
712
+ if (update && update.action === 'Checked') {
713
+ if (update.seen) {
714
+ const metric = addMetric('checked_files', {
715
+ files: new Set(),
716
+ fileCount: 0,
717
+ added: 0,
718
+ removed: 0,
719
+ seen: false,
720
+ render: (m) => {
721
+ const count = m.fileCount + m.files.size;
722
+ const target = count === 1 && m.fileCount === 0 ? [...m.files][0] : `${count} ${pluralize(count, 'file')}`;
723
+ const editDelta = formatLineDelta(metrics.get('updated_files'));
724
+ const delta = editDelta ? '' : formatLineDelta(m);
725
+ return delta ? `Checked ${target} · ${delta}` : `Checked ${target}`;
726
+ },
727
+ });
728
+ if (update.file) metric.files.add(update.file);
729
+ metric.fileCount += update.fileCount;
730
+ metric.added += update.added;
731
+ metric.removed += update.removed;
732
+ metric.seen = metric.seen || update.seen;
733
+ continue;
734
+ }
735
+ } else if (update) {
707
736
  const metric = addMetric('updated_files', {
708
737
  files: new Set(),
709
738
  fileCount: 0,
@@ -0,0 +1,36 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { envFlag } from './env.mjs';
3
+
4
+ // Boot-timing profiler + instrumented dynamic import. Extracted verbatim from
5
+ // mixdog-session-runtime.mjs during the facade split. `profiledImport` resolves
6
+ // relative specifiers against this module's directory (src/session-runtime/),
7
+ // which matches runtime-core.mjs, so callers keep passing the same specifiers.
8
+ const BOOT_PROFILE_ENABLED = envFlag('MIXDOG_BOOT_PROFILE');
9
+ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
10
+
11
+ export function bootProfile(event, fields = {}) {
12
+ if (!BOOT_PROFILE_ENABLED) return;
13
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
14
+ const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, event];
15
+ for (const [key, value] of Object.entries(fields || {})) {
16
+ if (value === undefined || value === null || value === '') continue;
17
+ parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
18
+ }
19
+ try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
20
+ }
21
+
22
+ export async function profiledImport(label, spec, { optional = false } = {}) {
23
+ const startedAt = performance.now();
24
+ try {
25
+ const mod = await import(spec);
26
+ bootProfile(`import:${label}`, { ms: (performance.now() - startedAt).toFixed(1) });
27
+ return mod;
28
+ } catch (error) {
29
+ bootProfile(`import:${label}:failed`, {
30
+ ms: (performance.now() - startedAt).toFixed(1),
31
+ error: error?.message || String(error),
32
+ });
33
+ if (optional) return null;
34
+ throw error;
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ channelSetup,
3
+ deleteSchedule,
4
+ deleteWebhook,
5
+ setChannel,
6
+ saveSchedule,
7
+ saveWebhook,
8
+ setScheduleEnabled,
9
+ setWebhookEnabled,
10
+ setWebhookConfig,
11
+ } from '../standalone/channel-admin.mjs';
12
+
13
+ // Channel/webhook/schedule config surface. Extracted verbatim from the runtime
14
+ // API object; the mutating admin helpers are imported directly here and the
15
+ // runtime injects only the closure-owned callbacks (backend flush, channel
16
+ // worker handle, soft reload).
17
+ export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
18
+ return {
19
+ getChannelSetup() {
20
+ // Flush a pending debounced backend switch first so setup readers
21
+ // (Settings → Channel Setting, remote toggles) never observe the
22
+ // previous backend during the 150ms debounce window.
23
+ try { flushBackendSave(); } catch {}
24
+ return channelSetup();
25
+ },
26
+ getChannelWorkerStatus() {
27
+ return channels.status();
28
+ },
29
+ setChannel(entry) {
30
+ const result = setChannel(entry);
31
+ reloadChannelsSoon();
32
+ return result;
33
+ },
34
+ setWebhookConfig(patch) {
35
+ const result = setWebhookConfig(patch);
36
+ reloadChannelsSoon();
37
+ return result;
38
+ },
39
+ saveSchedule(entry) {
40
+ const result = saveSchedule(entry);
41
+ reloadChannelsSoon();
42
+ return result;
43
+ },
44
+ deleteSchedule(name) {
45
+ const result = deleteSchedule(name);
46
+ reloadChannelsSoon();
47
+ return result;
48
+ },
49
+ setScheduleEnabled(name, enabled) {
50
+ const result = setScheduleEnabled(name, enabled);
51
+ reloadChannelsSoon();
52
+ return result;
53
+ },
54
+ saveWebhook(entry) {
55
+ const result = saveWebhook(entry);
56
+ reloadChannelsSoon();
57
+ return result;
58
+ },
59
+ deleteWebhook(name) {
60
+ const result = deleteWebhook(name);
61
+ reloadChannelsSoon();
62
+ return result;
63
+ },
64
+ setWebhookEnabled(name, enabled) {
65
+ const result = setWebhookEnabled(name, enabled);
66
+ reloadChannelsSoon();
67
+ return result;
68
+ },
69
+ };
70
+ }
@@ -58,7 +58,7 @@ export function createConfigLifecycle({
58
58
  ) {
59
59
  return outputStyleStatusCache;
60
60
  }
61
- outputStyleStatusCache = outputStyleStatus(dataDir);
61
+ outputStyleStatusCache = outputStyleStatus(dataDir, { fresh });
62
62
  outputStyleStatusCacheAt = now;
63
63
  outputStyleStatusCacheDir = cacheDir;
64
64
  return outputStyleStatusCache;