@soleri/core 9.4.0 → 9.6.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 (228) hide show
  1. package/dist/adapters/claude-code-adapter.d.ts +27 -0
  2. package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
  3. package/dist/adapters/claude-code-adapter.js +111 -0
  4. package/dist/adapters/claude-code-adapter.js.map +1 -0
  5. package/dist/adapters/index.d.ts +9 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +10 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/registry.d.ts +21 -0
  10. package/dist/adapters/registry.d.ts.map +1 -0
  11. package/dist/adapters/registry.js +44 -0
  12. package/dist/adapters/registry.js.map +1 -0
  13. package/dist/adapters/types.d.ts +93 -0
  14. package/dist/adapters/types.d.ts.map +1 -0
  15. package/dist/adapters/types.js +10 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/brain/brain.d.ts +12 -1
  18. package/dist/brain/brain.d.ts.map +1 -1
  19. package/dist/brain/brain.js +106 -44
  20. package/dist/brain/brain.js.map +1 -1
  21. package/dist/brain/intelligence.d.ts.map +1 -1
  22. package/dist/brain/intelligence.js +36 -30
  23. package/dist/brain/intelligence.js.map +1 -1
  24. package/dist/chat/agent-loop.js +1 -1
  25. package/dist/chat/agent-loop.js.map +1 -1
  26. package/dist/chat/notifications.d.ts.map +1 -1
  27. package/dist/chat/notifications.js +4 -0
  28. package/dist/chat/notifications.js.map +1 -1
  29. package/dist/control/intent-router.d.ts +1 -0
  30. package/dist/control/intent-router.d.ts.map +1 -1
  31. package/dist/control/intent-router.js +11 -5
  32. package/dist/control/intent-router.js.map +1 -1
  33. package/dist/curator/curator.d.ts +4 -0
  34. package/dist/curator/curator.d.ts.map +1 -1
  35. package/dist/curator/curator.js +141 -27
  36. package/dist/curator/curator.js.map +1 -1
  37. package/dist/hooks/candidate-scorer.d.ts +28 -0
  38. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  39. package/dist/hooks/candidate-scorer.js +20 -0
  40. package/dist/hooks/candidate-scorer.js.map +1 -0
  41. package/dist/hooks/index.d.ts +2 -0
  42. package/dist/hooks/index.d.ts.map +1 -0
  43. package/dist/hooks/index.js +2 -0
  44. package/dist/hooks/index.js.map +1 -0
  45. package/dist/index.d.ts +14 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +12 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/llm/llm-client.d.ts.map +1 -1
  50. package/dist/llm/llm-client.js +1 -0
  51. package/dist/llm/llm-client.js.map +1 -1
  52. package/dist/packs/index.d.ts +3 -2
  53. package/dist/packs/index.d.ts.map +1 -1
  54. package/dist/packs/index.js +3 -2
  55. package/dist/packs/index.js.map +1 -1
  56. package/dist/packs/lockfile.d.ts +23 -1
  57. package/dist/packs/lockfile.d.ts.map +1 -1
  58. package/dist/packs/lockfile.js +50 -4
  59. package/dist/packs/lockfile.js.map +1 -1
  60. package/dist/packs/pack-installer.d.ts +10 -0
  61. package/dist/packs/pack-installer.d.ts.map +1 -1
  62. package/dist/packs/pack-installer.js +69 -2
  63. package/dist/packs/pack-installer.js.map +1 -1
  64. package/dist/packs/pack-lifecycle.d.ts +50 -0
  65. package/dist/packs/pack-lifecycle.d.ts.map +1 -0
  66. package/dist/packs/pack-lifecycle.js +91 -0
  67. package/dist/packs/pack-lifecycle.js.map +1 -0
  68. package/dist/packs/types.d.ts +64 -44
  69. package/dist/packs/types.d.ts.map +1 -1
  70. package/dist/packs/types.js +9 -0
  71. package/dist/packs/types.js.map +1 -1
  72. package/dist/persistence/sqlite-provider.d.ts +5 -1
  73. package/dist/persistence/sqlite-provider.d.ts.map +1 -1
  74. package/dist/persistence/sqlite-provider.js +22 -2
  75. package/dist/persistence/sqlite-provider.js.map +1 -1
  76. package/dist/planning/github-projection.d.ts +8 -8
  77. package/dist/planning/github-projection.d.ts.map +1 -1
  78. package/dist/planning/github-projection.js +42 -42
  79. package/dist/planning/github-projection.js.map +1 -1
  80. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  81. package/dist/planning/plan-lifecycle.js +6 -1
  82. package/dist/planning/plan-lifecycle.js.map +1 -1
  83. package/dist/plugins/types.d.ts +21 -21
  84. package/dist/queue/pipeline-runner.d.ts.map +1 -1
  85. package/dist/queue/pipeline-runner.js +4 -0
  86. package/dist/queue/pipeline-runner.js.map +1 -1
  87. package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
  88. package/dist/runtime/curator-extra-ops.js +9 -1
  89. package/dist/runtime/curator-extra-ops.js.map +1 -1
  90. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  91. package/dist/runtime/facades/memory-facade.js +169 -0
  92. package/dist/runtime/facades/memory-facade.js.map +1 -1
  93. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  94. package/dist/runtime/orchestrate-ops.js +133 -4
  95. package/dist/runtime/orchestrate-ops.js.map +1 -1
  96. package/dist/runtime/runtime.d.ts.map +1 -1
  97. package/dist/runtime/runtime.js +128 -90
  98. package/dist/runtime/runtime.js.map +1 -1
  99. package/dist/runtime/session-briefing.d.ts.map +1 -1
  100. package/dist/runtime/session-briefing.js +44 -11
  101. package/dist/runtime/session-briefing.js.map +1 -1
  102. package/dist/runtime/shutdown-registry.d.ts +36 -0
  103. package/dist/runtime/shutdown-registry.d.ts.map +1 -0
  104. package/dist/runtime/shutdown-registry.js +74 -0
  105. package/dist/runtime/shutdown-registry.js.map +1 -0
  106. package/dist/runtime/types.d.ts +10 -1
  107. package/dist/runtime/types.d.ts.map +1 -1
  108. package/dist/subagent/concurrency-manager.d.ts +29 -0
  109. package/dist/subagent/concurrency-manager.d.ts.map +1 -0
  110. package/dist/subagent/concurrency-manager.js +73 -0
  111. package/dist/subagent/concurrency-manager.js.map +1 -0
  112. package/dist/subagent/dispatcher.d.ts +41 -0
  113. package/dist/subagent/dispatcher.d.ts.map +1 -0
  114. package/dist/subagent/dispatcher.js +259 -0
  115. package/dist/subagent/dispatcher.js.map +1 -0
  116. package/dist/subagent/index.d.ts +14 -0
  117. package/dist/subagent/index.d.ts.map +1 -0
  118. package/dist/subagent/index.js +15 -0
  119. package/dist/subagent/index.js.map +1 -0
  120. package/dist/subagent/orphan-reaper.d.ts +37 -0
  121. package/dist/subagent/orphan-reaper.d.ts.map +1 -0
  122. package/dist/subagent/orphan-reaper.js +71 -0
  123. package/dist/subagent/orphan-reaper.js.map +1 -0
  124. package/dist/subagent/result-aggregator.d.ts +7 -0
  125. package/dist/subagent/result-aggregator.d.ts.map +1 -0
  126. package/dist/subagent/result-aggregator.js +57 -0
  127. package/dist/subagent/result-aggregator.js.map +1 -0
  128. package/dist/subagent/task-checkout.d.ts +36 -0
  129. package/dist/subagent/task-checkout.d.ts.map +1 -0
  130. package/dist/subagent/task-checkout.js +52 -0
  131. package/dist/subagent/task-checkout.js.map +1 -0
  132. package/dist/subagent/types.d.ts +114 -0
  133. package/dist/subagent/types.d.ts.map +1 -0
  134. package/dist/subagent/types.js +9 -0
  135. package/dist/subagent/types.js.map +1 -0
  136. package/dist/subagent/workspace-resolver.d.ts +35 -0
  137. package/dist/subagent/workspace-resolver.d.ts.map +1 -0
  138. package/dist/subagent/workspace-resolver.js +99 -0
  139. package/dist/subagent/workspace-resolver.js.map +1 -0
  140. package/dist/transport/http-server.d.ts.map +1 -1
  141. package/dist/transport/http-server.js +49 -3
  142. package/dist/transport/http-server.js.map +1 -1
  143. package/dist/transport/ws-server.d.ts.map +1 -1
  144. package/dist/transport/ws-server.js +7 -0
  145. package/dist/transport/ws-server.js.map +1 -1
  146. package/dist/vault/linking.d.ts +3 -4
  147. package/dist/vault/linking.d.ts.map +1 -1
  148. package/dist/vault/linking.js +79 -32
  149. package/dist/vault/linking.js.map +1 -1
  150. package/dist/vault/vault-maintenance.d.ts.map +1 -1
  151. package/dist/vault/vault-maintenance.js +7 -14
  152. package/dist/vault/vault-maintenance.js.map +1 -1
  153. package/dist/vault/vault-memories.d.ts.map +1 -1
  154. package/dist/vault/vault-memories.js +19 -9
  155. package/dist/vault/vault-memories.js.map +1 -1
  156. package/dist/vault/vault-schema.d.ts +1 -0
  157. package/dist/vault/vault-schema.d.ts.map +1 -1
  158. package/dist/vault/vault-schema.js +20 -0
  159. package/dist/vault/vault-schema.js.map +1 -1
  160. package/dist/vault/vault.d.ts.map +1 -1
  161. package/dist/vault/vault.js +7 -3
  162. package/dist/vault/vault.js.map +1 -1
  163. package/package.json +8 -2
  164. package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
  165. package/src/__tests__/adapters/registry.test.ts +100 -0
  166. package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
  167. package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
  168. package/src/__tests__/subagent/dispatcher.test.ts +195 -0
  169. package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
  170. package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
  171. package/src/__tests__/subagent/task-checkout.test.ts +86 -0
  172. package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
  173. package/src/adapters/claude-code-adapter.ts +163 -0
  174. package/src/adapters/index.ts +22 -0
  175. package/src/adapters/registry.ts +53 -0
  176. package/src/adapters/types.ts +114 -0
  177. package/src/brain/brain.ts +120 -46
  178. package/src/brain/intelligence.ts +42 -34
  179. package/src/chat/agent-loop.ts +1 -1
  180. package/src/chat/notifications.ts +4 -0
  181. package/src/control/intent-router.ts +10 -8
  182. package/src/curator/curator.ts +146 -29
  183. package/src/hooks/candidate-scorer.test.ts +76 -0
  184. package/src/hooks/candidate-scorer.ts +39 -0
  185. package/src/index.ts +40 -1
  186. package/src/llm/llm-client.ts +1 -0
  187. package/src/packs/index.ts +5 -1
  188. package/src/packs/lockfile.ts +70 -5
  189. package/src/packs/pack-installer.ts +78 -2
  190. package/src/packs/pack-lifecycle.ts +115 -0
  191. package/src/packs/pack-lockfile.test.ts +1 -1
  192. package/src/packs/pack-system.test.ts +1 -1
  193. package/src/packs/types.ts +40 -2
  194. package/src/persistence/sqlite-provider.ts +27 -2
  195. package/src/planning/github-projection.ts +48 -44
  196. package/src/planning/plan-lifecycle.ts +14 -1
  197. package/src/queue/pipeline-runner.ts +4 -0
  198. package/src/runtime/admin-setup-ops.test.ts +9 -4
  199. package/src/runtime/curator-extra-ops.test.ts +7 -0
  200. package/src/runtime/curator-extra-ops.ts +10 -1
  201. package/src/runtime/facades/curator-facade.test.ts +7 -0
  202. package/src/runtime/facades/memory-facade.ts +187 -0
  203. package/src/runtime/orchestrate-ops.ts +156 -4
  204. package/src/runtime/runtime.test.ts +50 -2
  205. package/src/runtime/runtime.ts +132 -89
  206. package/src/runtime/session-briefing.test.ts +94 -2
  207. package/src/runtime/session-briefing.ts +48 -12
  208. package/src/runtime/shutdown-registry.test.ts +151 -0
  209. package/src/runtime/shutdown-registry.ts +85 -0
  210. package/src/runtime/types.ts +10 -1
  211. package/src/subagent/concurrency-manager.ts +89 -0
  212. package/src/subagent/dispatcher.ts +326 -0
  213. package/src/subagent/index.ts +28 -0
  214. package/src/subagent/orphan-reaper.ts +82 -0
  215. package/src/subagent/result-aggregator.ts +66 -0
  216. package/src/subagent/task-checkout.ts +60 -0
  217. package/src/subagent/types.ts +138 -0
  218. package/src/subagent/workspace-resolver.ts +117 -0
  219. package/src/transport/http-server.ts +50 -3
  220. package/src/transport/ws-server.ts +8 -0
  221. package/src/vault/linking.test.ts +12 -0
  222. package/src/vault/linking.ts +90 -44
  223. package/src/vault/vault-maintenance.ts +11 -18
  224. package/src/vault/vault-memories.ts +21 -13
  225. package/src/vault/vault-scaling.test.ts +3 -2
  226. package/src/vault/vault-schema.ts +21 -0
  227. package/src/vault/vault.ts +8 -3
  228. package/vitest.config.ts +2 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Subagent runtime engine — types for spawning, managing, and aggregating
3
+ * results from child agent processes.
4
+ *
5
+ * The SubagentDispatcher composes: TaskCheckout, WorkspaceResolver,
6
+ * ConcurrencyManager, OrphanReaper, and RuntimeAdapterRegistry.
7
+ */
8
+
9
+ import type { AdapterSessionState, AdapterTokenUsage } from '../adapters/types.js';
10
+
11
+ // ─── Task ───────────────────────────────────────────────────────────
12
+
13
+ /** A task to be dispatched to a subagent */
14
+ export interface SubagentTask {
15
+ /** Unique task identifier */
16
+ taskId: string;
17
+ /** The prompt or task description for the subagent */
18
+ prompt: string;
19
+ /** Working directory for execution */
20
+ workspace: string;
21
+ /** Runtime adapter type (e.g., 'claude-code', 'codex'). Falls back to registry default. */
22
+ runtime?: string;
23
+ /** Task IDs this task depends on (must complete first) */
24
+ dependencies?: string[];
25
+ /** Timeout in milliseconds. Default: 300_000 (5 min) */
26
+ timeout?: number;
27
+ /** Additional context to pass to the adapter */
28
+ config?: Record<string, unknown>;
29
+ }
30
+
31
+ // ─── Status ─────────────────────────────────────────────────────────
32
+
33
+ /** Lifecycle status of a subagent task */
34
+ export type SubagentStatus = 'queued' | 'claimed' | 'running' | 'completed' | 'failed' | 'orphaned';
35
+
36
+ // ─── Result ─────────────────────────────────────────────────────────
37
+
38
+ /** Result from a single subagent execution */
39
+ export interface SubagentResult {
40
+ /** Task ID this result belongs to */
41
+ taskId: string;
42
+ /** Final status */
43
+ status: SubagentStatus;
44
+ /** Exit code from the adapter (0 = success) */
45
+ exitCode: number;
46
+ /** Human-readable summary of what the subagent did */
47
+ summary?: string;
48
+ /** Token usage */
49
+ usage?: AdapterTokenUsage;
50
+ /** Session state for potential resume */
51
+ sessionState?: AdapterSessionState;
52
+ /** Files changed by this subagent */
53
+ filesChanged?: string[];
54
+ /** Error message if failed */
55
+ error?: string;
56
+ /** Duration in milliseconds */
57
+ durationMs: number;
58
+ /** PID of the child process (if spawned) */
59
+ pid?: number;
60
+ }
61
+
62
+ // ─── Dispatch Options ───────────────────────────────────────────────
63
+
64
+ /** Options controlling how tasks are dispatched */
65
+ export interface DispatchOptions {
66
+ /** Run tasks in parallel (default: true) */
67
+ parallel?: boolean;
68
+ /** Max concurrent subagents (default: 3) */
69
+ maxConcurrent?: number;
70
+ /** Isolate each task in a git worktree (default: false) */
71
+ worktreeIsolation?: boolean;
72
+ /** Global timeout per task in ms (default: 300_000) */
73
+ timeout?: number;
74
+ /** Callback for per-task status updates */
75
+ onTaskUpdate?: (taskId: string, status: SubagentStatus) => void;
76
+ }
77
+
78
+ // ─── Aggregated Result ──────────────────────────────────────────────
79
+
80
+ /** Aggregated result from multiple subagent executions */
81
+ export interface AggregatedResult {
82
+ /** Overall status */
83
+ status: 'all-passed' | 'partial' | 'all-failed';
84
+ /** Total tasks dispatched */
85
+ totalTasks: number;
86
+ /** Count of completed tasks */
87
+ completed: number;
88
+ /** Count of failed tasks */
89
+ failed: number;
90
+ /** Sum of all token usage */
91
+ totalUsage: AdapterTokenUsage;
92
+ /** Deduplicated list of all files changed */
93
+ filesChanged: string[];
94
+ /** Combined summary from all tasks */
95
+ combinedSummary: string;
96
+ /** Total duration in milliseconds */
97
+ durationMs: number;
98
+ /** Per-task results */
99
+ results: SubagentResult[];
100
+ }
101
+
102
+ // ─── Claim Info ─────────────────────────────────────────────────────
103
+
104
+ /** Information about a task claim */
105
+ export interface ClaimInfo {
106
+ /** Task ID */
107
+ taskId: string;
108
+ /** Agent/process that claimed this task */
109
+ claimerId: string;
110
+ /** When the claim was made */
111
+ claimedAt: number;
112
+ }
113
+
114
+ // ─── Worktree Info ──────────────────────────────────────────────────
115
+
116
+ /** Information about an active git worktree */
117
+ export interface WorktreeInfo {
118
+ /** Task ID this worktree is for */
119
+ taskId: string;
120
+ /** Absolute path to the worktree */
121
+ path: string;
122
+ /** Branch name (if created) */
123
+ branch?: string;
124
+ /** When the worktree was created */
125
+ createdAt: number;
126
+ }
127
+
128
+ // ─── Tracked Process ────────────────────────────────────────────────
129
+
130
+ /** A tracked child process for orphan detection */
131
+ export interface TrackedProcess {
132
+ /** Process ID */
133
+ pid: number;
134
+ /** Task ID this process is executing */
135
+ taskId: string;
136
+ /** When the process was registered */
137
+ registeredAt: number;
138
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WorkspaceResolver — Git worktree isolation for subagent tasks.
3
+ *
4
+ * When isolation is requested, creates a dedicated git worktree per task
5
+ * at `<baseDir>/.soleri/worktrees/<taskId>/`. Falls back gracefully to the
6
+ * original workspace if git worktree creation fails.
7
+ */
8
+
9
+ import { execSync } from 'node:child_process';
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import type { WorktreeInfo } from './types.js';
14
+
15
+ const EXEC_OPTS = { encoding: 'utf-8' as const, timeout: 30_000 };
16
+
17
+ export class WorkspaceResolver {
18
+ private readonly baseDir: string;
19
+ private readonly worktrees = new Map<string, WorktreeInfo>();
20
+
21
+ constructor(baseDir: string) {
22
+ this.baseDir = baseDir;
23
+ }
24
+
25
+ /**
26
+ * Resolve a workspace path for a task.
27
+ *
28
+ * If `isolate` is true, creates a git worktree at
29
+ * `<baseDir>/.soleri/worktrees/<taskId>/` on branch `subagent/<taskId>`.
30
+ * Returns the worktree path on success, or the original `workspace` on failure.
31
+ *
32
+ * If `isolate` is false, returns `workspace` as-is.
33
+ */
34
+ resolve(taskId: string, workspace: string, isolate: boolean): string {
35
+ if (!isolate) {
36
+ return workspace;
37
+ }
38
+
39
+ try {
40
+ const worktreePath = join(this.baseDir, '.soleri', 'worktrees', taskId);
41
+ const branch = `subagent/${taskId}`;
42
+
43
+ // Ensure parent directory exists
44
+ const parentDir = join(this.baseDir, '.soleri', 'worktrees');
45
+ if (!existsSync(parentDir)) {
46
+ mkdirSync(parentDir, { recursive: true });
47
+ }
48
+
49
+ execSync(`git worktree add "${worktreePath}" -b "${branch}"`, {
50
+ ...EXEC_OPTS,
51
+ cwd: this.baseDir,
52
+ });
53
+
54
+ const info: WorktreeInfo = {
55
+ taskId,
56
+ path: worktreePath,
57
+ branch,
58
+ createdAt: Date.now(),
59
+ };
60
+ this.worktrees.set(taskId, info);
61
+
62
+ return worktreePath;
63
+ } catch (err) {
64
+ // Graceful fallback — log warning and return original workspace
65
+ console.warn(
66
+ `[WorkspaceResolver] Failed to create worktree for task "${taskId}":`,
67
+ err instanceof Error ? err.message : err,
68
+ );
69
+ return workspace;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Remove the worktree for a given task.
75
+ * Silently handles errors (e.g., worktree already removed).
76
+ */
77
+ cleanup(taskId: string): void {
78
+ const info = this.worktrees.get(taskId);
79
+ if (!info) {
80
+ return;
81
+ }
82
+
83
+ try {
84
+ execSync(`git worktree remove "${info.path}" --force`, { ...EXEC_OPTS, cwd: this.baseDir });
85
+ } catch {
86
+ // Silently ignore — worktree may already be gone
87
+ }
88
+
89
+ // Clean up the branch as well
90
+ if (info.branch) {
91
+ try {
92
+ execSync(`git branch -D "${info.branch}"`, { ...EXEC_OPTS, cwd: this.baseDir });
93
+ } catch {
94
+ // Silently ignore — branch may not exist
95
+ }
96
+ }
97
+
98
+ this.worktrees.delete(taskId);
99
+ }
100
+
101
+ /** Remove all active worktrees. */
102
+ cleanupAll(): void {
103
+ for (const taskId of Array.from(this.worktrees.keys())) {
104
+ this.cleanup(taskId);
105
+ }
106
+ }
107
+
108
+ /** Return all currently active worktrees. */
109
+ listActive(): WorktreeInfo[] {
110
+ return [...this.worktrees.values()];
111
+ }
112
+
113
+ /** Check whether a worktree exists for the given task. */
114
+ isActive(taskId: string): boolean {
115
+ return this.worktrees.has(taskId);
116
+ }
117
+ }
@@ -151,7 +151,18 @@ export class HttpMcpServer {
151
151
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
152
152
 
153
153
  if (method === 'POST') {
154
- const body = await this.readBody(req);
154
+ let body: unknown;
155
+ try {
156
+ body = await this.readBody(req);
157
+ } catch (err) {
158
+ const statusCode = (err as { statusCode?: number }).statusCode;
159
+ if (statusCode === 413) {
160
+ this.sendJSON(res, 413, { error: 'Request body too large' });
161
+ return;
162
+ }
163
+ this.sendJSON(res, 400, { error: 'Failed to read request body' });
164
+ return;
165
+ }
155
166
 
156
167
  if (sessionId) {
157
168
  const session = this.sessions.get(sessionId);
@@ -241,10 +252,41 @@ export class HttpMcpServer {
241
252
  }
242
253
 
243
254
  private readBody(req: IncomingMessage): Promise<unknown> {
255
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
256
+ const BODY_TIMEOUT = 30_000; // 30 seconds
257
+
244
258
  return new Promise((resolve, reject) => {
259
+ let size = 0;
245
260
  const chunks: Buffer[] = [];
246
- req.on('data', (chunk: Buffer) => chunks.push(chunk));
261
+ let settled = false;
262
+
263
+ const timer = setTimeout(() => {
264
+ if (!settled) {
265
+ settled = true;
266
+ req.destroy();
267
+ reject(new Error('Request body timeout'));
268
+ }
269
+ }, BODY_TIMEOUT);
270
+
271
+ const cleanup = () => clearTimeout(timer);
272
+
273
+ req.on('data', (chunk: Buffer) => {
274
+ size += chunk.length;
275
+ if (size > MAX_BODY_SIZE) {
276
+ if (!settled) {
277
+ settled = true;
278
+ cleanup();
279
+ req.destroy();
280
+ reject(Object.assign(new Error('Request body too large'), { statusCode: 413 }));
281
+ }
282
+ return;
283
+ }
284
+ chunks.push(chunk);
285
+ });
247
286
  req.on('end', () => {
287
+ if (settled) return;
288
+ settled = true;
289
+ cleanup();
248
290
  try {
249
291
  const text = Buffer.concat(chunks).toString('utf-8');
250
292
  resolve(text.length > 0 ? JSON.parse(text) : {});
@@ -252,7 +294,12 @@ export class HttpMcpServer {
252
294
  reject(e);
253
295
  }
254
296
  });
255
- req.on('error', reject);
297
+ req.on('error', (e) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ cleanup();
301
+ reject(e);
302
+ });
256
303
  });
257
304
  }
258
305
 
@@ -230,11 +230,19 @@ export class WsMcpServer {
230
230
 
231
231
  // Set up frame reader
232
232
  const maxSize = this.config.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE;
233
+ const maxBufferSize = maxSize; // raw buffer limit matches max message size (1 MB default)
233
234
  let buffer = Buffer.alloc(0);
234
235
 
235
236
  socket.on('data', (chunk: Buffer) => {
236
237
  buffer = Buffer.concat([buffer, chunk]);
237
238
 
239
+ // Guard against unbounded buffer growth (e.g. slow-drip DoS with no complete frames)
240
+ if (buffer.length > maxBufferSize) {
241
+ this.sendClose(socket, 1009, 'Buffer exceeded max size');
242
+ socket.destroy();
243
+ return;
244
+ }
245
+
238
246
  // Process all complete frames in the buffer
239
247
  while (buffer.length >= 2) {
240
248
  const frame = this.parseFrame(buffer);
@@ -68,6 +68,12 @@ class LinkingMockDB implements PersistenceProvider {
68
68
  get<T>(sql: string, params?: unknown[]): T | undefined {
69
69
  const p = params ?? [];
70
70
  if (sql.includes('COUNT(*)')) {
71
+ if (sql.includes('NOT IN')) {
72
+ // Count orphan entries (no links)
73
+ const linkedIds = new Set(this.links.flatMap((l) => [l.source_id, l.target_id]));
74
+ const count = this.entries.filter((e) => !linkedIds.has(e.id)).length;
75
+ return { count } as T;
76
+ }
71
77
  const id = p[0] as string;
72
78
  const count = this.links.filter((l) => l.source_id === id || l.target_id === id).length;
73
79
  return { count } as T;
@@ -102,6 +108,12 @@ class LinkingMockDB implements PersistenceProvider {
102
108
  const ids = new Set(p.slice(0, half) as string[]);
103
109
  return this.links.filter((l) => ids.has(l.source_id) || ids.has(l.target_id)) as T[];
104
110
  }
111
+ if (sql.includes('FROM entries WHERE id IN')) {
112
+ const ids = new Set(p as string[]);
113
+ return this.entries
114
+ .filter((e) => ids.has(e.id))
115
+ .map((e) => ({ id: e.id, title: e.title, type: e.type, domain: e.domain })) as T[];
116
+ }
105
117
  if (sql.includes('NOT IN')) {
106
118
  const limit = p[0] as number;
107
119
  const linkedIds = new Set(this.links.flatMap((l) => [l.source_id, l.target_id]));
@@ -145,6 +145,7 @@ export class LinkManager {
145
145
  /**
146
146
  * Walk the link graph from a starting entry up to `depth` hops.
147
147
  * BFS — walks both outgoing and incoming links (undirected).
148
+ * Batch-loads all links per frontier level to avoid N+1 queries.
148
149
  */
149
150
  traverse(entryId: string, depth: number = 2): LinkedEntry[] {
150
151
  const visited = new Set<string>([entryId]);
@@ -152,9 +153,47 @@ export class LinkManager {
152
153
  let frontier = [entryId];
153
154
 
154
155
  for (let d = 0; d < depth && frontier.length > 0; d++) {
156
+ // Batch-load all links for entire frontier in one query
157
+ const allLinks = this.getAllLinksForEntries(frontier);
158
+
159
+ // Collect unvisited neighbor IDs
160
+ const neighborMap = new Map<
161
+ string,
162
+ { link: VaultLink; direction: 'outgoing' | 'incoming' }
163
+ >();
164
+ for (const link of allLinks) {
165
+ // Outgoing: source is in frontier, target is the neighbor
166
+ if (frontier.includes(link.sourceId) && !visited.has(link.targetId)) {
167
+ if (!neighborMap.has(link.targetId)) {
168
+ neighborMap.set(link.targetId, { link, direction: 'outgoing' });
169
+ }
170
+ }
171
+ // Incoming: target is in frontier, source is the neighbor
172
+ if (frontier.includes(link.targetId) && !visited.has(link.sourceId)) {
173
+ if (!neighborMap.has(link.sourceId)) {
174
+ neighborMap.set(link.sourceId, { link, direction: 'incoming' });
175
+ }
176
+ }
177
+ }
178
+
179
+ if (neighborMap.size === 0) break;
180
+
181
+ // Batch-load entry metadata for all neighbors in one query
182
+ const neighborIds = [...neighborMap.keys()];
183
+ const metaMap = this.getEntryMetaBatch(neighborIds);
184
+
155
185
  const nextFrontier: string[] = [];
156
- for (const currentId of frontier) {
157
- this.collectNeighbors(currentId, visited, nextFrontier, result);
186
+ for (const [neighborId, { link, direction }] of neighborMap) {
187
+ visited.add(neighborId);
188
+ nextFrontier.push(neighborId);
189
+ const entry = metaMap.get(neighborId);
190
+ if (!entry) continue;
191
+ result.push({
192
+ ...entry,
193
+ linkType: link.linkType,
194
+ linkDirection: direction,
195
+ linkNote: link.note,
196
+ });
158
197
  }
159
198
  frontier = nextFrontier;
160
199
  }
@@ -162,43 +201,6 @@ export class LinkManager {
162
201
  return result;
163
202
  }
164
203
 
165
- /** Collect unvisited outgoing and incoming neighbors for BFS. */
166
- private collectNeighbors(
167
- currentId: string,
168
- visited: Set<string>,
169
- nextFrontier: string[],
170
- result: LinkedEntry[],
171
- ): void {
172
- for (const link of this.getLinks(currentId)) {
173
- this.visitNeighbor(link.targetId, link, 'outgoing', visited, nextFrontier, result);
174
- }
175
- for (const link of this.getBacklinks(currentId)) {
176
- this.visitNeighbor(link.sourceId, link, 'incoming', visited, nextFrontier, result);
177
- }
178
- }
179
-
180
- /** Visit a single neighbor node if not already visited. */
181
- private visitNeighbor(
182
- neighborId: string,
183
- link: VaultLink,
184
- direction: 'outgoing' | 'incoming',
185
- visited: Set<string>,
186
- nextFrontier: string[],
187
- result: LinkedEntry[],
188
- ): void {
189
- if (visited.has(neighborId)) return;
190
- visited.add(neighborId);
191
- nextFrontier.push(neighborId);
192
- const entry = this.getEntryMeta(neighborId);
193
- if (!entry) return;
194
- result.push({
195
- ...entry,
196
- linkType: link.linkType,
197
- linkDirection: direction,
198
- linkNote: link.note,
199
- });
200
- }
201
-
202
204
  // ── Bulk Queries ────────────────────────────────────────────────────
203
205
 
204
206
  /** Get all links where either source or target is in the given ID set. */
@@ -317,20 +319,43 @@ export class LinkManager {
317
319
  const batchSize = opts?.batchSize ?? 50;
318
320
  const start = Date.now();
319
321
 
320
- const orphans = this.getOrphans(10000);
321
322
  let processed = 0;
322
323
  let linksCreated = 0;
323
324
  const preview: Array<{ sourceId: string; targetId: string; linkType: string; score: number }> =
324
325
  [];
325
326
 
326
- for (let i = 0; i < orphans.length; i += batchSize) {
327
- const batch = orphans.slice(i, i + batchSize);
328
- for (const entry of batch) {
327
+ // Estimate total for progress reporting (single COUNT query)
328
+ let totalEstimate = 0;
329
+ try {
330
+ const countRow = this.provider.get<{ count: number }>(
331
+ `SELECT COUNT(*) as count FROM entries
332
+ WHERE id NOT IN (SELECT source_id FROM vault_links)
333
+ AND id NOT IN (SELECT target_id FROM vault_links)`,
334
+ );
335
+ totalEstimate = countRow?.count ?? 0;
336
+ } catch {
337
+ // fall through with 0
338
+ }
339
+
340
+ // Process orphans in batches of batchSize instead of loading all at once.
341
+ // After each batch, successfully linked entries are no longer orphans,
342
+ // so the next getOrphans() call returns the next set.
343
+ // For dry-run mode, we must track processed IDs to avoid re-fetching the same orphans.
344
+ const processedIds = new Set<string>();
345
+ // eslint-disable-next-line no-constant-condition
346
+ while (true) {
347
+ const batch = this.getOrphans(batchSize);
348
+ // Filter out already-processed entries (relevant for dry-run where orphan status doesn't change)
349
+ const unprocessed = batch.filter((e) => !processedIds.has(e.id));
350
+ if (unprocessed.length === 0) break;
351
+
352
+ for (const entry of unprocessed) {
353
+ processedIds.add(entry.id);
329
354
  const created = this.processOrphan(entry.id, threshold, maxLinks, dryRun, preview);
330
355
  linksCreated += created;
331
356
  processed++;
332
357
  }
333
- opts?.onProgress?.({ processed, total: orphans.length, linksCreated });
358
+ opts?.onProgress?.({ processed, total: totalEstimate, linksCreated });
334
359
  }
335
360
 
336
361
  return {
@@ -381,6 +406,27 @@ export class LinkManager {
381
406
  return null;
382
407
  }
383
408
  }
409
+
410
+ /** Batch-load entry metadata for multiple IDs in a single query. */
411
+ private getEntryMetaBatch(
412
+ entryIds: string[],
413
+ ): Map<string, Omit<LinkedEntry, 'linkType' | 'linkDirection' | 'linkNote'>> {
414
+ const result = new Map<string, Omit<LinkedEntry, 'linkType' | 'linkDirection' | 'linkNote'>>();
415
+ if (entryIds.length === 0) return result;
416
+ try {
417
+ const placeholders = entryIds.map(() => '?').join(',');
418
+ const rows = this.provider.all<{ id: string; title: string; type: string; domain: string }>(
419
+ `SELECT id, title, type, domain FROM entries WHERE id IN (${placeholders})`,
420
+ entryIds,
421
+ );
422
+ for (const row of rows) {
423
+ result.set(row.id, row);
424
+ }
425
+ } catch {
426
+ // graceful degradation
427
+ }
428
+ return result;
429
+ }
384
430
  }
385
431
 
386
432
  // ── Free-standing helpers ─────────────────────────────────────────────
@@ -111,25 +111,18 @@ export function archive(
111
111
  const reason = options.reason ?? `Archived: older than ${options.olderThanDays} days`;
112
112
 
113
113
  return provider.transaction(() => {
114
- const candidates = provider.all<{ id: string }>('SELECT id FROM entries WHERE updated_at < ?', [
115
- cutoff,
116
- ]);
117
-
118
- if (candidates.length === 0) return { archived: 0 };
119
-
120
- let archived = 0;
121
- for (const { id } of candidates) {
122
- provider.run(
123
- `INSERT OR IGNORE INTO entries_archive (id, type, domain, title, severity, description, context, example, counter_example, why, tags, applies_to, created_at, updated_at, valid_from, valid_until, archive_reason)
124
- SELECT id, type, domain, title, severity, description, context, example, counter_example, why, tags, applies_to, created_at, updated_at, valid_from, valid_until, ?
125
- FROM entries WHERE id = ?`,
126
- [reason, id],
127
- );
128
- const result = provider.run('DELETE FROM entries WHERE id = ?', [id]);
129
- archived += result.changes;
130
- }
114
+ // Bulk INSERT INTO ... SELECT copies all matching entries to archive in one query
115
+ provider.run(
116
+ `INSERT OR IGNORE INTO entries_archive (id, type, domain, title, severity, description, context, example, counter_example, why, tags, applies_to, created_at, updated_at, valid_from, valid_until, archive_reason)
117
+ SELECT id, type, domain, title, severity, description, context, example, counter_example, why, tags, applies_to, created_at, updated_at, valid_from, valid_until, ?
118
+ FROM entries WHERE updated_at < ?`,
119
+ [reason, cutoff],
120
+ );
121
+
122
+ // Bulk DELETE — removes all archived entries in one query
123
+ const result = provider.run('DELETE FROM entries WHERE updated_at < ?', [cutoff]);
131
124
 
132
- return { archived };
125
+ return { archived: result.changes };
133
126
  });
134
127
  }
135
128
 
@@ -299,21 +299,29 @@ export function memoryTopics(
299
299
  export function memoriesByProject(
300
300
  provider: PersistenceProvider,
301
301
  ): Array<{ project: string; count: number; memories: Memory[] }> {
302
- const rows = provider.all<{ project: string; count: number }>(
303
- 'SELECT project_path as project, COUNT(*) as count FROM memories WHERE archived_at IS NULL GROUP BY project_path ORDER BY count DESC',
302
+ // Single query fetching all non-archived memories, grouped client-side by project
303
+ const allRows = provider.all<Record<string, unknown>>(
304
+ 'SELECT * FROM memories WHERE archived_at IS NULL ORDER BY project_path, created_at DESC',
304
305
  );
305
306
 
306
- return rows.map((row) => {
307
- const mems = provider.all<Record<string, unknown>>(
308
- 'SELECT * FROM memories WHERE project_path = ? AND archived_at IS NULL ORDER BY created_at DESC',
309
- [row.project],
310
- );
311
- return {
312
- project: row.project,
313
- count: row.count,
314
- memories: mems.map(rowToMemory),
315
- };
316
- });
307
+ const projectMap = new Map<string, Memory[]>();
308
+ for (const row of allRows) {
309
+ const memory = rowToMemory(row);
310
+ const project = memory.projectPath;
311
+ if (!projectMap.has(project)) {
312
+ projectMap.set(project, []);
313
+ }
314
+ projectMap.get(project)!.push(memory);
315
+ }
316
+
317
+ // Sort by count descending (matching original behavior)
318
+ return [...projectMap.entries()]
319
+ .map(([project, memories]) => ({
320
+ project,
321
+ count: memories.length,
322
+ memories,
323
+ }))
324
+ .sort((a, b) => b.count - a.count);
317
325
  }
318
326
 
319
327
  // ── Helper ──────────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ import { Vault } from './vault.js';
10
10
  import { Brain } from '../brain/brain.js';
11
11
  import type { IntelligenceEntry } from '../intelligence/types.js';
12
12
 
13
+ const isCI = !!process.env.CI;
13
14
  const DOMAINS = ['design', 'a11y', 'performance', 'security', 'architecture', 'testing', 'ux'];
14
15
  const TYPES: IntelligenceEntry['type'][] = ['pattern', 'anti-pattern', 'rule', 'playbook'];
15
16
  const SEVERITIES: IntelligenceEntry['severity'][] = ['critical', 'warning', 'suggestion'];
@@ -76,7 +77,7 @@ describe('Vault Scaling — 10K entries', () => {
76
77
  const elapsed = performance.now() - start;
77
78
 
78
79
  expect(results.length).toBeGreaterThan(0);
79
- expect(elapsed).toBeLessThan(50);
80
+ expect(elapsed).toBeLessThan(isCI ? 500 : 50);
80
81
  }
81
82
  });
82
83
 
@@ -89,7 +90,7 @@ describe('Vault Scaling — 10K entries', () => {
89
90
  const elapsed = performance.now() - start;
90
91
 
91
92
  expect(results.length).toBeGreaterThan(0);
92
- expect(elapsed).toBeLessThan(50);
93
+ expect(elapsed).toBeLessThan(isCI ? 500 : 50);
93
94
  });
94
95
 
95
96
  test('list with filters under 200ms at 10K', () => {