@johpaz/hive-sdk 0.1.4 → 0.1.6

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 (278) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +78 -23
  3. package/bun.lock +55 -29
  4. package/bunfig.toml +4 -2
  5. package/docs/API-AGENTS.md +78 -27
  6. package/docs/API-CONTEXT-COMPILER.md +31 -34
  7. package/docs/API-TOOLS-SKILLS-CHANNELS.md +58 -22
  8. package/docs/HIVE-HARNESS.md +1 -1
  9. package/docs/INDEX.md +4 -4
  10. package/docs/TEMPLATE-HIVE-APP.md +10 -10
  11. package/package.json +17 -12
  12. package/packages/cli/package.json +2 -2
  13. package/packages/cli/src/commands/create-app.test.ts +36 -7
  14. package/packages/cli/src/commands/init.ts +3 -3
  15. package/packages/cli/src/commands/run.ts +1 -1
  16. package/packages/cli/src/commands/test.ts +37 -25
  17. package/packages/cli/src/commands/trace.ts +30 -28
  18. package/packages/cli/templates/hive-app/.env.example +10 -2
  19. package/packages/cli/templates/hive-app/README.md +103 -0
  20. package/packages/cli/templates/hive-app/hive.config.ts +9 -3
  21. package/packages/cli/templates/hive-app/src/agents/coordinator.ts +8 -1
  22. package/packages/cli/templates/hive-app/src/main.ts +12 -19
  23. package/packages/core/package.json +13 -12
  24. package/packages/core/src/agent/acceptance-checks.ts +172 -0
  25. package/packages/core/src/agent/agent-catalog.ts +348 -0
  26. package/packages/core/src/agent/agent-loop.ts +1373 -0
  27. package/packages/core/src/agent/capability-search.ts +186 -0
  28. package/packages/core/src/agent/catalog-selector.ts +103 -0
  29. package/packages/core/src/agent/{Compaction.ts → compaction.ts} +86 -63
  30. package/packages/core/src/agent/context-compiler.ts +689 -0
  31. package/packages/core/src/agent/conversation-store.ts +381 -0
  32. package/packages/core/src/agent/curator.ts +276 -0
  33. package/packages/core/src/agent/delegation-runtime.ts +241 -0
  34. package/packages/core/src/agent/goal-runner.ts +323 -0
  35. package/packages/core/src/agent/index.ts +17 -12
  36. package/packages/core/src/agent/llm-client.ts +266 -0
  37. package/packages/core/src/agent/llm-providers/anthropic.ts +264 -0
  38. package/packages/core/src/agent/llm-providers/deepseek.ts +8 -0
  39. package/packages/core/src/agent/{providers → llm-providers}/gemini.ts +98 -60
  40. package/packages/core/src/agent/llm-providers/groq.ts +5 -0
  41. package/packages/core/src/agent/llm-providers/hiveagents.ts +253 -0
  42. package/packages/core/src/agent/{providers → llm-providers}/interface.ts +73 -13
  43. package/packages/core/src/agent/llm-providers/kimi.ts +8 -0
  44. package/packages/core/src/agent/llm-providers/minimax.ts +13 -0
  45. package/packages/core/src/agent/llm-providers/mistral.ts +5 -0
  46. package/packages/core/src/agent/llm-providers/modelscope.ts +5 -0
  47. package/packages/core/src/agent/llm-providers/nvidia.ts +5 -0
  48. package/packages/core/src/agent/{providers → llm-providers}/ollama.ts +31 -5
  49. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +418 -0
  50. package/packages/core/src/agent/llm-providers/openai.ts +5 -0
  51. package/packages/core/src/agent/llm-providers/opencode-go.ts +9 -0
  52. package/packages/core/src/agent/llm-providers/openrouter.ts +5 -0
  53. package/packages/core/src/agent/llm-providers/qwen.ts +5 -0
  54. package/packages/core/src/agent/llm-providers/z-ai.ts +5 -0
  55. package/packages/core/src/agent/minimal-loadout.ts +47 -0
  56. package/packages/core/src/agent/playbook-selector.ts +119 -0
  57. package/packages/core/src/agent/{PromptBuilder.ts → prompt-builder.ts} +21 -22
  58. package/packages/core/src/{harness → agent}/proof-packet.ts +16 -21
  59. package/packages/core/src/agent/providers/index.ts +35 -16
  60. package/packages/core/src/agent/reflector.ts +320 -0
  61. package/packages/core/src/agent/routing-intent.ts +22 -0
  62. package/packages/core/src/{harness → agent}/run-epoch.ts +4 -3
  63. package/packages/core/src/{harness → agent}/run-store.ts +142 -81
  64. package/packages/core/src/agent/{Service.ts → service.ts} +37 -26
  65. package/packages/core/src/agent/skill-selector.ts +374 -0
  66. package/packages/core/src/agent/stuck-loop.ts +209 -0
  67. package/packages/core/src/agent/{selectors/ToolSelector.ts → tool-selector.ts} +188 -178
  68. package/packages/core/src/{ace/Tracer.ts → agent/tracer.ts} +37 -27
  69. package/packages/core/src/api/createAgent.test.ts +139 -27
  70. package/packages/core/src/api/createAgent.ts +232 -44
  71. package/packages/core/src/artifacts/store.ts +162 -0
  72. package/packages/core/src/canvas/canvas-manager.ts +161 -0
  73. package/packages/core/src/canvas/canvas.test.ts +8 -4
  74. package/packages/core/src/canvas/emitter.ts +131 -80
  75. package/packages/core/src/canvas/index.ts +1 -3
  76. package/packages/core/src/channels/base.ts +9 -1
  77. package/packages/core/src/channels/discord.ts +5 -4
  78. package/packages/core/src/channels/manager.ts +122 -30
  79. package/packages/core/src/channels/slack.ts +5 -4
  80. package/packages/core/src/channels/telegram.ts +36 -6
  81. package/packages/core/src/channels/webchat.ts +11 -10
  82. package/packages/core/src/channels/whatsapp.ts +23 -7
  83. package/packages/core/src/config/index.ts +13 -2
  84. package/packages/core/src/config/loader.ts +76 -29
  85. package/packages/core/src/ethics/EthicsGuard.test.ts +90 -36
  86. package/packages/core/src/ethics/EthicsGuard.ts +51 -47
  87. package/packages/core/src/events/agent-bus.ts +44 -68
  88. package/packages/core/src/events/channel-narration.ts +150 -0
  89. package/packages/core/src/events/narration.ts +82 -0
  90. package/packages/core/src/events/tool-narration.ts +62 -0
  91. package/packages/core/src/gateway/delegation-groups.ts +258 -0
  92. package/packages/core/src/{harness → gateway}/durable-queue.ts +102 -42
  93. package/packages/core/src/{harness → gateway}/job-store.ts +85 -48
  94. package/packages/core/src/gateway/lane-queue.ts +173 -0
  95. package/packages/core/src/gateway/notification-inbox.ts +57 -0
  96. package/packages/core/src/gateway/server.ts +1 -1
  97. package/packages/core/src/harness/index.ts +46 -27
  98. package/packages/core/src/index.ts +33 -27
  99. package/packages/core/src/mcp/hot-reload.ts +32 -23
  100. package/packages/core/src/mcp/index.ts +6 -3
  101. package/packages/core/src/mcp/singleton.ts +1 -4
  102. package/packages/core/src/mcp/tool-sync.ts +138 -0
  103. package/packages/core/src/memory/Scratchpad.test.ts +39 -20
  104. package/packages/core/src/memory/Scratchpad.ts +27 -34
  105. package/packages/core/src/multimodal/vision-service.ts +44 -38
  106. package/packages/core/src/resilience/retry.ts +95 -0
  107. package/packages/core/src/scheduler/CronScheduler.ts +334 -287
  108. package/packages/core/src/scheduler/index.ts +9 -7
  109. package/packages/core/src/scheduler/integration.ts +46 -26
  110. package/packages/core/src/scheduler/scheduler.test.ts +9 -13
  111. package/packages/core/src/scheduler/types.ts +7 -2
  112. package/packages/core/src/security/Pairing.ts +1 -1
  113. package/packages/core/src/skills/bundled/a2ui/a2ui_dashboard/SKILL.md +176 -0
  114. package/packages/core/src/skills/bundled/a2ui/a2ui_form/SKILL.md +202 -0
  115. package/packages/core/src/skills/bundled/a2ui/a2ui_interactive/SKILL.md +206 -0
  116. package/packages/core/src/skills/bundled/agents/agent_spawner/SKILL.md +173 -0
  117. package/packages/core/src/skills/bundled/agents/memory_manager/SKILL.md +143 -0
  118. package/packages/core/src/skills/bundled/agents/research_and_remember/SKILL.md +139 -0
  119. package/packages/core/src/skills/bundled/agents/task_orchestrator/SKILL.md +98 -0
  120. package/packages/core/src/skills/bundled/api/api_client/SKILL.md +132 -0
  121. package/packages/core/src/skills/bundled/cli/cli_pipeline/SKILL.md +135 -0
  122. package/packages/core/src/skills/bundled/cli/cli_safe_exec/SKILL.md +125 -0
  123. package/packages/core/src/skills/bundled/cli/software_engineering/SKILL.md +23 -0
  124. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +188 -0
  125. package/packages/core/src/skills/bundled/cron_reminder/SKILL.md +112 -0
  126. package/packages/core/src/skills/bundled/filesystem/file_manager/SKILL.md +118 -0
  127. package/packages/core/src/skills/bundled/filesystem/file_read_and_summarize/SKILL.md +109 -0
  128. package/packages/core/src/skills/bundled/filesystem/file_writer/SKILL.md +129 -0
  129. package/packages/core/src/skills/bundled/filesystem/workspace_file_operator/SKILL.md +22 -0
  130. package/packages/core/src/skills/bundled/office/office_document_manager/SKILL.md +262 -0
  131. package/packages/core/src/skills/bundled/search_knowledge/capability_discovery/SKILL.md +75 -0
  132. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +120 -0
  133. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +109 -0
  134. package/packages/core/src/skills/bundled/web/web_monitor/SKILL.md +127 -0
  135. package/packages/core/src/skills/bundled/web/web_research/SKILL.md +119 -0
  136. package/packages/core/src/skills/bundled-data.generated.ts +731 -2678
  137. package/packages/core/src/skills/skills.test.ts +52 -11
  138. package/packages/core/src/{harness → storage}/boot-id.ts +5 -2
  139. package/packages/core/src/storage/bootstrap.ts +151 -0
  140. package/packages/core/src/storage/causal-events.ts +84 -0
  141. package/packages/core/src/storage/collections.ts +680 -0
  142. package/packages/core/src/storage/crypto.ts +205 -74
  143. package/packages/core/src/{harness/db-helpers.ts → storage/hive.ts} +63 -7
  144. package/packages/core/src/storage/hivedb.ts +61 -0
  145. package/packages/core/src/storage/index.ts +111 -18
  146. package/packages/core/src/storage/model-id.ts +53 -0
  147. package/packages/core/src/storage/onboarding.ts +540 -972
  148. package/packages/core/src/storage/reconcile.ts +238 -0
  149. package/packages/core/src/storage/seed.ts +572 -406
  150. package/packages/core/src/storage/usage.ts +285 -225
  151. package/packages/core/src/storage/user-email.ts +11 -0
  152. package/packages/core/src/swarm/AgentExecutor.ts +1 -1
  153. package/packages/core/src/swarm/EventBridge.ts +1 -1
  154. package/packages/core/src/swarm/index.ts +12 -9
  155. package/packages/core/src/tool-runtime/index.ts +146 -23
  156. package/packages/core/src/tool-runtime/tool-worker.ts +2 -2
  157. package/packages/core/src/tool-runtime/worker-tools.ts +27 -0
  158. package/packages/core/src/{canvas/a2ui-tools.ts → tools/a2ui/index.ts} +17 -8
  159. package/packages/core/src/tools/agents/get-available-models.ts +36 -54
  160. package/packages/core/src/tools/agents/index.ts +784 -292
  161. package/packages/core/src/tools/api/api-request.test.ts +164 -0
  162. package/packages/core/src/tools/api/api-request.ts +174 -0
  163. package/packages/core/src/tools/api/index.ts +16 -0
  164. package/packages/core/src/tools/cli/index.ts +4 -0
  165. package/packages/core/src/tools/core/index.ts +281 -112
  166. package/packages/core/src/tools/cron/index.ts +121 -124
  167. package/packages/core/src/tools/index.ts +63 -78
  168. package/packages/core/src/tools/office/office-escribir-xlsx.ts +3 -1
  169. package/packages/core/src/tools/types.ts +3 -1
  170. package/packages/core/src/tools/web/artifact-inspect.ts +23 -0
  171. package/packages/core/src/tools/web/browser-backend.ts +129 -0
  172. package/packages/core/src/tools/web/browser-screenshot.ts +26 -5
  173. package/packages/core/src/tools/web/browser-service.ts +80 -35
  174. package/packages/core/src/tools/web/browser-type.ts +3 -8
  175. package/packages/core/src/tools/web/index.ts +4 -4
  176. package/packages/core/src/tools/web/webview-backend.ts +412 -0
  177. package/packages/core/src/voice/index.ts +89 -63
  178. package/packages/core/src/workers/agent.worker.ts +2 -2
  179. package/packages/core/src/workers/workers.test.ts +3 -10
  180. package/scripts/bump-version.ts +248 -0
  181. package/scripts/generate-skill-bundle.ts +108 -0
  182. package/test/acceptance-checks.test.ts +403 -0
  183. package/test/agent-loop-terminal-synthesis.test.ts +32 -0
  184. package/test/browser-backend.test.ts +308 -0
  185. package/test/catalog-agents-stay-enabled.test.ts +117 -0
  186. package/test/causal-events.test.ts +117 -0
  187. package/test/compaction.test.ts +105 -0
  188. package/test/context-compiler.test.ts +269 -0
  189. package/test/curator.test.ts +130 -0
  190. package/test/durable-queue.test.ts +114 -0
  191. package/test/harness-barrel.test.ts +64 -0
  192. package/test/hive-helpers.test.ts +130 -0
  193. package/test/hivedb-search.test.ts +189 -0
  194. package/test/internal-turns.test.ts +166 -0
  195. package/test/job-idempotency.test.ts +68 -0
  196. package/test/job-retry-backoff.test.ts +184 -0
  197. package/test/job-store.test.ts +381 -0
  198. package/test/llm-retry.test.ts +97 -0
  199. package/test/memory-perf.test.ts +774 -0
  200. package/test/minimal-loadout.test.ts +78 -0
  201. package/test/model-catalog.test.ts +105 -0
  202. package/test/preload.ts +12 -0
  203. package/test/reflector.test.ts +320 -0
  204. package/test/retention-cap.test.ts +91 -0
  205. package/test/retired-capabilities-pruned.test.ts +192 -0
  206. package/test/run-store.test.ts +355 -0
  207. package/test/scratchpad.test.ts +74 -0
  208. package/test/secrets-durability.test.ts +119 -0
  209. package/test/seed-model-reseed.test.ts +155 -0
  210. package/test/setup-agent-seed.test.ts +264 -0
  211. package/test/tool-inventory.test.ts +65 -0
  212. package/test/tool-runtime.test.ts +258 -0
  213. package/test/tool-selector-runtime-tools.test.ts +117 -0
  214. package/test/toon.test.ts +429 -0
  215. package/tsconfig.json +2 -0
  216. package/packages/core/src/ace/Curator.ts +0 -158
  217. package/packages/core/src/ace/Reflector.ts +0 -200
  218. package/packages/core/src/ace/index.ts +0 -4
  219. package/packages/core/src/agent/AgentRunner.ts +0 -711
  220. package/packages/core/src/agent/ContextCompiler.ts +0 -567
  221. package/packages/core/src/agent/ContextGuard.ts +0 -91
  222. package/packages/core/src/agent/ConversationStore.ts +0 -254
  223. package/packages/core/src/agent/Hooks.ts +0 -166
  224. package/packages/core/src/agent/StuckLoop.ts +0 -133
  225. package/packages/core/src/agent/providers/LLMClient.ts +0 -149
  226. package/packages/core/src/agent/providers/anthropic.ts +0 -212
  227. package/packages/core/src/agent/providers/openai-compat.ts +0 -231
  228. package/packages/core/src/agent/selectors/PlaybookSelector.ts +0 -121
  229. package/packages/core/src/agent/selectors/SkillSelector.ts +0 -322
  230. package/packages/core/src/agent/selectors/index.ts +0 -6
  231. package/packages/core/src/auth/auth.ts +0 -121
  232. package/packages/core/src/auth/index.ts +0 -1
  233. package/packages/core/src/canvas/CanvasManager.ts +0 -390
  234. package/packages/core/src/canvas/canvas-tools.ts +0 -448
  235. package/packages/core/src/harness/collections.ts +0 -98
  236. package/packages/core/src/harness/goal-verifier.ts +0 -141
  237. package/packages/core/src/harness/harness.test.ts +0 -236
  238. package/packages/core/src/harness/reconcile.ts +0 -149
  239. package/packages/core/src/mcp/MCPToolAdapter.ts +0 -176
  240. package/packages/core/src/multimodal/VisionService.ts +0 -293
  241. package/packages/core/src/scheduler/dag/AgentExecutor.ts +0 -53
  242. package/packages/core/src/scheduler/dag/DAGScheduler.ts +0 -250
  243. package/packages/core/src/scheduler/dag/EventBridge.ts +0 -122
  244. package/packages/core/src/scheduler/dag/TaskGraph.ts +0 -192
  245. package/packages/core/src/scheduler/dag/TaskNode.ts +0 -97
  246. package/packages/core/src/scheduler/dag/TaskResult.ts +0 -22
  247. package/packages/core/src/scheduler/dag/errors.ts +0 -37
  248. package/packages/core/src/scheduler/dag/index.ts +0 -26
  249. package/packages/core/src/scheduler/dag/presets/ResearchPreset.ts +0 -97
  250. package/packages/core/src/scheduler/dag/strategies/ParallelStrategy.ts +0 -21
  251. package/packages/core/src/scheduler/dag/strategies/PriorityStrategy.ts +0 -46
  252. package/packages/core/src/storage/HiveDBStorage.ts +0 -64
  253. package/packages/core/src/storage/SQLiteStorage.ts +0 -414
  254. package/packages/core/src/storage/hiveSeed.ts +0 -308
  255. package/packages/core/src/storage/hiveStorage.test.ts +0 -38
  256. package/packages/core/src/storage/schema.ts +0 -689
  257. package/packages/core/src/storage/storage.test.ts +0 -37
  258. package/packages/core/src/swarm/AgentBus.ts +0 -460
  259. package/packages/core/src/swarm/EventBus.ts +0 -169
  260. package/packages/core/src/swarm/WorkerPool.ts +0 -236
  261. package/packages/core/src/tools/bridge-events.ts +0 -26
  262. package/packages/core/src/tools/canvas/index.ts +0 -375
  263. package/packages/core/src/tools/codebridge/index.ts +0 -342
  264. package/packages/core/src/tools/meeting/index.ts +0 -353
  265. package/packages/core/src/tools/projects/index.ts +0 -37
  266. package/packages/core/src/tools/projects/project-create.ts +0 -94
  267. package/packages/core/src/tools/projects/project-done.ts +0 -66
  268. package/packages/core/src/tools/projects/project-fail.ts +0 -66
  269. package/packages/core/src/tools/projects/project-list.ts +0 -96
  270. package/packages/core/src/tools/projects/project-update.ts +0 -72
  271. package/packages/core/src/tools/projects/task-create.ts +0 -68
  272. package/packages/core/src/tools/projects/task-evaluate.ts +0 -93
  273. package/packages/core/src/tools/projects/task-update.ts +0 -93
  274. package/packages/core/src/tools/voice/index.ts +0 -104
  275. package/packages/core/src/tools/web/api-request.test.ts +0 -170
  276. package/packages/core/src/tools/web/api-request.ts +0 -239
  277. package/test/setup-db.ts +0 -216
  278. /package/packages/core/src/agent/{NativeTools.ts → native-tools.ts} +0 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Conversation Store — persists message history in the `conversations` HiveDB collection.
3
+ * Replaces the LangGraph BunSqliteSaver + lg_checkpoints approach.
4
+ *
5
+ * Also manages: summaries and scratchpad, both HiveDB document collections.
6
+ */
7
+
8
+ import { col, nextId, bumpRollup } from "../storage/hive"
9
+ import { getHiveDb } from "../storage/hivedb"
10
+ import { logger } from "../utils/logger"
11
+ import type { LLMMessage, ContentPart } from "./llm-client"
12
+ import { estimateTokens } from "../utils/toon"
13
+ import type { ConversationDoc, SummaryDoc, MessageSource } from "../storage/collections"
14
+
15
+ const log = logger.child("conv-store")
16
+
17
+ // ─── Types ────────────────────────────────────────────────────────────────────
18
+
19
+ export interface StoredMessage {
20
+ /** Per-thread monotonic sequence number (NOT globally unique — scope every comparison to a single threadId). */
21
+ id: number
22
+ thread_id: string
23
+ channel: string
24
+ role: "user" | "assistant" | "tool"
25
+ /** Provenance of this turn. Never null — legacy rows are normalized to "legacy_internal" on read. */
26
+ source: MessageSource
27
+ content: string
28
+ tool_calls_json: string | null
29
+ tool_call_id: string | null
30
+ reasoning_content: string | null // Kimi K2 thinking — must be round-tripped
31
+ content_multimodal: string | null // JSON array of ContentPart[]
32
+ token_count: number
33
+ created_at: number
34
+ }
35
+
36
+ // ─── Internal events (delegation fan-in, etc.) ────────────────────────────────
37
+ //
38
+ // These are system-authored turns (async delegation outcomes) that must reach
39
+ // the model as input but must never be persisted with role:"system" — doing so
40
+ // causes every LLM provider to hoist them permanently into the system
41
+ // instruction on every subsequent turn (see gemini.ts/anthropic.ts, which
42
+ // concatenate ALL role:"system" messages into systemInstruction/system). They
43
+ // are persisted as role:"user" + a source tag instead, and wrapped with a
44
+ // framing marker only at serialization time (toAPIMessages), so stored content
45
+ // stays clean and the wording can evolve without a migration.
46
+
47
+ export const INTERNAL_SOURCES: ReadonlySet<string> =
48
+ new Set(["task_complete", "delegation_summary", "legacy_internal"])
49
+
50
+ export function isInternalSource(source: string | null | undefined): boolean {
51
+ return !!source && INTERNAL_SOURCES.has(source)
52
+ }
53
+
54
+ export function formatInternalEvent(source: string, content: string): string {
55
+ return `<hive:internal_event source="${source}">\n` +
56
+ `Evento interno del sistema — NO es un mensaje del usuario. No lo cites literalmente, no expongas IDs internos (task_id, worker_id) ni JSON crudo. Respondé al usuario de forma natural y breve.\n\n` +
57
+ `${content}\n` +
58
+ `</hive:internal_event>`
59
+ }
60
+
61
+ function storageId(threadId: string, seq: number): string {
62
+ return `${threadId}:${String(seq).padStart(15, "0")}`
63
+ }
64
+
65
+ function toStoredMessage(id: string, doc: ConversationDoc): StoredMessage {
66
+ const seq = parseInt(id.slice(id.lastIndexOf(":") + 1), 10)
67
+ // Legacy rows (written before `source` existed) used role:"system" as the
68
+ // sole marker for internal events. Normalize them here so every downstream
69
+ // reader (getRecentMessages, compaction, context-compiler) sees a single
70
+ // consistent shape and never has to special-case role:"system" again.
71
+ const legacyInternal = doc.role === "system"
72
+ return {
73
+ id: seq,
74
+ thread_id: doc.thread_id,
75
+ channel: doc.channel,
76
+ role: legacyInternal ? "user" : (doc.role as StoredMessage["role"]),
77
+ source: doc.source ?? (legacyInternal ? "legacy_internal" : "message"),
78
+ content: doc.content,
79
+ tool_calls_json: doc.tool_calls_json,
80
+ tool_call_id: doc.tool_call_id,
81
+ reasoning_content: doc.reasoning_content,
82
+ content_multimodal: doc.content_multimodal,
83
+ token_count: doc.token_count,
84
+ created_at: doc.created_at,
85
+ }
86
+ }
87
+
88
+ // ─── Message operations ───────────────────────────────────────────────────────
89
+
90
+ const recentMessageTimestamps: number[] = []
91
+
92
+ export function getRecentMessageCount(windowMs = 5 * 60_000): number {
93
+ const cutoff = Date.now() - windowMs
94
+ while (recentMessageTimestamps.length && recentMessageTimestamps[0] < cutoff) {
95
+ recentMessageTimestamps.shift()
96
+ }
97
+ return recentMessageTimestamps.length
98
+ }
99
+
100
+ export async function addMessage(
101
+ threadId: string,
102
+ role: StoredMessage["role"],
103
+ content: string | ContentPart[],
104
+ opts?: {
105
+ channel?: string
106
+ tool_calls?: LLMMessage["tool_calls"]
107
+ tool_call_id?: string
108
+ reasoning_content?: string
109
+ source?: MessageSource
110
+ }
111
+ ): Promise<number> {
112
+ // Handle multimodal content by extracting text for the content column
113
+ const textContent = typeof content === "string"
114
+ ? content
115
+ : Array.isArray(content)
116
+ ? content.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
117
+ : String(content)
118
+
119
+ const content_multimodal = Array.isArray(content) ? JSON.stringify(content) : null
120
+ const tool_calls_json = opts?.tool_calls ? JSON.stringify(opts.tool_calls) : null
121
+
122
+ const paddedSeq = await nextId(`conversations:${threadId}`)
123
+ const seq = parseInt(paddedSeq, 10)
124
+ const now = Date.now()
125
+
126
+ const conversationsCol = await col<ConversationDoc>("conversations")
127
+ await conversationsCol.put(storageId(threadId, seq), {
128
+ id: storageId(threadId, seq),
129
+ thread_id: threadId,
130
+ channel: opts?.channel ?? "webchat",
131
+ role,
132
+ content: textContent,
133
+ content_multimodal,
134
+ tool_calls_json,
135
+ tool_call_id: opts?.tool_call_id ?? null,
136
+ reasoning_content: opts?.reasoning_content ?? null,
137
+ source: opts?.source ?? "message",
138
+ // Estimate tokens: content + tool_calls JSON
139
+ token_count: Math.max(1, estimateTokens(textContent) + estimateTokens(tool_calls_json ?? "")),
140
+ created_at: now,
141
+ updated_at: now,
142
+ }, { expectedVersion: 0 })
143
+
144
+ // Fire-and-forget — never block message persistence on the activity chart rollup.
145
+ const hour = new Date(now).toISOString().slice(0, 13)
146
+ bumpRollup("activityRollups", hour, { messageCount: 1 }).catch(() => {})
147
+ recentMessageTimestamps.push(now)
148
+
149
+ return seq
150
+ }
151
+
152
+ /**
153
+ * Returns all messages for the thread ordered oldest → newest.
154
+ */
155
+ export async function getHistory(threadId: string, limit = 200): Promise<StoredMessage[]> {
156
+ const conversationsCol = await col<ConversationDoc>("conversations")
157
+ const entries = await conversationsCol.scan({ prefix: `${threadId}:`, limit })
158
+ return entries.map(e => toStoredMessage(e.id, e.doc))
159
+ }
160
+
161
+ /**
162
+ * Returns only the last N messages (oldest → newest order),
163
+ * with leading orphaned tool messages stripped from the window start.
164
+ *
165
+ * A tool message is "orphaned" when the assistant message that issued its
166
+ * tool_call_id is not present in the loaded window (it was compacted away).
167
+ * Sending orphaned tool messages to the LLM causes provider errors.
168
+ */
169
+ export async function getRecentMessages(threadId: string, n: number): Promise<StoredMessage[]> {
170
+ const conversationsCol = await col<ConversationDoc>("conversations")
171
+ const entries = await conversationsCol.scan({ prefix: `${threadId}:`, reverse: true })
172
+ const nonTool = entries.filter(e => e.doc.role !== "tool").slice(0, n)
173
+ const rows = nonTool.map(e => toStoredMessage(e.id, e.doc)).reverse()
174
+ return stripLeadingOrphanedTools(rows)
175
+ }
176
+
177
+ function stripLeadingOrphanedTools(rows: StoredMessage[]): StoredMessage[] {
178
+ // Collect all tool_call_ids referenced by assistant messages in this window
179
+ const knownIds = new Set<string>()
180
+ for (const r of rows) {
181
+ if (r.role === "assistant" && r.tool_calls_json) {
182
+ try {
183
+ const tcs = JSON.parse(r.tool_calls_json) as Array<{ id: string }>
184
+ for (const tc of tcs) knownIds.add(tc.id)
185
+ } catch { /* ignore malformed JSON */ }
186
+ }
187
+ }
188
+
189
+ // Drop tool messages at the start of the window whose assistant is missing
190
+ let start = 0
191
+ while (
192
+ start < rows.length &&
193
+ rows[start].role === "tool" &&
194
+ rows[start].tool_call_id !== null &&
195
+ !knownIds.has(rows[start].tool_call_id!)
196
+ ) {
197
+ start++
198
+ }
199
+
200
+ if (start > 0) {
201
+ log.warn(`[conv-store] Stripped ${start} leading orphaned tool message(s) from window (tool_call_ids outside window)`)
202
+ }
203
+ return start > 0 ? rows.slice(start) : rows
204
+ }
205
+
206
+ export async function getMessageCount(threadId: string): Promise<number> {
207
+ const conversationsCol = await col<ConversationDoc>("conversations")
208
+ const entries = await conversationsCol.scan({ prefix: `${threadId}:` })
209
+ return entries.length
210
+ }
211
+
212
+ export async function getTotalTokens(threadId: string): Promise<number> {
213
+ const conversationsCol = await col<ConversationDoc>("conversations")
214
+ const entries = await conversationsCol.scan({ prefix: `${threadId}:` })
215
+ return entries.reduce((sum, e) => sum + e.doc.token_count, 0)
216
+ }
217
+
218
+ /**
219
+ * Messages after a given message ID (for incremental summary updates).
220
+ */
221
+ export async function getMessagesAfter(threadId: string, afterId: number): Promise<StoredMessage[]> {
222
+ const conversationsCol = await col<ConversationDoc>("conversations")
223
+ const entries = await conversationsCol.scan({ prefix: `${threadId}:` })
224
+ return entries
225
+ .map(e => toStoredMessage(e.id, e.doc))
226
+ .filter(m => m.id > afterId)
227
+ }
228
+
229
+ // ─── Convert stored messages → LLMMessage array ───────────────────────────────
230
+
231
+ export function toAPIMessages(rows: StoredMessage[]): LLMMessage[] {
232
+ return rows.map((r) => {
233
+ let content: string | ContentPart[] = r.content
234
+ if (r.content_multimodal) {
235
+ try { content = JSON.parse(r.content_multimodal) } catch { /* ignore */ }
236
+ }
237
+ // Internal events (delegation fan-in) are wrapped at serialization time —
238
+ // never at authoring time — so stored content stays clean and legacy rows
239
+ // (normalized to source:"legacy_internal" in toStoredMessage) get wrapped
240
+ // for free. Internal events are never multimodal in practice.
241
+ if (isInternalSource(r.source) && typeof content === "string") {
242
+ content = formatInternalEvent(r.source, content)
243
+ }
244
+ const msg: LLMMessage = { role: r.role, content }
245
+ // Note: tool_calls and tool_call_id are NOT reconstructed from DB.
246
+ // Tool results are kept in-memory during iteration but not persisted,
247
+ // so historical messages only contain text conversation.
248
+ if (r.reasoning_content) msg.reasoning_content = r.reasoning_content
249
+ return msg
250
+ })
251
+ }
252
+
253
+ // ─── Summaries ────────────────────────────────────────────────────────────────
254
+
255
+ export interface Summary {
256
+ summary: string
257
+ last_message_id: number
258
+ messages_covered: number
259
+ }
260
+
261
+ export async function getSummary(threadId: string): Promise<Summary | null> {
262
+ const summariesCol = await col<SummaryDoc>("summaries")
263
+ const entry = await summariesCol.get(threadId)
264
+ if (!entry) return null
265
+ return {
266
+ summary: entry.doc.summary,
267
+ last_message_id: entry.doc.last_message_id ? parseInt(entry.doc.last_message_id, 10) : 0,
268
+ messages_covered: entry.doc.messages_covered,
269
+ }
270
+ }
271
+
272
+ export async function saveSummary(
273
+ threadId: string,
274
+ summary: string,
275
+ messagesCovered: number,
276
+ lastMessageId: number
277
+ ): Promise<void> {
278
+ const summariesCol = await col<SummaryDoc>("summaries")
279
+ const existing = await summariesCol.get(threadId)
280
+ await summariesCol.put(threadId, {
281
+ thread_id: threadId,
282
+ summary,
283
+ messages_covered: messagesCovered,
284
+ last_message_id: String(lastMessageId),
285
+ }, existing ? { expectedVersion: existing.version } : { expectedVersion: 0 })
286
+ }
287
+
288
+ // ─── Scratchpad (HiveDB collection) ────────────────────────────────────────────
289
+ //
290
+ // Persistent key-value notes per conversation. Lives in a HiveDB document
291
+ // collection instead of SQLite: id = "<threadId>:<key>", so a per-thread
292
+ // listing is a prefix scan and no secondary index is needed.
293
+
294
+ export interface ScratchpadDoc {
295
+ threadId: string
296
+ key: string
297
+ value: string
298
+ source: string | null
299
+ createdAt: number
300
+ updatedAt: number
301
+ /** Monotonic per-process counter — tiebreaker for notes saved within the same clock tick. */
302
+ seq: number
303
+ }
304
+
305
+ let scratchpadSeq = 0
306
+
307
+ /** Wire shape for the admin notes panel — mirrors the old SQLite row (snake_case, epoch seconds). */
308
+ export interface ScratchpadNoteRow {
309
+ id: string
310
+ thread_id: string
311
+ key: string
312
+ value: string
313
+ source: string | null
314
+ created_at: number
315
+ updated_at: number
316
+ }
317
+
318
+ function scratchpadNoteId(threadId: string, key: string): string {
319
+ return `${threadId}:${key}`
320
+ }
321
+
322
+ async function scratchpadCollection() {
323
+ const db = await getHiveDb()
324
+ return db.collection<ScratchpadDoc>("scratchpad")
325
+ }
326
+
327
+ export async function saveScratchpadNote(
328
+ threadId: string,
329
+ key: string,
330
+ value: string,
331
+ source?: string
332
+ ): Promise<void> {
333
+ const col = await scratchpadCollection()
334
+ const id = scratchpadNoteId(threadId, key)
335
+ const existing = await col.get(id)
336
+ const now = Date.now()
337
+ await col.put(id, {
338
+ threadId,
339
+ key,
340
+ value,
341
+ source: source ?? null,
342
+ createdAt: existing?.doc.createdAt ?? now,
343
+ updatedAt: now,
344
+ seq: scratchpadSeq++,
345
+ })
346
+ }
347
+
348
+ function byMostRecent(a: { doc: ScratchpadDoc }, b: { doc: ScratchpadDoc }): number {
349
+ return b.doc.updatedAt - a.doc.updatedAt || b.doc.seq - a.doc.seq
350
+ }
351
+
352
+ export async function getScratchpad(threadId: string): Promise<Array<{ key: string; value: string }>> {
353
+ const col = await scratchpadCollection()
354
+ const entries = await col.scan({ prefix: `${threadId}:` })
355
+ return entries
356
+ .sort(byMostRecent)
357
+ .map((e) => ({ key: e.doc.key, value: e.doc.value }))
358
+ }
359
+
360
+ /** All notes across every thread, most recently updated first — used by the admin notes panel. */
361
+ export async function listAllScratchpadNotes(limit: number): Promise<ScratchpadNoteRow[]> {
362
+ const col = await scratchpadCollection()
363
+ const entries = await col.scan({})
364
+ return entries
365
+ .sort(byMostRecent)
366
+ .slice(0, limit)
367
+ .map((e) => ({
368
+ id: e.id,
369
+ thread_id: e.doc.threadId,
370
+ key: e.doc.key,
371
+ value: e.doc.value,
372
+ source: e.doc.source,
373
+ created_at: Math.floor(e.doc.createdAt / 1000),
374
+ updated_at: Math.floor(e.doc.updatedAt / 1000),
375
+ }))
376
+ }
377
+
378
+ export async function deleteScratchpadNote(threadId: string, key: string): Promise<void> {
379
+ const col = await scratchpadCollection()
380
+ await col.delete(scratchpadNoteId(threadId, key))
381
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * ACE Curator — converts reflections into playbook rules.
3
+ *
4
+ * Runs after the Reflector. Performs incremental edits to the playbook:
5
+ * - New insights → new rules
6
+ * - Repeated patterns → increment helpful_count
7
+ * - Contradicted rules → increment harmful_count or deactivate
8
+ * - Deactivate rules where harmful_count > helpful_count
9
+ *
10
+ * Never rewrites the whole playbook — only incremental edits.
11
+ *
12
+ * "Last processed reflection" is tracked via a `cursors` collection doc
13
+ * (id="curator:lastReflection") instead of SQL's MAX(source_reflection_id).
14
+ */
15
+
16
+ import { logger } from "../utils/logger"
17
+ import { col, nextId, toIndexable, fromIndexable } from "../storage/hive"
18
+ import type {
19
+ ReflectionDoc,
20
+ PlaybookDoc,
21
+ AgentDoc,
22
+ CursorDoc,
23
+ AgentProposalDoc,
24
+ TraceDoc,
25
+ } from "../storage/collections"
26
+
27
+ const log = logger.child("curator")
28
+
29
+ const MAX_HARMFUL_BEFORE_PRUNE = 3
30
+ const CURSOR_ID = "curator:lastReflection"
31
+
32
+ /** Entry point — called by reflector.ts after it inserts new reflections */
33
+ export async function runCurator(): Promise<void> {
34
+ try {
35
+ const cursorsCol = await col<CursorDoc>("cursors")
36
+ const playbookCol = await col<PlaybookDoc>("playbook")
37
+ const reflectionsCol = await col<ReflectionDoc>("reflections")
38
+ // Process unprocessed reflections (those newer than last run)
39
+ const cursorEntry = await cursorsCol.get(CURSOR_ID)
40
+ const lastProcessed = cursorEntry?.doc.value ?? null
41
+
42
+ let candidates = lastProcessed
43
+ ? await reflectionsCol.scan({ start: lastProcessed })
44
+ : await reflectionsCol.scan({})
45
+ if (lastProcessed && candidates[0]?.id === lastProcessed) candidates = candidates.slice(1)
46
+
47
+ if (candidates.length === 0) {
48
+ log.debug("[curator] No new reflections to process")
49
+ } else {
50
+ log.info(`[curator] Processing ${candidates.length} new reflections`)
51
+ const allPlaybook = await playbookCol.scan({})
52
+ for (const entry of candidates) {
53
+ await processReflection(playbookCol, allPlaybook, entry.doc)
54
+ }
55
+ const newCursor = candidates[candidates.length - 1].id
56
+ await cursorsCol.put(CURSOR_ID, { value: newCursor }, cursorEntry ? { expectedVersion: cursorEntry.version } : { expectedVersion: 0 })
57
+ }
58
+
59
+ // Prune rules where harmful > helpful (consistently bad rules)
60
+ const allActive = (await playbookCol.scan({})).filter(e => e.doc.active)
61
+ for (const entry of allActive) {
62
+ if (entry.doc.harmful_count > entry.doc.helpful_count && entry.doc.harmful_count >= MAX_HARMFUL_BEFORE_PRUNE) {
63
+ await playbookCol.put(entry.id, { ...entry.doc, active: false, updated_at: Date.now() }, { expectedVersion: entry.version })
64
+ }
65
+ }
66
+
67
+ await curateAgentStructure()
68
+
69
+ log.info("[curator] Playbook updated")
70
+ } catch (err) {
71
+ log.warn("[curator] Error:", err)
72
+ }
73
+ }
74
+
75
+ async function curateAgentStructure(): Promise<void> {
76
+ const agentsCol = await col<AgentDoc>("agents")
77
+ const proposalsCol = await col<AgentProposalDoc>("agentProposals")
78
+ const tracesCol = await col<TraceDoc>("traces")
79
+ const now = Date.now()
80
+
81
+ // A catalog agent is a capability, not a disposable worker: disabling
82
+ // `workspace_file_operator` takes filesystem work away from the whole hive
83
+ // and nothing replaces it, so a bad streak can never switch one off by
84
+ // itself. It raises a proposal for a human to decide instead — and the ACE
85
+ // counters keep accumulating either way, so routing still sees the signal.
86
+ for (const entry of await agentsCol.findBy("source", "catalog")) {
87
+ if (!entry.doc.enabled) continue
88
+ const helpful = entry.doc.helpful_count ?? 0
89
+ const harmful = entry.doc.harmful_count ?? 0
90
+ if (harmful > helpful && harmful >= MAX_HARMFUL_BEFORE_PRUNE) {
91
+ await ensureAgentProposal(proposalsCol, {
92
+ type: "disable_agent",
93
+ agentId: entry.id,
94
+ change: { active: false, reason: "harmful_count exceeded helpful_count" },
95
+ evidence: [],
96
+ confidence: 1,
97
+ })
98
+ log.warn(`[curator] Catalog agent '${entry.doc.name}' (${entry.id}) is failing verification (helpful=${helpful}, harmful=${harmful}) — proposed for review, left enabled`)
99
+ }
100
+ }
101
+
102
+ const traces = (await tracesCol.scan({})).map((entry) => entry.doc)
103
+ const agents = new Map((await agentsCol.scan({})).map((entry) => [entry.id, entry.doc]))
104
+
105
+ // Recurrent successful free workers are candidates for a learned template.
106
+ const freeSuccess = new Map<string, TraceDoc[]>()
107
+ for (const trace of traces) {
108
+ const agent = agents.get(trace.agent_id)
109
+ if (!trace.success || trace.tool_used || trace.catalog_agent_id || agent?.role !== "worker" || agent.source === "catalog") continue
110
+ const bucket = freeSuccess.get(trace.agent_id) ?? []
111
+ bucket.push(trace)
112
+ freeSuccess.set(trace.agent_id, bucket)
113
+ }
114
+ for (const [agentId, evidence] of freeSuccess) {
115
+ if (evidence.length < 5) continue
116
+ const agent = agents.get(agentId)!
117
+ await ensureAgentProposal(proposalsCol, {
118
+ type: "create_agent",
119
+ agentId: `candidate:${agentId}`,
120
+ change: {
121
+ suggested_name: agent.name,
122
+ suggested_description: agent.description,
123
+ observed_tasks: evidence.slice(-5).map((trace) => trace.input_summary),
124
+ },
125
+ evidence: evidence.slice(-5).map((trace) => trace.id),
126
+ confidence: Math.min(0.95, 0.5 + evidence.length * 0.05),
127
+ })
128
+ }
129
+
130
+ // Repeated failures identify a loadout mismatch. This remains a proposal;
131
+ // permissions are never changed automatically.
132
+ const failures = new Map<string, TraceDoc[]>()
133
+ for (const trace of traces) {
134
+ if (trace.success || !trace.catalog_agent_id || !trace.tool_used) continue
135
+ const key = `${trace.catalog_agent_id}\0${trace.tool_used}`
136
+ const bucket = failures.get(key) ?? []
137
+ bucket.push(trace)
138
+ failures.set(key, bucket)
139
+ }
140
+ for (const [key, evidence] of failures) {
141
+ if (evidence.length < 3) continue
142
+ const [agentId, tool] = key.split("\0")
143
+ await ensureAgentProposal(proposalsCol, {
144
+ type: "move_tool",
145
+ agentId,
146
+ change: { tool, reason: "repeated failures; review ownership or remove from loadout" },
147
+ evidence: evidence.slice(-10).map((trace) => trace.id),
148
+ confidence: Math.min(0.95, 0.55 + evidence.length * 0.05),
149
+ })
150
+ }
151
+
152
+ const { syncCatalogAgentsToIndex } = await import("./catalog-selector")
153
+ await syncCatalogAgentsToIndex()
154
+ }
155
+
156
+ async function ensureAgentProposal(
157
+ proposalsCol: Awaited<ReturnType<typeof col<AgentProposalDoc>>>,
158
+ input: {
159
+ type: AgentProposalDoc["type"]
160
+ agentId: string
161
+ change: unknown
162
+ evidence: string[]
163
+ confidence: number
164
+ },
165
+ ): Promise<void> {
166
+ const existing = (await proposalsCol.findBy("agent_id", input.agentId))
167
+ .find((entry) => entry.doc.type === input.type && entry.doc.status === "proposed")
168
+ if (existing) return
169
+ const id = await nextId("agentProposals")
170
+ const now = Date.now()
171
+ await proposalsCol.put(id, {
172
+ id,
173
+ type: input.type,
174
+ agent_id: input.agentId,
175
+ proposed_change_json: JSON.stringify(input.change),
176
+ evidence_trace_ids_json: JSON.stringify(input.evidence),
177
+ confidence: input.confidence,
178
+ status: "proposed",
179
+ created_at: now,
180
+ updated_at: now,
181
+ }, { expectedVersion: 0 })
182
+ }
183
+
184
+ // ─── Process a single reflection ─────────────────────────────────────────────
185
+
186
+ async function processReflection(
187
+ playbookCol: Awaited<ReturnType<typeof col<PlaybookDoc>>>,
188
+ allPlaybook: Array<{ id: string; version: number; doc: PlaybookDoc }>,
189
+ reflection: ReflectionDoc
190
+ ): Promise<void> {
191
+ const category = mapInsightTypeToCategory(reflection.insight_type)
192
+ const applicable: string[] = reflection.affected_tools ? JSON.parse(reflection.affected_tools) : []
193
+ if (reflection.affected_agents) {
194
+ const agentsCol = await col<AgentDoc>("agents")
195
+ for (const agentId of JSON.parse(reflection.affected_agents) as string[]) {
196
+ const agent = await agentsCol.get(agentId)
197
+ if (agent?.doc.source === "catalog") applicable.push(`agent:${agent.doc.id}`)
198
+ }
199
+ }
200
+ const applicableTo = applicable.length ? JSON.stringify([...new Set(applicable)]) : null
201
+
202
+ // Check if a similar rule already exists (fuzzy check by first 60 chars)
203
+ const prefix = reflection.description.substring(0, 60)
204
+ const existing = allPlaybook.find(e => e.doc.active && e.doc.rule.startsWith(prefix))
205
+
206
+ if (existing) {
207
+ // Reinforce existing rule
208
+ await playbookCol.put(existing.id, { ...existing.doc, helpful_count: existing.doc.helpful_count + 1, updated_at: Date.now() }, { expectedVersion: existing.version })
209
+ return
210
+ }
211
+
212
+ // Insert new rule
213
+ const id = await nextId("playbook")
214
+ const now = Date.now()
215
+ await playbookCol.put(id, {
216
+ id,
217
+ rule: reflection.description,
218
+ category,
219
+ applicable_to: applicableTo,
220
+ helpful_count: 1,
221
+ harmful_count: 0,
222
+ active: true,
223
+ source_reflection_id: toIndexable(reflection.id),
224
+ created_at: now,
225
+ updated_at: now,
226
+ }, { expectedVersion: 0 })
227
+ allPlaybook.push({ id, version: 1, doc: { id, rule: reflection.description, category, applicable_to: applicableTo, helpful_count: 1, harmful_count: 0, active: true, source_reflection_id: toIndexable(reflection.id), created_at: now, updated_at: now } })
228
+ }
229
+
230
+ function mapInsightTypeToCategory(
231
+ type: string
232
+ ): "tool_selection" | "response_quality" | "error_avoidance" | "optimization" | "agent_creation" {
233
+ const map: Record<string, any> = {
234
+ success_pattern: "tool_selection",
235
+ failure_pattern: "error_avoidance",
236
+ optimization: "optimization",
237
+ ethics_violation: "error_avoidance",
238
+ // G9 evaluateHarness() insights (reflector.ts's analyzeCausalThreads)
239
+ root_cause: "error_avoidance",
240
+ learning_proposal: "response_quality",
241
+ }
242
+ return map[type] ?? "optimization"
243
+ }
244
+
245
+ async function addOrUpdateRule(
246
+ playbookCol: Awaited<ReturnType<typeof col<PlaybookDoc>>>,
247
+ allPlaybook: Array<{ id: string; version: number; doc: PlaybookDoc }>,
248
+ opts: {
249
+ rule: string
250
+ category: string
251
+ applicable_to: string | null
252
+ sourceReflectionId: string | null
253
+ }
254
+ ): Promise<void> {
255
+ const prefix = opts.rule.substring(0, 60)
256
+ const existing = allPlaybook.find(e => e.doc.rule.startsWith(prefix))
257
+
258
+ if (existing) {
259
+ await playbookCol.put(existing.id, { ...existing.doc, helpful_count: existing.doc.helpful_count + 1, updated_at: Date.now() }, { expectedVersion: existing.version })
260
+ } else {
261
+ const id = await nextId("playbook")
262
+ const now = Date.now()
263
+ await playbookCol.put(id, {
264
+ id,
265
+ rule: opts.rule,
266
+ category: opts.category as PlaybookDoc["category"],
267
+ applicable_to: opts.applicable_to,
268
+ helpful_count: 1,
269
+ harmful_count: 0,
270
+ active: true,
271
+ source_reflection_id: toIndexable(opts.sourceReflectionId),
272
+ created_at: now,
273
+ updated_at: now,
274
+ }, { expectedVersion: 0 })
275
+ }
276
+ }