@johpaz/hive-sdk 0.0.18 → 0.1.3

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 (41) hide show
  1. package/bun.lock +291 -1
  2. package/docs/HIVE-HARNESS.md +113 -0
  3. package/package.json +36 -2
  4. package/packages/cli/package.json +1 -1
  5. package/packages/core/package.json +13 -2
  6. package/packages/core/src/ace/Tracer.ts +1 -1
  7. package/packages/core/src/agent/AgentRunner.ts +12 -0
  8. package/packages/core/src/agent/ContextCompiler.ts +4 -4
  9. package/packages/core/src/agent/ConversationStore.ts +30 -20
  10. package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
  11. package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
  12. package/packages/core/src/agent/selectors/ToolSelector.ts +53 -89
  13. package/packages/core/src/auth/auth.ts +36 -23
  14. package/packages/core/src/harness/boot-id.ts +20 -0
  15. package/packages/core/src/harness/collections.ts +98 -0
  16. package/packages/core/src/harness/db-helpers.ts +87 -0
  17. package/packages/core/src/harness/durable-queue.ts +337 -0
  18. package/packages/core/src/harness/goal-verifier.ts +141 -0
  19. package/packages/core/src/harness/harness.test.ts +236 -0
  20. package/packages/core/src/harness/index.ts +34 -0
  21. package/packages/core/src/harness/job-store.ts +399 -0
  22. package/packages/core/src/harness/proof-packet.ts +69 -0
  23. package/packages/core/src/harness/reconcile.ts +149 -0
  24. package/packages/core/src/harness/run-epoch.ts +32 -0
  25. package/packages/core/src/harness/run-store.ts +334 -0
  26. package/packages/core/src/index.ts +6 -0
  27. package/packages/core/src/memory/Scratchpad.test.ts +23 -21
  28. package/packages/core/src/memory/Scratchpad.ts +41 -24
  29. package/packages/core/src/storage/HiveDBStorage.ts +64 -0
  30. package/packages/core/src/storage/SQLiteStorage.ts +7 -0
  31. package/packages/core/src/storage/hiveSeed.ts +308 -0
  32. package/packages/core/src/storage/hiveStorage.test.ts +38 -0
  33. package/packages/core/src/storage/index.ts +10 -0
  34. package/packages/core/src/storage/seed.ts +5 -1
  35. package/packages/core/src/storage/usage.ts +106 -167
  36. package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
  37. package/packages/core/src/tools/agents/get-available-models.ts +52 -56
  38. package/packages/core/src/tools/agents/index.ts +77 -60
  39. package/packages/core/src/tools/core/index.ts +106 -291
  40. package/packages/core/src/tools/meeting/index.ts +83 -93
  41. package/packages/core/src/utils/toon.ts +4 -4
@@ -0,0 +1,113 @@
1
+ # Hive Harness — durable task execution
2
+
3
+ The `harness` module (`@johpaz/hive-sdk/harness`) is the SDK's durable-execution
4
+ layer: a HiveDB-backed job queue with crash recovery, checkpointable runs,
5
+ retry with backoff, idempotent submission, goal verification, and proof
6
+ packets. It's what lets a host app (a `hive-app`, or a production service like
7
+ Hive Cloud) survive a process restart mid-task without losing work or
8
+ double-executing a tool call.
9
+
10
+ It is deliberately **not** wired into `AgentRunner` automatically, and it has
11
+ no built-in notion of "chat" vs "project task" vs any other app-specific job
12
+ type — job `type` and run `kind` are plain strings. The host app defines its
13
+ own vocabulary and registers executors for it. This is the same infrastructure
14
+ that powers `hive`'s durable-queue harness, generalized so any SDK consumer
15
+ can reuse it instead of re-implementing crash-safe job execution from scratch.
16
+
17
+ ## Architecture
18
+
19
+ | Piece | File | Responsibility |
20
+ |---|---|---|
21
+ | `JobDoc` / `HarnessRunDoc` / `ProofPacketDoc` | `collections.ts` | HiveDB document shapes |
22
+ | `db-helpers` | `db-helpers.ts` | `nextId`, `updateDoc`, `findByAny` — primitives HiveDB's `Collection` doesn't provide directly |
23
+ | `job-store` | `job-store.ts` | Durable job persistence: claim/lease/complete/fail/retry, all via OCC |
24
+ | `run-store` | `run-store.ts` | Checkpoint + lease for a single durable run (messages, iteration/token counters, pending tool calls) |
25
+ | `durable-queue` | `durable-queue.ts` | `DurableLaneQueue` — FIFO+priority per lane, global concurrency cap, executor registry |
26
+ | `goal-verifier` | `goal-verifier.ts` | `verifyGoal()` — deterministic check tool or LLM verifier, single goal or a list of acceptance criteria |
27
+ | `run-epoch` | `run-epoch.ts` | Fixed-worker epoch fingerprint (provider/model/app-version/tool-catalog) |
28
+ | `proof-packet` | `proof-packet.ts` | Compressed evidence artifact for a completed run |
29
+ | `reconcile` | `reconcile.ts` | `reconcileOnBoot()` — crash repair + retention cap, call once at startup |
30
+
31
+ ## Durable queue semantics
32
+
33
+ - **Lanes**: a lane (e.g. a session id, or `task:<id>`) runs at most one job
34
+ at a time, FIFO within the lane, ordered by `priority` then creation order.
35
+ - **Global concurrency**: `maxGlobalConcurrency` caps how many jobs run at
36
+ once across all lanes (default 4). Types listed in `nonRetryableTypes`
37
+ (default `["chat_turn"]`) bypass this cap — a busy batch of background jobs
38
+ must not make an interactive/user-facing job type stop responding.
39
+ - **Leases**: a claimed job gets a lease (default 30 min); the queue renews
40
+ it every 30s while executing. A lease that expires (crashed process) is
41
+ reclaimed to `pending` or marked `interrupted` once `attempts >=
42
+ max_attempts` — checked by `reconcileOnBoot()` at startup and by the
43
+ queue's periodic maintenance tick thereafter.
44
+ - **Executors**: register one per job type with `registerExecutor(type, fn)`.
45
+ An executor receives the `JobDoc`, an `AbortSignal` (fired on cancel or
46
+ `taskTimeoutMs`), and any live callbacks passed to `enqueue()`.
47
+
48
+ ## Retry & backoff
49
+
50
+ Two independent retry mechanisms:
51
+
52
+ 1. **Crash retries** (`attempts` / `max_attempts`) — bumped on every claim,
53
+ checked by `reclaimOrInterrupt` after a lease expires. This is about
54
+ *the process dying*, not the job failing logically.
55
+ 2. **Logical-failure retries** (`retry_count` / `JobRetryPolicy`) — when an
56
+ executor returns `{ok: false, retryable: true}` (the default unless set
57
+ `false`), `failJobOrRetry` reschedules the job with exponential backoff +
58
+ jitter instead of failing it immediately:
59
+
60
+ ```ts
61
+ delay = min(maxDelayMs, initialDelayMs * backoffMultiplier ** retryCount)
62
+ * (1 + jitter * random())
63
+ ```
64
+
65
+ Once `retryCount >= policy.maxRetries`, the job fails terminally. Types in
66
+ `nonRetryableTypes` never take this path — a failed interactive turn
67
+ should surface to the user immediately, not silently retry later.
68
+
69
+ ## Idempotency
70
+
71
+ `createJob`/`enqueue` accept an optional `idempotency_key`. A repeated key
72
+ returns the existing job (whatever its status — pending, running, completed,
73
+ or terminally failed) instead of creating a duplicate, so a retried HTTP
74
+ request from a caller doesn't double-enqueue work.
75
+
76
+ ## Goal verification & acceptance criteria
77
+
78
+ `verifyGoal()` answers "was this met" for a single goal or — when
79
+ `acceptance` criteria are supplied — for each criterion independently (its
80
+ own optional `checkTool`, or an LLM judgment against its own description).
81
+ The overall verdict is the conjunction of all criteria. The harness has no
82
+ built-in tool registry: pass a `runCheckTool` callback that resolves a
83
+ `checkTool` name to something the host app can actually execute.
84
+
85
+ ## Proof packets
86
+
87
+ `buildProofPacket()` persists a compressed evidence artifact once a run
88
+ finishes: intended outcome, per-criterion results, checks run, evidence
89
+ snippets, known limits, and the run's fixed-worker epoch. Useful as an
90
+ audit trail without having to replay the full run transcript.
91
+
92
+ ## Setup
93
+
94
+ ```ts
95
+ import {
96
+ ensureHarnessIndexes,
97
+ reconcileOnBoot,
98
+ initDurableQueue,
99
+ registerExecutor,
100
+ getBootId,
101
+ } from "@johpaz/hive-sdk/harness";
102
+
103
+ await ensureHarnessIndexes(); // idempotent — safe every boot
104
+ await reconcileOnBoot(getBootId());
105
+
106
+ registerExecutor("my_job_type", async (job, signal) => {
107
+ // ... do the work, honoring `signal` for cancellation/timeout
108
+ return { ok: true, result: "done" };
109
+ });
110
+
111
+ const queue = initDurableQueue({ maxGlobalConcurrency: 4 });
112
+ await queue.enqueue({ lane: "session-1", type: "my_job_type", run_id: "r1", payload: {} });
113
+ ```
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.0.18",
4
- "private": false,
3
+ "version": "0.1.3",
5
4
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
5
  "license": "MIT",
7
6
  "homepage": "https://github.com/johpaz/hive-sdk#readme",
@@ -28,6 +27,33 @@
28
27
  ],
29
28
  "main": "./packages/core/src/index.ts",
30
29
  "types": "./packages/core/src/index.ts",
30
+ "exports": {
31
+ ".": "./packages/core/src/index.ts",
32
+ "./agent": "./packages/core/src/agent/index.ts",
33
+ "./agent/providers": "./packages/core/src/agent/providers/index.ts",
34
+ "./agent/selectors": "./packages/core/src/agent/selectors/index.ts",
35
+ "./tools": "./packages/core/src/tools/index.ts",
36
+ "./skills": "./packages/core/src/skills/index.ts",
37
+ "./storage": "./packages/core/src/storage/index.ts",
38
+ "./swarm": "./packages/core/src/swarm/index.ts",
39
+ "./swarm/strategies": "./packages/core/src/swarm/strategies/index.ts",
40
+ "./swarm/presets": "./packages/core/src/swarm/presets/index.ts",
41
+ "./ace": "./packages/core/src/ace/index.ts",
42
+ "./ethics": "./packages/core/src/ethics/index.ts",
43
+ "./canvas": "./packages/core/src/canvas/index.ts",
44
+ "./config": "./packages/core/src/config/index.ts",
45
+ "./mcp": "./packages/core/src/mcp/index.ts",
46
+ "./mcp/transports": "./packages/core/src/mcp/transports/index.ts",
47
+ "./memory": "./packages/core/src/memory/index.ts",
48
+ "./multimodal": "./packages/core/src/multimodal/index.ts",
49
+ "./security": "./packages/core/src/security/index.ts",
50
+ "./state": "./packages/core/src/state/index.ts",
51
+ "./utils": "./packages/core/src/utils/index.ts",
52
+ "./gateway": "./packages/core/src/gateway/index.ts",
53
+ "./api": "./packages/core/src/api/index.ts",
54
+ "./harness": "./packages/core/src/harness/index.ts",
55
+ "./package.json": "./package.json"
56
+ },
31
57
  "bin": {
32
58
  "hives": "./packages/cli/bin/hives"
33
59
  },
@@ -42,15 +68,21 @@
42
68
  "prepublish": "echo 'No build needed - Bun runs TypeScript directly'"
43
69
  },
44
70
  "dependencies": {
71
+ "@johpaz/hive-db": "^0.4.0",
45
72
  "@anthropic-ai/sdk": "^0.74.0",
46
73
  "@google/genai": "^1.43.0",
47
74
  "@modelcontextprotocol/sdk": "latest",
48
75
  "@sapphire/snowflake": "latest",
76
+ "@slack/bolt": "latest",
77
+ "@whiskeysockets/baileys": "latest",
49
78
  "async-mutex": "^0.5.0",
50
79
  "cron-parser": "^5.5.0",
51
80
  "croner": "^10.0.1",
81
+ "discord.js": "latest",
52
82
  "docx": "^9.6.1",
83
+ "grammy": "latest",
53
84
  "groq-sdk": "^0.37.0",
85
+ "jsonwebtoken": "^9.0.3",
54
86
  "jszip": "^3.10.1",
55
87
  "pdf-lib": "^1.17.1",
56
88
  "mammoth": "^1.12.0",
@@ -58,12 +90,14 @@
58
90
  "openai": "^6.18.0",
59
91
  "pdfjs-dist": "^5.6.205",
60
92
  "pptxgenjs": "^4.0.1",
93
+ "qrcode-terminal": "latest",
61
94
  "toon-format-parser": "^1.1.0",
62
95
  "xlsx": "^0.18.5",
63
96
  "zod": "latest"
64
97
  },
65
98
  "devDependencies": {
66
99
  "@types/bun": "^1.3.13",
100
+ "@types/jsonwebtoken": "^9.0.10",
67
101
  "typescript": "6.0.2"
68
102
  }
69
103
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hive/cli",
3
- "version": "0.0.15",
3
+ "version": "0.1.3",
4
4
  "description": "Hive CLI — init, run, test, trace",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hive/core",
3
- "version": "0.0.15",
3
+ "version": "0.1.3",
4
4
  "description": "Hive Core — Agentes AI con Context Engineering, FTS5, ACE, Swarm",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,18 +29,25 @@
29
29
  "./state": "./src/state/index.ts",
30
30
  "./utils": "./src/utils/index.ts",
31
31
  "./gateway": "./src/gateway/index.ts",
32
- "./api": "./src/api/index.ts"
32
+ "./api": "./src/api/index.ts",
33
+ "./harness": "./src/harness/index.ts"
33
34
  },
34
35
  "dependencies": {
36
+ "@johpaz/hive-db": "^0.4.0",
35
37
  "@anthropic-ai/sdk": "^0.74.0",
36
38
  "@google/genai": "^1.43.0",
37
39
  "@modelcontextprotocol/sdk": "latest",
38
40
  "@sapphire/snowflake": "latest",
41
+ "@slack/bolt": "latest",
42
+ "@whiskeysockets/baileys": "latest",
39
43
  "async-mutex": "^0.5.0",
40
44
  "cron-parser": "^5.5.0",
41
45
  "croner": "^10.0.1",
46
+ "discord.js": "latest",
42
47
  "docx": "^9.6.1",
48
+ "grammy": "latest",
43
49
  "groq-sdk": "^0.37.0",
50
+ "jsonwebtoken": "^9.0.3",
44
51
  "jszip": "^3.10.1",
45
52
  "pdf-lib": "^1.17.1",
46
53
  "mammoth": "^1.12.0",
@@ -48,10 +55,14 @@
48
55
  "openai": "^6.18.0",
49
56
  "pdfjs-dist": "^5.6.205",
50
57
  "pptxgenjs": "^4.0.1",
58
+ "qrcode-terminal": "latest",
51
59
  "toon-format-parser": "^1.1.0",
52
60
  "xlsx": "^0.18.5",
53
61
  "zod": "latest"
54
62
  },
63
+ "devDependencies": {
64
+ "@types/jsonwebtoken": "^9.0.10"
65
+ },
55
66
  "peerDependencies": {
56
67
  "typescript": "^5.0.0"
57
68
  }
@@ -89,7 +89,7 @@ export function recordLLMUsage(opts: {
89
89
  Promise.resolve().then(async () => {
90
90
  try {
91
91
  const { recordUsage } = await import("../storage/usage.ts")
92
- recordUsage({
92
+ await recordUsage({
93
93
  provider: opts.provider,
94
94
  model: opts.model,
95
95
  inputTokens: opts.inputTokens,
@@ -78,6 +78,16 @@ export interface AgentLoopOptions {
78
78
  signal?: AbortSignal
79
79
  /** Clean text for FTS5 and tracing (extracted from userMessage if multimodal) */
80
80
  rawUserMessage?: string
81
+ /**
82
+ * Per-call provider API key override, taking precedence over the DB-stored
83
+ * key resolved by `resolveProviderConfig`. Required for safe multi-tenant
84
+ * hosting (e.g. hive-cloud): without it, concurrent calls for different
85
+ * tenants using the same provider would race on `process.env[...]`, since
86
+ * that env var is process-global.
87
+ */
88
+ apiKey?: string
89
+ /** Per-call provider base URL override (self-hosted/proxy endpoints), same rationale as `apiKey`. */
90
+ baseUrl?: string
81
91
  }
82
92
 
83
93
  export interface StepEvent {
@@ -115,6 +125,8 @@ export async function* runAgent(
115
125
  agent.provider_id || "openai",
116
126
  agent.model_id || "gpt-4o-mini"
117
127
  )
128
+ if (opts.apiKey) providerCfg.apiKey = opts.apiKey
129
+ if (opts.baseUrl) providerCfg.baseUrl = opts.baseUrl
118
130
 
119
131
  const cleanModel = providerCfg.model.replace(new RegExp(`^${providerCfg.provider}\\/`), "")
120
132
  log.info(`[agent-loop] Starting: agent=${agentName} thread=${opts.threadId} provider=${providerCfg.provider}/${cleanModel}`)
@@ -138,9 +138,9 @@ export async function compileContext(opts: {
138
138
 
139
139
  // [STEP-2] STRATEGY 1: WRITE — Load scratchpad (persistent notes)
140
140
  log.info(`[context-compiler] [STEP-2] Loading scratchpad...`)
141
- let scratchpadNotes: ReturnType<typeof getScratchpad> = []
141
+ let scratchpadNotes: Awaited<ReturnType<typeof getScratchpad>> = []
142
142
  try {
143
- scratchpadNotes = getScratchpad(threadId)
143
+ scratchpadNotes = await getScratchpad(threadId)
144
144
  log.info(`[context-compiler] [STEP-2] ✅ Loaded ${scratchpadNotes.length} scratchpad notes`)
145
145
  } catch (err) {
146
146
  log.error(`[context-compiler] [STEP-2] ❌ FAILED loading scratchpad: ${JSON.stringify(err)}`)
@@ -257,7 +257,7 @@ export async function compileContext(opts: {
257
257
 
258
258
  try {
259
259
  // Load minimal skills (always available)
260
- minimalSkills = getMinimalSkills()
260
+ minimalSkills = await getMinimalSkills()
261
261
  log.info(`[context-compiler] [STEP-8b] ✅ Loaded ${minimalSkills.length} minimal skills`)
262
262
 
263
263
  // Discover additional skills via FTS5 (coordinator only)
@@ -268,7 +268,7 @@ export async function compileContext(opts: {
268
268
  : Array.isArray(inputForSkills)
269
269
  ? inputForSkills.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
270
270
  : String(inputForSkills)
271
- discoveredSkills = selectSkills(textMessage)
271
+ discoveredSkills = await selectSkills(textMessage)
272
272
  log.info(`[context-compiler] [STEP-8b] ✅ Discovered ${discoveredSkills.length} additional skills via FTS5`)
273
273
  }
274
274
  } catch (err) {
@@ -212,33 +212,43 @@ export function saveSummary(
212
212
  `).run(threadId, summary, messagesCovered, lastMessageId)
213
213
  }
214
214
 
215
+ import { getHiveDB } from "../storage/HiveDBStorage.ts";
216
+
217
+ interface ScratchpadDoc {
218
+ threadId: string;
219
+ key: string;
220
+ value: string;
221
+ updatedAt: number;
222
+ }
223
+
224
+ function scratchpadDocId(threadId: string, key: string): string {
225
+ return `${threadId}:${key}`;
226
+ }
227
+
215
228
  // ─── Scratchpad ───────────────────────────────────────────────────────────────
216
229
 
217
- export function saveScratchpadNote(
230
+ export async function saveScratchpadNote(
218
231
  threadId: string,
219
232
  key: string,
220
233
  value: string,
221
- source?: string
222
- ): void {
223
- const db = getDb()
224
- db.query(`
225
- INSERT INTO scratchpad (thread_id, key, value, source)
226
- VALUES (?, ?, ?, ?)
227
- ON CONFLICT(thread_id, key) DO UPDATE SET
228
- value = excluded.value,
229
- source = excluded.source,
230
- updated_at = unixepoch()
231
- `).run(threadId, key, value, source ?? null)
234
+ _source?: string
235
+ ): Promise<void> {
236
+ const db = await getHiveDB();
237
+ const col = db.collection<ScratchpadDoc>("scratchpad");
238
+ await col.put(scratchpadDocId(threadId, key), { threadId, key, value, updatedAt: Date.now() });
232
239
  }
233
240
 
234
- export function getScratchpad(threadId: string): Array<{ key: string; value: string }> {
235
- const db = getDb()
236
- return db.query(
237
- "SELECT key, value FROM scratchpad WHERE thread_id = ? ORDER BY updated_at DESC"
238
- ).all(threadId) as Array<{ key: string; value: string }>
241
+ export async function getScratchpad(threadId: string): Promise<Array<{ key: string; value: string }>> {
242
+ const db = await getHiveDB();
243
+ const col = db.collection<ScratchpadDoc>("scratchpad");
244
+ const entries = await col.scan();
245
+ return entries
246
+ .filter(e => e.doc.threadId === threadId)
247
+ .map(e => ({ key: e.doc.key, value: e.doc.value }));
239
248
  }
240
249
 
241
- export function deleteScratchpadNote(threadId: string, key: string): void {
242
- const db = getDb()
243
- db.query("DELETE FROM scratchpad WHERE thread_id = ? AND key = ?").run(threadId, key)
250
+ export async function deleteScratchpadNote(threadId: string, key: string): Promise<void> {
251
+ const db = await getHiveDB();
252
+ const col = db.collection<ScratchpadDoc>("scratchpad");
253
+ await col.delete(scratchpadDocId(threadId, key));
244
254
  }
@@ -1,19 +1,20 @@
1
1
  /**
2
- * FTS5-based Playbook Rules Selector (ACE Curator)
3
- *
4
- * This module allows the Context Compiler to inject relevant evolved rules
5
- * into the agent prompt based on semantic relevance to the current message.
2
+ * HiveDB-based Playbook Rules Selector (ACE Curator)
3
+ *
4
+ * Uses HiveDB hybrid search over the playbook index.
6
5
  */
7
6
 
8
- import { getDb } from "../../storage/SQLiteStorage.ts"
7
+ import { getHiveDB } from "../../storage/HiveDBStorage.ts"
9
8
  import { logger } from "../../utils/logger.ts"
9
+ import type { HivePlaybookDoc } from "../../storage/hiveSeed.ts"
10
+ import type { IndexDoc } from "@johpaz/hive-db"
10
11
 
11
12
  const log = logger.child("playbook-selector")
12
13
 
13
14
  // ─── Types ───────────────────────────────────────────────────────────────────────
14
15
 
15
16
  export interface PlaybookRule {
16
- id: number
17
+ id: string
17
18
  rule: string
18
19
  category: string
19
20
  applicable_to?: string
@@ -21,25 +22,27 @@ export interface PlaybookRule {
21
22
 
22
23
  // ─── Configuration ─────────────────────────────────────────────────────────────
23
24
 
24
- /** Maximum rules to inject per context window */
25
25
  const MAX_RULES_PER_TURN = 5
26
26
 
27
- /** Minimum bm25 score threshold for rules */
28
- const MIN_RELEVANCE_THRESHOLD = -10 // Relaxed for better matching
27
+ const MIN_RELEVANCE_THRESHOLD = 0.5
29
28
 
30
29
  // ─── Selection Logic ───────────────────────────────────────────────────────────
31
30
 
32
- /**
33
- * Select relevant rules from the Playbook based on semantic matching
34
- */
35
- export function selectPlaybookRules(message: string): PlaybookRule[] {
36
- const db = getDb()
31
+ function toRule(id: string, doc: HivePlaybookDoc): PlaybookRule {
32
+ return {
33
+ id,
34
+ rule: doc.rule,
35
+ category: doc.category,
36
+ applicable_to: doc.applicableTo ? doc.applicableTo.join(",") : undefined,
37
+ }
38
+ }
39
+
40
+ export async function selectPlaybookRules(message: string): Promise<PlaybookRule[]> {
41
+ const db = await getHiveDB()
37
42
  const startTime = performance.now()
38
43
 
39
- // Clean query — use prefix matching for consistency with skill-selector and tool-selector
40
44
  const keywords = message
41
45
  .toLowerCase()
42
- // Keep only letters, numbers, spaces (strips ALL FTS5 special syntax)
43
46
  .replace(/[^\p{L}\p{N}\s]/gu, " ")
44
47
  .split(/\s+/)
45
48
  .filter(w => w.length > 3)
@@ -47,32 +50,29 @@ export function selectPlaybookRules(message: string): PlaybookRule[] {
47
50
 
48
51
  if (keywords.length === 0) return []
49
52
 
50
- // Use prefix matching for better recall (e.g., "program*" matches "programar", "programación")
51
- const ftsQuery = keywords.map(w => `${w}*`).join(" OR ")
53
+ const query = keywords.join(" ")
52
54
 
53
55
  try {
54
- // Query FTS table
55
- const ftsResults = db.query(`
56
- SELECT rowid, bm25(playbook_fts) as score
57
- FROM playbook_fts
58
- WHERE playbook_fts MATCH ?
59
- ORDER BY score ASC
60
- LIMIT ?
61
- `).all(ftsQuery, MAX_RULES_PER_TURN) as Array<{ rowid: number; score: number }>
62
-
63
- const relevantIds = ftsResults
56
+ const hits = await db.queryHybrid({
57
+ text: query,
58
+ k: MAX_RULES_PER_TURN,
59
+ boosts: { body: 5.0, tags: 2.0, name: 1.0 },
60
+ })
61
+
62
+ const relevantIds = hits
64
63
  .filter(r => r.score >= MIN_RELEVANCE_THRESHOLD)
65
- .map(r => r.rowid)
64
+ .map(r => r.id)
66
65
 
67
66
  if (relevantIds.length === 0) return []
68
67
 
69
- // Fetch full rules
70
- const rules = db.query(`
71
- SELECT id, rule, category, applicable_to
72
- FROM playbook
73
- WHERE id IN (${relevantIds.map(() => '?').join(',')})
74
- AND active = 1
75
- `).all(...relevantIds) as PlaybookRule[]
68
+ const playbookCol = db.collection<HivePlaybookDoc>("playbook")
69
+ const rules: PlaybookRule[] = []
70
+ for (const id of relevantIds) {
71
+ const entry = await playbookCol.get(id)
72
+ if (entry && entry.doc.active) {
73
+ rules.push(toRule(id, entry.doc))
74
+ }
75
+ }
76
76
 
77
77
  const timing = performance.now() - startTime
78
78
  log.info(`[playbook-selector] Selected ${rules.length} rules in ${timing.toFixed(2)}ms`)
@@ -89,56 +89,30 @@ export function selectPlaybookRules(message: string): PlaybookRule[] {
89
89
 
90
90
  // ─── Sync Logic ───────────────────────────────────────────────────────────────
91
91
 
92
- /**
93
- * Sync active playbook rules to FTS5 virtual table
94
- */
95
92
  export async function syncPlaybookToFTS(): Promise<void> {
96
- const db = getDb()
93
+ const db = await getHiveDB()
97
94
 
98
95
  try {
99
- // Step 1: Get active rules
100
- const rules = db.query(`
101
- SELECT id, rule, category, applicable_to
102
- FROM playbook
103
- WHERE active = 1
104
- `).all() as Array<{
105
- id: number
106
- rule: string
107
- category: string
108
- applicable_to: string
109
- }>
96
+ const playbookCol = db.collection<HivePlaybookDoc>("playbook")
97
+ const entries = await playbookCol.scan()
98
+ const rules = entries.map(e => ({ id: e.id, doc: e.doc })).filter(r => r.doc.active)
110
99
 
111
100
  if (rules.length === 0) {
112
101
  log.debug(`[playbook-selector] No rules in playbook to sync`)
102
+ return
113
103
  }
114
104
 
115
- // Step 2: Atomic transaction for FTS5 sync
116
- const syncTransaction = db.transaction(() => {
117
- // Verify table exists
118
- const tableCheck = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='playbook_fts'").get()
119
- if (!tableCheck) {
120
- throw new Error("playbook_fts table does not exist!")
121
- }
122
-
123
- // A: Clear existing data
124
- db.run("DELETE FROM playbook_fts")
125
-
126
- // B: Prepare insertion
127
- const insert = db.prepare(`
128
- INSERT INTO playbook_fts(rowid, rule, category, applicable_to)
129
- VALUES (?, ?, ?, ?)
130
- `)
131
-
132
- // C: Re-populate
133
- for (const item of rules) {
134
- insert.run(item.id, item.rule, item.category, item.applicable_to)
135
- }
136
- })
105
+ const docs: IndexDoc[] = rules.map(r => ({
106
+ id: r.id,
107
+ name: r.doc.category,
108
+ body: r.doc.rule,
109
+ tags: r.doc.applicableTo ? r.doc.applicableTo.join(" ") : "",
110
+ filters: [{ field: "type", value: "playbook" }],
111
+ }))
137
112
 
138
- // Execute transaction
139
- syncTransaction()
113
+ await db.upsertBatch(docs)
140
114
 
141
- log.info(`[playbook-selector] Atomic sync complete: ${rules.length} rules indexed in FTS5`)
115
+ log.info(`[playbook-selector] Atomic sync complete: ${rules.length} rules indexed in HiveDB`)
142
116
 
143
117
  } catch (err) {
144
118
  log.error(`[playbook-selector] Transactional sync failed:`, err)