@capekai/core 1.0.0

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 (148) hide show
  1. package/README.md +12 -0
  2. package/package.json +105 -0
  3. package/src/adapters/ai-sdk.ts +84 -0
  4. package/src/compaction/contracts.ts +82 -0
  5. package/src/compaction/executor.ts +161 -0
  6. package/src/compaction/policy.ts +318 -0
  7. package/src/compaction/recovery.ts +139 -0
  8. package/src/compaction/task.ts +540 -0
  9. package/src/configuration/contracts.ts +58 -0
  10. package/src/configuration/defaults.ts +27 -0
  11. package/src/configuration/runtime.ts +42 -0
  12. package/src/configuration/single-model.ts +75 -0
  13. package/src/context/assembler.ts +112 -0
  14. package/src/context/index.ts +2 -0
  15. package/src/context/sources.ts +119 -0
  16. package/src/context/workspace.ts +63 -0
  17. package/src/core/agent.ts +401 -0
  18. package/src/core/build-tools.ts +139 -0
  19. package/src/core/chat-handler.ts +858 -0
  20. package/src/core/error-handling.ts +18 -0
  21. package/src/core/fork.ts +103 -0
  22. package/src/core/interrupt.ts +192 -0
  23. package/src/core/message-utils.ts +261 -0
  24. package/src/core/model-utils.ts +149 -0
  25. package/src/core/part-utils.ts +88 -0
  26. package/src/core/provider-utils.ts +67 -0
  27. package/src/core/revert.ts +46 -0
  28. package/src/core/step-handlers.ts +157 -0
  29. package/src/core/stream/finalization.ts +65 -0
  30. package/src/core/stream/stream-config.ts +82 -0
  31. package/src/core/stream-handlers.ts +242 -0
  32. package/src/core/structured-output.ts +68 -0
  33. package/src/core/tool-builders/agent-tools.ts +71 -0
  34. package/src/core/tool-builders/external-tools.ts +179 -0
  35. package/src/core/tool-builders/types.ts +16 -0
  36. package/src/core/tool-builders/workspace-tools.ts +293 -0
  37. package/src/core/tool-capabilities.ts +65 -0
  38. package/src/goals/evaluator.ts +171 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/loop.ts +167 -0
  41. package/src/goals/service.ts +39 -0
  42. package/src/index.ts +10 -0
  43. package/src/internal/ask-authority.ts +29 -0
  44. package/src/internal/composition.ts +44 -0
  45. package/src/internal/configuration.ts +22 -0
  46. package/src/internal/execution.ts +108 -0
  47. package/src/internal/hosts.ts +64 -0
  48. package/src/internal/plugins.ts +71 -0
  49. package/src/internal/providers.ts +32 -0
  50. package/src/internal/sandbox.ts +19 -0
  51. package/src/internal/tools.ts +48 -0
  52. package/src/internal/workspace.ts +25 -0
  53. package/src/kernel/diagnostics.ts +249 -0
  54. package/src/kernel/errors.ts +120 -0
  55. package/src/kernel/events.ts +82 -0
  56. package/src/kernel/index.ts +72 -0
  57. package/src/kernel/kernel.ts +62 -0
  58. package/src/kernel/lifecycle.ts +72 -0
  59. package/src/kernel/plugin.ts +218 -0
  60. package/src/kernel/registry.ts +493 -0
  61. package/src/kernel/scope.ts +776 -0
  62. package/src/kernel/service-key.ts +19 -0
  63. package/src/kernel/types.ts +317 -0
  64. package/src/memory/index.ts +2 -0
  65. package/src/memory/memory-tool.ts +75 -0
  66. package/src/memory/registry.ts +172 -0
  67. package/src/permission/ask-user-api.ts +70 -0
  68. package/src/permission/contracts.ts +135 -0
  69. package/src/permission/permission-request-manager.ts +58 -0
  70. package/src/permission/policy.ts +277 -0
  71. package/src/permission/runtime.ts +612 -0
  72. package/src/plugins/compaction-policy.ts +46 -0
  73. package/src/plugins/compose.ts +171 -0
  74. package/src/plugins/context-sections.ts +246 -0
  75. package/src/plugins/default-agent-driver.ts +14 -0
  76. package/src/plugins/facade-plugins.ts +129 -0
  77. package/src/plugins/goal-domain.ts +82 -0
  78. package/src/plugins/legacy-system-message.ts +152 -0
  79. package/src/plugins/loaded-tools.ts +23 -0
  80. package/src/plugins/memory-domain.ts +264 -0
  81. package/src/plugins/orchestrator-session.ts +29 -0
  82. package/src/plugins/permission-policy.ts +49 -0
  83. package/src/plugins/retry-policy.ts +28 -0
  84. package/src/plugins/scheduler-domain.ts +192 -0
  85. package/src/plugins/service-keys.ts +294 -0
  86. package/src/plugins/session-search-domain.ts +238 -0
  87. package/src/plugins/skills-domain.ts +272 -0
  88. package/src/plugins/subagent-domain.ts +287 -0
  89. package/src/plugins/tool-catalog.ts +78 -0
  90. package/src/plugins/tool-output-policy.ts +52 -0
  91. package/src/plugins/value-plugins.ts +150 -0
  92. package/src/plugins/workflow-domain.ts +198 -0
  93. package/src/plugins/workspace-policy.ts +37 -0
  94. package/src/providers/registry.ts +63 -0
  95. package/src/providers/types.ts +44 -0
  96. package/src/retry/policy.ts +282 -0
  97. package/src/retry/stream-chat.ts +312 -0
  98. package/src/runtime/agent-runtime.ts +83 -0
  99. package/src/runtime/default-agent-driver.ts +23 -0
  100. package/src/runtime/domain-tool-source.ts +156 -0
  101. package/src/runtime/events.ts +61 -0
  102. package/src/runtime/host-dependencies.ts +71 -0
  103. package/src/runtime/host-guidance.ts +22 -0
  104. package/src/runtime/host-layout.ts +23 -0
  105. package/src/runtime/host.ts +129 -0
  106. package/src/runtime/standalone-host.ts +118 -0
  107. package/src/sandbox/controller.ts +204 -0
  108. package/src/sandbox/model.ts +305 -0
  109. package/src/sandbox/provider.ts +53 -0
  110. package/src/sandbox/types.ts +110 -0
  111. package/src/scheduler/host.ts +22 -0
  112. package/src/scheduler/scheduler-tool.ts +172 -0
  113. package/src/session-search/host.ts +56 -0
  114. package/src/session-search/index.ts +23 -0
  115. package/src/session-search/session-search-tool.ts +151 -0
  116. package/src/skills/index.ts +3 -0
  117. package/src/skills/registry.ts +63 -0
  118. package/src/skills/skill-manage-tool.ts +205 -0
  119. package/src/skills/skill-tool.ts +42 -0
  120. package/src/storage/contracts.ts +159 -0
  121. package/src/storage/memory.ts +321 -0
  122. package/src/storage/options.ts +75 -0
  123. package/src/storage/runtime.ts +115 -0
  124. package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
  125. package/src/storage/sqlite.ts +321 -0
  126. package/src/storage/tool-output-artifacts.ts +75 -0
  127. package/src/storage.ts +31 -0
  128. package/src/subagent/child-session.ts +282 -0
  129. package/src/subagent/guidance.ts +8 -0
  130. package/src/subagent/policy.ts +198 -0
  131. package/src/subagent/task-tool.ts +584 -0
  132. package/src/tool-output/contracts.ts +111 -0
  133. package/src/tool-output/policy.ts +410 -0
  134. package/src/tool.ts +1 -0
  135. package/src/tools/executor.ts +258 -0
  136. package/src/tools/install-manifest.ts +40 -0
  137. package/src/tools/llm-api.ts +77 -0
  138. package/src/tools/registry.ts +206 -0
  139. package/src/tools/tool-artifact.ts +182 -0
  140. package/src/tools/tool-source.ts +53 -0
  141. package/src/utils/errors.ts +334 -0
  142. package/src/utils/strip-visualization.ts +50 -0
  143. package/src/workflow/decomposer.ts +139 -0
  144. package/src/workflow/execution.ts +523 -0
  145. package/src/workflow/orchestrator-session.ts +161 -0
  146. package/src/workflow/synthesizer.ts +130 -0
  147. package/src/workspace/contracts.ts +135 -0
  148. package/src/workspace/policy.ts +327 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * C6 compaction policy contract and default provider.
3
+ *
4
+ * The agent-scoped compaction service owns the current policy decisions:
5
+ * policy resolution, the hybrid threshold formula, pruning knobs, the
6
+ * failure cooldown, the replay selection, and the per-session concurrency
7
+ * guard (all previously module-global state). The trigger creation, summary
8
+ * generation, pruning, and failure persistence live in `task.ts` with their
9
+ * safety invariants non-configurable.
10
+ *
11
+ * Environment variables become provider options at composition: the plugin
12
+ * layer reads the composed `capek.runtime-configuration` service once at
13
+ * setup and freezes the values into this service's options. Consumers that
14
+ * run outside a composed scope (the current Jean2 server path) fall back to
15
+ * one lazily created process-default service that keeps reading the active
16
+ * configuration live, exactly like the pre-C6 module reads, until C8 retires
17
+ * the compat surface.
18
+ */
19
+
20
+ import { AsyncLocalStorage } from 'node:async_hooks';
21
+ import {
22
+ findModel,
23
+ getMaxOutputTokens,
24
+ getRuntimeConfiguration,
25
+ } from '../configuration/runtime';
26
+ import { listMessagesWithParts } from '../storage/runtime';
27
+ import type {
28
+ AutoThresholdResult,
29
+ CompactionPolicy,
30
+ } from './contracts';
31
+
32
+ /** Fully resolved provider options. Every field holds the env-translated
33
+ * value; nothing in this interface reads the environment. */
34
+ export interface CompactionServiceOptions {
35
+ modelId: string | null;
36
+ providerId: string | null;
37
+ maxOutputTokens: number;
38
+ preserveRecentToolCount: number;
39
+ preserveSmallToolChars: number;
40
+ toolClearCharsThreshold: number;
41
+ maxPrunedToolCount: number;
42
+ autoThresholdRatio: number;
43
+ autoReserveCapTokens: number;
44
+ autoSafetyMarginTokens: number;
45
+ }
46
+
47
+ /** Optional test seams. `failureCooldownState` replaces the owned cooldown
48
+ * map; `now` replaces Date.now for cooldown window tests. Neither is used by
49
+ * production wiring. */
50
+ export interface CompactionServiceCreateOptions {
51
+ id?: string;
52
+ /** When omitted (the process-default fallback), options resolve live from
53
+ * the active runtime configuration, preserving the pre-C6 unscoped
54
+ * behavior. Composed scopes always pass frozen composition-time options. */
55
+ options?: CompactionServiceOptions;
56
+ failureCooldownState?: Map<string, { count: number; lastFailureAt: number }>;
57
+ now?: () => number;
58
+ }
59
+
60
+ /**
61
+ * C6 compaction service contract. Agent-scoped behind
62
+ * `capekCompactionServiceKey`. The default provider reproduces the exact
63
+ * pre-C6 behavior; a custom provider may vary the policy resolution, pruning
64
+ * numbers, threshold formula, cooldown, or replay selection without the
65
+ * executor or task pipeline changing. The safety invariants (main-session
66
+ * requirement, message-count validation, trigger validation, and boundary
67
+ * validation) stay inside the task pipeline and are not options.
68
+ */
69
+ export interface CompactionService {
70
+ readonly id: string;
71
+ /** Effective provider options for this scope. */
72
+ readonly options: Readonly<CompactionServiceOptions>;
73
+ /** Resolves a CompactionPolicy from optional overrides and session
74
+ * defaults. Service options take precedence, then overrides, then session
75
+ * values, then defaults. */
76
+ resolvePolicy(
77
+ sessionModelId: string | undefined,
78
+ sessionProviderId: string | undefined,
79
+ overrides?: Partial<CompactionPolicy>,
80
+ ): CompactionPolicy;
81
+ /** Hybrid auto-compaction threshold formula. */
82
+ computeThreshold(modelId: string | undefined, policy?: CompactionPolicy): AutoThresholdResult;
83
+ /** Failure cooldown: true when the session recently hit the consecutive
84
+ * failure limit and compaction should be skipped. */
85
+ shouldSkipCompaction(sessionId: string): boolean;
86
+ recordCompactionFailure(sessionId: string): void;
87
+ clearCompactionFailure(sessionId: string): void;
88
+ /** Replay policy: the replay text used after a successful compaction, or
89
+ * null when the history has no prior user turn to replay. */
90
+ buildReplayText(sessionId: string): Promise<string | null>;
91
+ /** Per-session compaction concurrency guard. */
92
+ isCompactionActive(sessionId: string): boolean;
93
+ beginCompaction(sessionId: string): void;
94
+ endCompaction(sessionId: string): void;
95
+ }
96
+
97
+ const COMPACTION_FAILURE_COOLDOWN_MS = 60_000;
98
+ const COMPACTION_MAX_CONSECUTIVE_FAILURES = 2;
99
+
100
+ function resolveOptionsLive(): CompactionServiceOptions {
101
+ const configuration = getRuntimeConfiguration();
102
+ return {
103
+ modelId: configuration.getCompactionModel() ?? null,
104
+ providerId: configuration.getCompactionProvider() ?? null,
105
+ maxOutputTokens: configuration.getCompactionMaxTokens(),
106
+ preserveRecentToolCount: configuration.getCompactionPreserveRecentToolCount(),
107
+ preserveSmallToolChars: configuration.getCompactionPreserveSmallToolChars(),
108
+ toolClearCharsThreshold: configuration.getCompactionToolClearCharsThreshold(),
109
+ maxPrunedToolCount: configuration.getCompactionMaxPrunedToolCount(),
110
+ autoThresholdRatio: configuration.getCompactionAutoThresholdRatio(),
111
+ autoReserveCapTokens: configuration.getCompactionAutoReserveCapTokens(),
112
+ autoSafetyMarginTokens: configuration.getCompactionAutoSafetyMarginTokens(),
113
+ };
114
+ }
115
+
116
+ /** The C6 default provider wrapping the exact pre-C6 behavior. */
117
+ export function createCompactionService(
118
+ createOptions: CompactionServiceCreateOptions = {},
119
+ ): CompactionService {
120
+ const id = createOptions.id ?? 'compaction.default';
121
+ const frozenOptions = createOptions.options;
122
+ const failureCooldownState = createOptions.failureCooldownState
123
+ ?? new Map<string, { count: number; lastFailureAt: number }>();
124
+ const activeSessions = new Set<string>();
125
+ const now = createOptions.now ?? Date.now;
126
+
127
+ function currentOptions(): CompactionServiceOptions {
128
+ return frozenOptions ?? resolveOptionsLive();
129
+ }
130
+
131
+ return {
132
+ id,
133
+ get options(): Readonly<CompactionServiceOptions> {
134
+ return currentOptions();
135
+ },
136
+ resolvePolicy(
137
+ sessionModelId: string | undefined,
138
+ sessionProviderId: string | undefined,
139
+ overrides?: Partial<CompactionPolicy>,
140
+ ): CompactionPolicy {
141
+ const current = currentOptions();
142
+ return {
143
+ modelId: current.modelId ?? overrides?.modelId ?? sessionModelId ?? null,
144
+ providerId: current.providerId ?? overrides?.providerId ?? sessionProviderId ?? null,
145
+ maxOutputTokens: overrides?.maxOutputTokens ?? current.maxOutputTokens,
146
+ overflowThresholdRatio: overrides?.overflowThresholdRatio ?? null,
147
+ // WS4: Budget-aware pruning - options take precedence, then overrides
148
+ preserveRecentToolCount: overrides?.preserveRecentToolCount ?? current.preserveRecentToolCount,
149
+ preserveSmallToolChars: overrides?.preserveSmallToolChars ?? current.preserveSmallToolChars,
150
+ toolClearCharsThreshold: overrides?.toolClearCharsThreshold ?? current.toolClearCharsThreshold,
151
+ maxPrunedToolCount: overrides?.maxPrunedToolCount ?? current.maxPrunedToolCount,
152
+ // Hybrid formula - options take precedence, then overrides
153
+ autoThresholdRatio: overrides?.autoThresholdRatio ?? current.autoThresholdRatio,
154
+ autoReserveCapTokens: overrides?.autoReserveCapTokens ?? current.autoReserveCapTokens,
155
+ autoSafetyMarginTokens: overrides?.autoSafetyMarginTokens ?? current.autoSafetyMarginTokens,
156
+ };
157
+ },
158
+ computeThreshold(modelId: string | undefined, policy?: CompactionPolicy): AutoThresholdResult {
159
+ const modelDef = modelId ? findModel(modelId) : undefined;
160
+ const contextWindow = modelDef?.contextWindow;
161
+
162
+ if (!contextWindow) {
163
+ return { threshold: 0, contextWindow: undefined };
164
+ }
165
+
166
+ const modelMaxOutputTokens = getMaxOutputTokens(modelId!);
167
+ const current = currentOptions();
168
+
169
+ const autoThresholdRatio = policy?.autoThresholdRatio ?? current.autoThresholdRatio;
170
+ const autoReserveCapTokens = policy?.autoReserveCapTokens ?? current.autoReserveCapTokens;
171
+ const autoSafetyMarginTokens = policy?.autoSafetyMarginTokens ?? current.autoSafetyMarginTokens;
172
+
173
+ const reserve = Math.min(modelMaxOutputTokens, autoReserveCapTokens);
174
+ const ratioBasedThreshold = Math.floor(contextWindow * autoThresholdRatio);
175
+ const safeThreshold = contextWindow - reserve - autoSafetyMarginTokens;
176
+
177
+ const threshold = Math.max(0, Math.min(ratioBasedThreshold, safeThreshold));
178
+
179
+ return { threshold, contextWindow };
180
+ },
181
+ shouldSkipCompaction(sessionId: string): boolean {
182
+ const tracker = failureCooldownState.get(sessionId);
183
+ if (!tracker) return false;
184
+
185
+ const elapsed = now() - tracker.lastFailureAt;
186
+ if (elapsed > COMPACTION_FAILURE_COOLDOWN_MS) {
187
+ failureCooldownState.delete(sessionId);
188
+ return false;
189
+ }
190
+
191
+ return tracker.count >= COMPACTION_MAX_CONSECUTIVE_FAILURES;
192
+ },
193
+ recordCompactionFailure(sessionId: string): void {
194
+ const existing = failureCooldownState.get(sessionId);
195
+ if (existing) {
196
+ existing.count++;
197
+ existing.lastFailureAt = now();
198
+ } else {
199
+ failureCooldownState.set(sessionId, { count: 1, lastFailureAt: now() });
200
+ }
201
+ },
202
+ clearCompactionFailure(sessionId: string): void {
203
+ failureCooldownState.delete(sessionId);
204
+ },
205
+ async buildReplayText(sessionId: string): Promise<string | null> {
206
+ const allMessages = await listMessagesWithParts(sessionId);
207
+
208
+ for (let i = allMessages.length - 2; i >= 0; i--) {
209
+ const m = allMessages[i];
210
+ if (m.message.role !== 'user') continue;
211
+ if (m.parts.every((p) => p.type === 'compaction')) continue;
212
+
213
+ const texts: string[] = [];
214
+ for (const p of m.parts) {
215
+ if (p.type === 'text' && p.text !== undefined) {
216
+ if (!p.text.startsWith('Continue:') && !p.text.startsWith('Continue from')) {
217
+ texts.push(p.text);
218
+ }
219
+ }
220
+ }
221
+ const text = texts.join(' ').trim();
222
+ if (text) {
223
+ return `Replay: ${text}`;
224
+ }
225
+ }
226
+
227
+ return null;
228
+ },
229
+ isCompactionActive(sessionId: string): boolean {
230
+ return activeSessions.has(sessionId);
231
+ },
232
+ beginCompaction(sessionId: string): void {
233
+ activeSessions.add(sessionId);
234
+ },
235
+ endCompaction(sessionId: string): void {
236
+ activeSessions.delete(sessionId);
237
+ },
238
+ };
239
+ }
240
+
241
+ const scopedService = new AsyncLocalStorage<CompactionService>();
242
+ let processDefaultService: CompactionService | undefined;
243
+
244
+ /** Resolves the service seeded for the active agent scope, falling back to
245
+ * one lazily created process-default service for consumers that run outside
246
+ * a composed scope (the current Jean2 server path). The process default
247
+ * keeps reading the active configuration live, exactly like the pre-C6
248
+ * module reads.
249
+ *
250
+ * The executor and the recovery policy both resolve through this accessor,
251
+ * so outside a composed scope they share the same process-default instance:
252
+ * one active-session set (the concurrency guard) and one failure-cooldown
253
+ * map for the whole process, exactly like the pre-C6 module globals. */
254
+ export function getCompactionService(): CompactionService {
255
+ return scopedService.getStore()
256
+ ?? (processDefaultService ??= createCompactionService({ id: 'compaction.process-default' }));
257
+ }
258
+
259
+ /** Seeds a service for the callback duration. `enterAgentScope` seeds the
260
+ * composed agent scope's service here. */
261
+ export function withCompactionService<T>(service: CompactionService, callback: () => T): T {
262
+ return scopedService.run(service, callback);
263
+ }
264
+
265
+ /** Test-only reset of the lazily created process default. Exported from this
266
+ * module only; no package subpath re-exports it. */
267
+ export function resetDefaultCompactionServiceForTests(): void {
268
+ processDefaultService = undefined;
269
+ }
270
+
271
+ /** Compatibility function over the scoped service: resolves the default
272
+ * policy with legacy-compatible defaults (model/provider stay null; env
273
+ * translation happens through the service options).
274
+ *
275
+ * Scope dependence: inside a composed agent scope this reflects the scope's
276
+ * frozen composition-time options; outside any scope it reads the live
277
+ * active configuration through the process-default service. The pre-C6
278
+ * module function read the live configuration unconditionally, so the
279
+ * unscoped path is the exact compat behavior. */
280
+ export function getDefaultCompactionPolicy(): CompactionPolicy {
281
+ const current = getCompactionService().options;
282
+ return {
283
+ modelId: null,
284
+ providerId: null,
285
+ maxOutputTokens: current.maxOutputTokens,
286
+ overflowThresholdRatio: null,
287
+ preserveRecentToolCount: current.preserveRecentToolCount,
288
+ preserveSmallToolChars: current.preserveSmallToolChars,
289
+ toolClearCharsThreshold: current.toolClearCharsThreshold,
290
+ maxPrunedToolCount: current.maxPrunedToolCount,
291
+ autoThresholdRatio: current.autoThresholdRatio,
292
+ autoReserveCapTokens: current.autoReserveCapTokens,
293
+ autoSafetyMarginTokens: current.autoSafetyMarginTokens,
294
+ };
295
+ }
296
+
297
+ /** Compatibility function over the scoped service. Scope dependence:
298
+ * composed scopes resolve through their frozen composition-time options;
299
+ * the unscoped path reads the live active configuration exactly like the
300
+ * pre-C6 module function. */
301
+ export function resolveCompactionPolicy(
302
+ sessionModelId: string | undefined,
303
+ sessionProviderId: string | undefined,
304
+ overrides?: Partial<CompactionPolicy>,
305
+ ): CompactionPolicy {
306
+ return getCompactionService().resolvePolicy(sessionModelId, sessionProviderId, overrides);
307
+ }
308
+
309
+ /** Compatibility function over the scoped service. Scope dependence:
310
+ * composed scopes resolve through their frozen composition-time options;
311
+ * the unscoped path reads the live active configuration exactly like the
312
+ * pre-C6 module function. */
313
+ export function computeAutoThreshold(
314
+ modelId: string | undefined,
315
+ policy?: CompactionPolicy,
316
+ ): AutoThresholdResult {
317
+ return getCompactionService().computeThreshold(modelId, policy);
318
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * C6 compaction recovery policy. Owns the pre-C6 reconciliation decisions
3
+ * that previously lived in the server store module: skip while compaction is
4
+ * genuinely in flight, clear a stuck compacting flag, and persist one
5
+ * append-only failure per orphaned trigger. The storage queries stay
6
+ * host-provided through `CompactionRecoveryDeps`, so the Capek policy never
7
+ * imports server SQL; the server store module is now a thin compat wiring
8
+ * over this domain.
9
+ */
10
+
11
+ import type { Message, Session } from '@capekai/types';
12
+ import type { BroadcastFn } from '../runtime/host-dependencies';
13
+ import { getCompactionService } from './policy';
14
+ import { persistCompactionFailure } from './task';
15
+
16
+ /** Inward-facing storage port the host (the current Jean2 server store)
17
+ * fulfills. Shapes are SDK structural copies; no SQL crosses the boundary. */
18
+ export interface CompactionRecoveryDeps {
19
+ /** True when the session's compacting flag is set (a stuck state). */
20
+ isSessionCompacting(sessionId: string): boolean;
21
+ /** Clears the stuck compacting flag and returns the updated session, or
22
+ * null when the session no longer exists. */
23
+ clearSessionCompacting(sessionId: string): Session | null;
24
+ /** Orphaned compaction triggers: user messages with a compaction part and
25
+ * no assistant outcome (assistant message with parentId pointing at it). */
26
+ listOrphanedCompactionTriggers(sessionId: string): Message[];
27
+ /** All session ids for startup-wide reconciliation. */
28
+ listSessionIds(): string[];
29
+ /** Broadcasts runtime events for the persisted failure records. */
30
+ broadcast: BroadcastFn;
31
+ /** Broadcasts session updates after clearing the stuck flag. */
32
+ broadcastSessionUpdated(session: Session): void;
33
+ }
34
+
35
+ export interface ReconcileOptions {
36
+ /**
37
+ * When false, skips broadcasting session.updated after clearing the compacting
38
+ * flag. Use this at startup before the broadcast callback is registered.
39
+ * @default true
40
+ */
41
+ broadcast?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Reconcile a single session's compaction state.
46
+ *
47
+ * This function:
48
+ * 1. Finds orphaned compaction triggers (triggers without any outcome)
49
+ * 2. Persists failure records for each orphaned trigger (idempotent)
50
+ * 3. Clears the compacting flag if set
51
+ *
52
+ * Returns the number of orphaned triggers reconciled.
53
+ */
54
+ export async function reconcileSessionCompaction(
55
+ sessionId: string,
56
+ deps: CompactionRecoveryDeps,
57
+ options: ReconcileOptions = {},
58
+ ): Promise<number> {
59
+ const { broadcast = true } = options;
60
+ const broadcastFn: BroadcastFn = broadcast ? deps.broadcast : () => {};
61
+ const broadcastSessUpdate = broadcast ? deps.broadcastSessionUpdated : () => {};
62
+
63
+ // If compaction is genuinely in-flight (tracked in-memory), skip reconciliation entirely.
64
+ // This prevents false "Compaction interrupted" failures when the user switches sessions
65
+ // while compaction is still running on the server.
66
+ if (getCompactionService().isCompactionActive(sessionId)) {
67
+ return 0;
68
+ }
69
+
70
+ // Always clear the compacting flag - it's stuck if we're recovering
71
+ if (deps.isSessionCompacting(sessionId)) {
72
+ const session = deps.clearSessionCompacting(sessionId);
73
+ if (broadcast && session) {
74
+ broadcastSessUpdate(session);
75
+ }
76
+ }
77
+
78
+ // Find orphaned triggers
79
+ const orphanedTriggers = deps.listOrphanedCompactionTriggers(sessionId);
80
+ const count = orphanedTriggers.length;
81
+
82
+ if (count === 0) {
83
+ return 0;
84
+ }
85
+
86
+ // Persist failure for each orphaned trigger
87
+ for (const trigger of orphanedTriggers) {
88
+ // Idempotent: once persisted, the failure message becomes the outcome,
89
+ // so the orphan query (NOT EXISTS outcome.parent_id) stops matching.
90
+ await persistCompactionFailure(
91
+ sessionId,
92
+ trigger.id,
93
+ 'Compaction interrupted (session recovered after crash or interruption)',
94
+ broadcastFn,
95
+ );
96
+ }
97
+
98
+ console.log(
99
+ `[compaction-recovery] Reconciled ${count} orphaned trigger(s) for session ${sessionId}`,
100
+ );
101
+
102
+ return count;
103
+ }
104
+
105
+ /**
106
+ * Run one-shot recovery across all sessions at startup.
107
+ *
108
+ * This scans all sessions to find orphaned compaction triggers (user messages
109
+ * with a compaction part that have no outcome). Once persisted, the failure
110
+ * message becomes the outcome, so the orphan query stops matching.
111
+ *
112
+ * Returns total count of orphaned triggers reconciled.
113
+ */
114
+ export async function reconcileAllSessionsCompaction(deps: CompactionRecoveryDeps): Promise<number> {
115
+ const sessionIds = new Set(deps.listSessionIds());
116
+
117
+ if (sessionIds.size === 0) {
118
+ console.log('[compaction-recovery] No sessions requiring compaction reconciliation found');
119
+ return 0;
120
+ }
121
+
122
+ console.log(
123
+ `[compaction-recovery] Reconciling ${sessionIds.size} session(s) for compaction state`,
124
+ );
125
+
126
+ let totalReconciled = 0;
127
+
128
+ // Startup path: disable broadcasting since the broadcast callback may not
129
+ // be registered yet when this runs at server startup.
130
+ for (const sessionId of sessionIds) {
131
+ totalReconciled += await reconcileSessionCompaction(sessionId, deps, { broadcast: false });
132
+ }
133
+
134
+ console.log(
135
+ `[compaction-recovery] Startup recovery complete: ${totalReconciled} orphaned trigger(s) reconciled`,
136
+ );
137
+
138
+ return totalReconciled;
139
+ }