hippo-memory 1.61.0 → 1.63.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 (377) hide show
  1. package/README.md +37 -53
  2. package/dist/agent-memories/apply.d.ts +3 -1
  3. package/dist/agent-memories/apply.js +23 -1
  4. package/dist/agent-memories/claude-code.d.ts +4 -1
  5. package/dist/agent-memories/claude-code.js +53 -9
  6. package/dist/agent-memories/report.d.ts +1 -0
  7. package/dist/agent-memories/report.js +2 -2
  8. package/dist/agent-memories/sync.d.ts +3 -3
  9. package/dist/agent-memories/sync.js +46 -24
  10. package/dist/agent-memories/types.d.ts +0 -2
  11. package/dist/ambient-store.d.ts +4 -4
  12. package/dist/ambient-store.js +4 -3
  13. package/dist/api/assemble.d.ts +7 -10
  14. package/dist/api/assemble.js +62 -66
  15. package/dist/api/audit.d.ts +2 -2
  16. package/dist/api/audit.js +2 -2
  17. package/dist/api/auth.d.ts +6 -6
  18. package/dist/api/auth.js +5 -7
  19. package/dist/api/context-select.d.ts +50 -0
  20. package/dist/api/context-select.js +342 -0
  21. package/dist/api/context-types.d.ts +12 -17
  22. package/dist/api/context.d.ts +5 -4
  23. package/dist/api/context.js +208 -535
  24. package/dist/api/dormant.js +1 -1
  25. package/dist/api/drill-down.d.ts +4 -4
  26. package/dist/api/drill-down.js +56 -41
  27. package/dist/api/outcome.d.ts +8 -13
  28. package/dist/api/outcome.js +13 -17
  29. package/dist/api/promote.d.ts +5 -8
  30. package/dist/api/promote.js +55 -66
  31. package/dist/api/quarantine.js +3 -2
  32. package/dist/api/recall-types.d.ts +43 -55
  33. package/dist/api/recall.d.ts +3 -3
  34. package/dist/api/recall.js +310 -456
  35. package/dist/api/remember.d.ts +2 -2
  36. package/dist/api/sleep.d.ts +7 -35
  37. package/dist/api/sleep.js +206 -220
  38. package/dist/api/tokens.d.ts +2 -2
  39. package/dist/api/tokens.js +2 -2
  40. package/dist/api/types.d.ts +6 -10
  41. package/dist/api/types.js +2 -4
  42. package/dist/audit-prune.d.ts +4 -6
  43. package/dist/audit-prune.js +3 -5
  44. package/dist/audit.js +11 -33
  45. package/dist/auth.d.ts +8 -9
  46. package/dist/auth.js +5 -7
  47. package/dist/autolearn.d.ts +1 -1
  48. package/dist/autolearn.js +1 -1
  49. package/dist/availability.js +3 -5
  50. package/dist/capture/command.d.ts +5 -4
  51. package/dist/capture/command.js +8 -22
  52. package/dist/capture/compact.d.ts +2 -2
  53. package/dist/capture/compact.js +11 -15
  54. package/dist/capture/extract.js +37 -115
  55. package/dist/capture-error.d.ts +1 -1
  56. package/dist/capture-error.js +1 -1
  57. package/dist/churn-git.d.ts +1 -1
  58. package/dist/churn-git.js +1 -1
  59. package/dist/cli/audit.js +3 -4
  60. package/dist/cli/auth.js +3 -6
  61. package/dist/cli/briefs.js +324 -306
  62. package/dist/cli/context.js +44 -34
  63. package/dist/cli/continuity.js +283 -271
  64. package/dist/cli/curate.d.ts +1 -1
  65. package/dist/cli/curate.js +43 -58
  66. package/dist/cli/dag.js +5 -9
  67. package/dist/cli/decisions.js +334 -345
  68. package/dist/cli/explain.js +68 -61
  69. package/dist/cli/goals.js +1 -1
  70. package/dist/cli/init.js +1 -1
  71. package/dist/cli/maintenance.js +62 -51
  72. package/dist/cli/playbooks.js +391 -379
  73. package/dist/cli/projects.js +11 -6
  74. package/dist/cli/recall.js +30 -44
  75. package/dist/cli/remember.js +118 -87
  76. package/dist/cli/session-hooks.js +106 -115
  77. package/dist/cli/setup.d.ts +1 -1
  78. package/dist/cli/setup.js +267 -250
  79. package/dist/cli/shared.js +3 -3
  80. package/dist/cli/slack.js +1 -1
  81. package/dist/cli/sleep.js +27 -1
  82. package/dist/cli/status.d.ts +4 -4
  83. package/dist/cli/status.js +80 -76
  84. package/dist/cli/transfer.js +88 -107
  85. package/dist/cli/usage.js +9 -6
  86. package/dist/cli.d.ts +1 -1
  87. package/dist/cli.js +4 -9
  88. package/dist/compaction-record.d.ts +2 -2
  89. package/dist/compaction-record.js +89 -68
  90. package/dist/compare.d.ts +11 -16
  91. package/dist/compare.js +11 -16
  92. package/dist/config.d.ts +19 -18
  93. package/dist/config.js +90 -67
  94. package/dist/connectors/github/backfill.d.ts +2 -2
  95. package/dist/connectors/github/backfill.js +8 -15
  96. package/dist/connectors/github/cli-impl.js +3 -8
  97. package/dist/connectors/github/deletion.d.ts +5 -12
  98. package/dist/connectors/github/deletion.js +5 -12
  99. package/dist/connectors/github/dlq.d.ts +6 -9
  100. package/dist/connectors/github/dlq.js +2 -3
  101. package/dist/connectors/github/ingest.d.ts +5 -7
  102. package/dist/connectors/github/ingest.js +8 -12
  103. package/dist/connectors/github/octokit-client.d.ts +3 -5
  104. package/dist/connectors/github/octokit-client.js +5 -6
  105. package/dist/connectors/github/signature.d.ts +9 -39
  106. package/dist/connectors/github/signature.js +9 -39
  107. package/dist/connectors/github/tenant-routing.d.ts +1 -1
  108. package/dist/connectors/github/tenant-routing.js +1 -1
  109. package/dist/connectors/github/transform.js +2 -2
  110. package/dist/connectors/github/types.d.ts +2 -10
  111. package/dist/connectors/github/types.js +1 -3
  112. package/dist/connectors/slack/deletion.d.ts +3 -8
  113. package/dist/connectors/slack/deletion.js +3 -8
  114. package/dist/connectors/slack/dlq.d.ts +1 -1
  115. package/dist/connectors/slack/ingest.d.ts +1 -1
  116. package/dist/connectors/slack/ingest.js +7 -16
  117. package/dist/connectors/slack/signature.d.ts +1 -1
  118. package/dist/connectors/slack/tenant-routing.d.ts +3 -5
  119. package/dist/connectors/slack/tenant-routing.js +3 -5
  120. package/dist/connectors/slack/transform.d.ts +5 -6
  121. package/dist/connectors/slack/transform.js +5 -6
  122. package/dist/connectors/slack/types.d.ts +2 -6
  123. package/dist/connectors/slack/types.js +1 -3
  124. package/dist/connectors/slack/web-client.js +10 -3
  125. package/dist/connectors/slack/workspaces.d.ts +3 -5
  126. package/dist/connectors/slack/workspaces.js +3 -5
  127. package/dist/consolidate/conflicts.js +3 -14
  128. package/dist/consolidate/decay.js +9 -29
  129. package/dist/consolidate/llm-passes.js +4 -5
  130. package/dist/consolidate/merge.js +8 -23
  131. package/dist/consolidate/run.d.ts +1 -8
  132. package/dist/consolidate/run.js +3 -25
  133. package/dist/consolidate/sleep.js +5 -17
  134. package/dist/consolidate/traces.js +9 -21
  135. package/dist/customer-notes.d.ts +5 -7
  136. package/dist/customer-notes.js +82 -76
  137. package/dist/dag.d.ts +10 -21
  138. package/dist/dag.js +189 -203
  139. package/dist/db/continuity.js +2 -2
  140. package/dist/db/migrations/v14.js +1 -1
  141. package/dist/db/migrations/v15.js +1 -2
  142. package/dist/db/migrations/v16.js +3 -4
  143. package/dist/db/migrations/v17.js +2 -3
  144. package/dist/db/migrations/v19.js +1 -1
  145. package/dist/db/migrations/v20.js +1 -1
  146. package/dist/db/migrations/v21.js +2 -6
  147. package/dist/db/migrations/v22.js +2 -4
  148. package/dist/db/migrations/v23.js +1 -1
  149. package/dist/db/migrations/v24.js +4 -6
  150. package/dist/db/migrations/v25.js +2 -3
  151. package/dist/db/migrations/v26.js +3 -3
  152. package/dist/db/migrations/v27.js +2 -10
  153. package/dist/db/migrations/v28.js +5 -8
  154. package/dist/db/migrations/v29.js +3 -4
  155. package/dist/db/migrations/v30.js +2 -2
  156. package/dist/db/migrations/v31.js +1 -1
  157. package/dist/db/migrations/v32.js +1 -1
  158. package/dist/db/migrations/v33.js +3 -3
  159. package/dist/db/migrations/v34.js +1 -1
  160. package/dist/db/migrations/v35.js +3 -4
  161. package/dist/db/migrations/v36.js +3 -4
  162. package/dist/db/migrations/v37.js +5 -5
  163. package/dist/db/migrations/v38.js +7 -8
  164. package/dist/db/migrations/v39.js +1 -1
  165. package/dist/db/migrations/v40.js +4 -16
  166. package/dist/db/migrations/v41.js +3 -4
  167. package/dist/db/migrations/v42.js +3 -4
  168. package/dist/db/migrations/v45.js +1 -1
  169. package/dist/db/migrations/v46.js +1 -1
  170. package/dist/db/migrations/v47.js +1 -1
  171. package/dist/db/migrations/v48.js +1 -1
  172. package/dist/decisions.d.ts +2 -2
  173. package/dist/decisions.js +97 -80
  174. package/dist/dedupe.js +86 -61
  175. package/dist/delivery-recorder.js +154 -135
  176. package/dist/doctor.js +129 -110
  177. package/dist/dormant.js +1 -4
  178. package/dist/embedding-provider.d.ts +4 -8
  179. package/dist/embedding-provider.js +4 -8
  180. package/dist/embeddings.js +55 -47
  181. package/dist/env.d.ts +1 -1
  182. package/dist/env.js +12 -12
  183. package/dist/escape.d.ts +5 -0
  184. package/dist/escape.js +10 -0
  185. package/dist/eval-stats.d.ts +1 -2
  186. package/dist/eval-stats.js +1 -2
  187. package/dist/eval-suite.js +27 -21
  188. package/dist/extract.js +4 -9
  189. package/dist/failure-log.d.ts +3 -3
  190. package/dist/failure-log.js +1 -1
  191. package/dist/forward-claim-detector.d.ts +2 -4
  192. package/dist/forward-claim-detector.js +6 -11
  193. package/dist/goals.d.ts +3 -3
  194. package/dist/goals.js +103 -91
  195. package/dist/graph/read.d.ts +2 -2
  196. package/dist/graph/read.js +5 -6
  197. package/dist/graph/types.d.ts +8 -8
  198. package/dist/graph/write.d.ts +7 -14
  199. package/dist/graph/write.js +16 -23
  200. package/dist/graph-extract.d.ts +7 -8
  201. package/dist/graph-extract.js +62 -72
  202. package/dist/graph-recall.d.ts +2 -2
  203. package/dist/graph-recall.js +55 -49
  204. package/dist/graph-stream.d.ts +5 -6
  205. package/dist/graph-stream.js +66 -57
  206. package/dist/graph-view.d.ts +2 -2
  207. package/dist/graph-view.js +7 -7
  208. package/dist/half-life-migration.d.ts +1 -2
  209. package/dist/half-life-migration.js +2 -3
  210. package/dist/hooks/codex-session.js +1 -1
  211. package/dist/hooks/codex-wrapper.d.ts +1 -1
  212. package/dist/hooks/codex-wrapper.js +3 -2
  213. package/dist/hooks/json-hooks.d.ts +2 -2
  214. package/dist/hooks/json-hooks.js +5 -4
  215. package/dist/hooks/opencode.d.ts +1 -1
  216. package/dist/hooks/opencode.js +5 -4
  217. package/dist/hooks/shared.d.ts +3 -7
  218. package/dist/hooks/shared.js +1 -8
  219. package/dist/http-util.d.ts +2 -3
  220. package/dist/http-util.js +3 -0
  221. package/dist/importers/core.d.ts +2 -9
  222. package/dist/importers/core.js +15 -30
  223. package/dist/importers/sources.js +2 -1
  224. package/dist/importers/vault.js +2 -20
  225. package/dist/incidents.d.ts +1 -1
  226. package/dist/incidents.js +46 -39
  227. package/dist/instruction-detect.d.ts +1 -1
  228. package/dist/instruction-detect.js +1 -1
  229. package/dist/invalidation.d.ts +3 -0
  230. package/dist/invalidation.js +160 -114
  231. package/dist/json.d.ts +5 -0
  232. package/dist/json.js +4 -0
  233. package/dist/judgment.js +1 -2
  234. package/dist/local-embedding.js +1 -1
  235. package/dist/mcp/admin-tools.js +7 -17
  236. package/dist/mcp/format.js +1 -1
  237. package/dist/mcp/framing.js +3 -6
  238. package/dist/mcp/protocol.d.ts +2 -5
  239. package/dist/mcp/protocol.js +1 -3
  240. package/dist/mcp/recall-tools.js +12 -15
  241. package/dist/mcp/request.js +4 -3
  242. package/dist/mcp/session-state.js +2 -3
  243. package/dist/mcp/stdio.js +2 -1
  244. package/dist/mcp/tools.js +9 -6
  245. package/dist/memory-value-weights.d.ts +5 -8
  246. package/dist/memory-value-weights.js +5 -8
  247. package/dist/memory-value.d.ts +13 -13
  248. package/dist/memory-value.js +26 -37
  249. package/dist/memory.d.ts +20 -22
  250. package/dist/memory.js +24 -48
  251. package/dist/multihop.d.ts +1 -1
  252. package/dist/multihop.js +3 -2
  253. package/dist/owner-validation.d.ts +4 -5
  254. package/dist/owner-validation.js +4 -5
  255. package/dist/physics.d.ts +4 -4
  256. package/dist/physics.js +7 -9
  257. package/dist/policies.d.ts +9 -10
  258. package/dist/policies.js +96 -81
  259. package/dist/postinstall.js +3 -6
  260. package/dist/predictions/planning-fallacy.d.ts +9 -14
  261. package/dist/predictions/planning-fallacy.js +10 -16
  262. package/dist/predictions/store.d.ts +15 -23
  263. package/dist/predictions/store.js +36 -33
  264. package/dist/processes.d.ts +2 -7
  265. package/dist/processes.js +88 -72
  266. package/dist/project-briefs.d.ts +2 -3
  267. package/dist/project-briefs.js +141 -118
  268. package/dist/project-identity.d.ts +22 -9
  269. package/dist/project-identity.js +47 -12
  270. package/dist/project-merge.d.ts +28 -5
  271. package/dist/project-merge.js +213 -46
  272. package/dist/project-remote.d.ts +12 -0
  273. package/dist/project-remote.js +138 -0
  274. package/dist/prompt-recall.js +1 -2
  275. package/dist/rate-limit.d.ts +1 -1
  276. package/dist/rate-limit.js +1 -1
  277. package/dist/raw-archive.d.ts +9 -0
  278. package/dist/raw-archive.js +70 -53
  279. package/dist/recall-history.d.ts +19 -20
  280. package/dist/recall-history.js +24 -42
  281. package/dist/recall-pipeline.js +4 -28
  282. package/dist/recall-scope.d.ts +7 -8
  283. package/dist/recall-scope.js +7 -8
  284. package/dist/recall-trace.d.ts +5 -9
  285. package/dist/recall-trace.js +6 -10
  286. package/dist/refine-llm.d.ts +1 -1
  287. package/dist/refine-llm.js +2 -2
  288. package/dist/reject-flow.d.ts +3 -4
  289. package/dist/reject-flow.js +122 -117
  290. package/dist/rejection.d.ts +5 -6
  291. package/dist/rejection.js +7 -15
  292. package/dist/rerankers/clef.d.ts +1 -1
  293. package/dist/rerankers/jev.d.ts +1 -2
  294. package/dist/rerankers/jev.js +4 -5
  295. package/dist/rerankers/llm.d.ts +1 -2
  296. package/dist/rerankers/llm.js +1 -2
  297. package/dist/rerankers/types.d.ts +1 -2
  298. package/dist/rrf.d.ts +2 -2
  299. package/dist/rrf.js +2 -2
  300. package/dist/search/bm25-search.d.ts +1 -1
  301. package/dist/search/bm25-search.js +2 -1
  302. package/dist/search/boosts.js +2 -1
  303. package/dist/search/hybrid.d.ts +1 -1
  304. package/dist/search/hybrid.js +2 -1
  305. package/dist/search/physics-search.d.ts +1 -1
  306. package/dist/search/physics-search.js +2 -1
  307. package/dist/search/types.d.ts +2 -0
  308. package/dist/search/types.js +3 -1
  309. package/dist/secret-detect.d.ts +4 -5
  310. package/dist/secret-detect.js +6 -10
  311. package/dist/server/auth.js +5 -5
  312. package/dist/server/client-ip.js +1 -1
  313. package/dist/server/cursor.js +2 -1
  314. package/dist/server/mcp-http.js +4 -4
  315. package/dist/server/request.d.ts +3 -6
  316. package/dist/server/request.js +6 -7
  317. package/dist/server/routes/admin.js +5 -4
  318. package/dist/server/routes/customer-notes.js +6 -5
  319. package/dist/server/routes/decisions.js +4 -3
  320. package/dist/server/routes/incidents.js +7 -5
  321. package/dist/server/routes/memories.js +7 -7
  322. package/dist/server/routes/policies.js +3 -2
  323. package/dist/server/routes/predictions.js +12 -15
  324. package/dist/server/routes/processes.js +3 -2
  325. package/dist/server/routes/project-briefs.js +8 -7
  326. package/dist/server/routes/recall.js +95 -93
  327. package/dist/server/routes/skills.js +6 -5
  328. package/dist/server/types.d.ts +1 -1
  329. package/dist/server/validation.d.ts +1 -2
  330. package/dist/server/validation.js +7 -14
  331. package/dist/server-detect.js +72 -58
  332. package/dist/server.d.ts +2 -2
  333. package/dist/server.js +131 -117
  334. package/dist/shared.d.ts +26 -17
  335. package/dist/shared.js +102 -104
  336. package/dist/skills.d.ts +3 -3
  337. package/dist/skills.js +88 -72
  338. package/dist/store/audit-event.d.ts +2 -2
  339. package/dist/store/audit-event.js +1 -1
  340. package/dist/store/candidates.d.ts +2 -2
  341. package/dist/store/candidates.js +4 -3
  342. package/dist/store/conflicts.js +30 -22
  343. package/dist/store/delete-and-batch.d.ts +11 -14
  344. package/dist/store/delete-and-batch.js +40 -91
  345. package/dist/store/entry-reads.d.ts +17 -25
  346. package/dist/store/entry-reads.js +59 -40
  347. package/dist/store/entry-row.d.ts +6 -24
  348. package/dist/store/entry-row.js +6 -24
  349. package/dist/store/entry-writes.d.ts +6 -7
  350. package/dist/store/entry-writes.js +13 -11
  351. package/dist/store/handoffs.d.ts +1 -1
  352. package/dist/store/handoffs.js +7 -10
  353. package/dist/store/index-and-stats.d.ts +2 -6
  354. package/dist/store/index-and-stats.js +4 -10
  355. package/dist/store/mirrors.d.ts +6 -19
  356. package/dist/store/mirrors.js +14 -39
  357. package/dist/store/open.js +9 -31
  358. package/dist/store/rows.d.ts +5 -11
  359. package/dist/store/rows.js +6 -11
  360. package/dist/store/search-rows.d.ts +17 -34
  361. package/dist/store/search-rows.js +34 -56
  362. package/dist/store/sessions.d.ts +4 -5
  363. package/dist/store/sessions.js +5 -6
  364. package/dist/store/summaries.d.ts +13 -17
  365. package/dist/store/summaries.js +26 -70
  366. package/dist/support-bundle.js +4 -8
  367. package/dist/tenant.d.ts +1 -5
  368. package/dist/token-ledger.d.ts +1 -1
  369. package/dist/token-ledger.js +3 -5
  370. package/dist/trace.js +1 -3
  371. package/dist/version.d.ts +1 -1
  372. package/dist/version.js +1 -1
  373. package/dist/working-memory.d.ts +1 -1
  374. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  375. package/extensions/openclaw-plugin/package.json +1 -1
  376. package/openclaw.plugin.json +1 -1
  377. package/package.json +1 -1
package/dist/shared.js CHANGED
@@ -19,8 +19,9 @@ import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall
19
19
  import { search } from './search/bm25-search.js';
20
20
  import { hybridSearch } from './search/hybrid.js';
21
21
  import { fitBudget } from './search/finalize.js';
22
+ import { DEFAULT_LOCAL_BUMP, DEFAULT_RECALL_BUDGET } from './search/types.js';
22
23
  import { evalNow } from './ablation.js';
23
- import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
24
+ import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir, resolveProjectIdentity } from './project-identity.js';
24
25
  import { detectSecret } from './secret-detect.js';
25
26
  import { isQuarantineScope } from './quarantine.js';
26
27
  import { RejectedValueError } from './rejection.js';
@@ -62,12 +63,12 @@ export function promoteToGlobal(localRoot, id, opts) {
62
63
  const entry = readEntry(localRoot, id, opts?.tenantId);
63
64
  if (!entry)
64
65
  throw new NotFoundError(`Memory not found: ${id}`);
65
- // CD5: same veto as shareMemory; a promoted copy would have no quarantine record to review.
66
+ // Same quarantine veto as shareMemory; a promoted copy would have no quarantine record to review.
66
67
  if (isQuarantineScope(entry.scope)) {
67
68
  throw new BadRequestError(`Refusing to promote ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
68
69
  }
69
- // v39 S4 producer veto: promote is a producer path to the global store
70
- // exactly like shareMemory - same hard rule (codex gating review P2).
70
+ // Secret producer veto: promote is a producer path to the global store
71
+ // exactly like shareMemory - same hard rule.
71
72
  const promoteSecret = detectSecret(entry);
72
73
  if (promoteSecret.flagged) {
73
74
  throw new BadRequestError(`Refusing to promote ${id} to the global store: content matches secret material (${promoteSecret.reason}). ` +
@@ -95,7 +96,7 @@ export function promoteToGlobal(localRoot, id, opts) {
95
96
  * Returns results sorted by adjusted score, within combined token budget.
96
97
  */
97
98
  export function searchBoth(query, localRoot, globalRoot, options = {}) {
98
- const { budget = 4000, now = evalNow(), minResults, tenantId } = options;
99
+ const { budget = DEFAULT_RECALL_BUDGET, now = evalNow(), minResults, tenantId } = options;
99
100
  const effectiveMin = minResults ?? 1;
100
101
  const localEntries = fs.existsSync(localRoot) ? loadSearchEntries(localRoot, query, undefined, tenantId) : [];
101
102
  const globalEntries = fs.existsSync(globalRoot) ? loadSearchEntries(globalRoot, query, undefined, tenantId) : [];
@@ -105,14 +106,13 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
105
106
  const localResults = search(query, localEntries, { budget, now, minResults });
106
107
  const globalResults = search(query, globalEntries, { budget, now, minResults });
107
108
  // Tag global results. Local memories get a configurable priority bump.
108
- const syncLocalBump = 1.2;
109
109
  const tagged = [
110
110
  ...localResults.map((r) => ({
111
111
  ...r,
112
112
  isGlobal: false,
113
- score: r.score * syncLocalBump,
113
+ score: r.score * DEFAULT_LOCAL_BUMP,
114
114
  breakdown: r.breakdown
115
- ? { ...r.breakdown, sourceBump: syncLocalBump, final: r.breakdown.final * syncLocalBump }
115
+ ? { ...r.breakdown, sourceBump: DEFAULT_LOCAL_BUMP, final: r.breakdown.final * DEFAULT_LOCAL_BUMP }
116
116
  : undefined,
117
117
  })),
118
118
  ...globalResults.map((r) => ({ ...r, isGlobal: true })),
@@ -126,11 +126,8 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
126
126
  seen.add(key);
127
127
  return true;
128
128
  });
129
- // T2 note: PLAIN stable score sort on purpose -- local/global inputs are
130
- // each deterministically ordered (content tail applied in the underlying
131
- // search), stability inherits that, and an exact post-bump tie keeps the
132
- // LOCAL result ahead of the global one (the concat order), preserving the
133
- // pre-T2 semantics.
129
+ // PLAIN stable score sort on purpose: both inputs are deterministically ordered,
130
+ // and an exact tie keeps the LOCAL result ahead of the global one (concat order).
134
131
  deduped.sort((a, b) => b.score - a.score);
135
132
  // Apply combined token budget (guarantee at least minResults items)
136
133
  const results = [];
@@ -148,19 +145,13 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
148
145
  * Async version of searchBoth that calls hybridSearch instead of search.
149
146
  */
150
147
  export async function searchBothHybrid(query, localRoot, globalRoot, options = {}) {
151
- const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
148
+ const { includeSuperseded, asOf, tenantId, entryFilter, recallScope } = options;
152
149
  // When an admission filter is active, lift the per-store candidate cap
153
150
  // (default 200): excluded rows matching the query could otherwise fill the
154
- // window before any admitted row is even loaded (codex gating round 6).
155
- // Only ambient-context query mode sets entryFilter, and that path is
156
- // interactive - never the per-turn pinned-only hook - so ranking the full
157
- // match set is acceptable.
158
- // 5000 = 25x the default 200-row window: large enough that exclusion
159
- // crowding is a non-issue on real stores, bounded so a common query term
160
- // on a 100k-row store cannot stall an interactive call by ranking every
161
- // match (post-merge adversarial review, 2026-07-02).
151
+ // window before any admitted row is even loaded. 5000 is bounded so a common
152
+ // term on a large store cannot stall an interactive call by ranking every match.
162
153
  const searchWindow = entryFilter ? 5000 : undefined;
163
- // v1.25.0 recall mode: push the scope predicate into SQL exactly like
154
+ // Recall mode: push the scope predicate into SQL exactly like
164
155
  // api.recall (loadRecallSearchEntries), so quarantine/private rows never
165
156
  // enter the candidate set, never shadow admitted duplicates in the dedupe
166
157
  // pass, and never consume budget. The JS post-filter below is the
@@ -179,8 +170,6 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
179
170
  const admit = (e) => passesScope(e) && (!entryFilter || entryFilter(e));
180
171
  const localEntries = loadEntries(localRoot).filter(admit);
181
172
  const globalEntries = loadEntries(globalRoot).filter(admit);
182
- if (localEntries.length === 0 && globalEntries.length === 0)
183
- return [];
184
173
  // The vector arm loads under the same SQL rules as loadEntries, then the same JS admission.
185
174
  const vectorCandidates = {
186
175
  tenantId,
@@ -188,9 +177,16 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
188
177
  includeSuperseded: !recallScope || Boolean(includeSuperseded) || Boolean(asOf),
189
178
  admit,
190
179
  };
180
+ return rankBothStores(query, { local: localRoot, global: globalRoot }, { local: localEntries, global: globalEntries }, vectorCandidates, options);
181
+ }
182
+ /** Hybrid ranking of rows already loaded from each store: the local bump, one copy per text, then the shared budget. */
183
+ export async function rankBothStores(query, roots, entries, vectorCandidates, options = {}) {
184
+ const { budget = DEFAULT_RECALL_BUDGET, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = DEFAULT_LOCAL_BUMP, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness } = options;
185
+ if (entries.local.length === 0 && entries.global.length === 0)
186
+ return [];
191
187
  const shared = { budget, now, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness, vectorCandidates };
192
- const localResults = await hybridSearch(query, localEntries, { ...shared, hippoRoot: localRoot });
193
- const globalResults = await hybridSearch(query, globalEntries, { ...shared, hippoRoot: globalRoot });
188
+ const localResults = await hybridSearch(query, entries.local, { ...shared, hippoRoot: roots.local });
189
+ const globalResults = await hybridSearch(query, entries.global, { ...shared, hippoRoot: roots.global });
194
190
  // Tag global results. Local memories get a configurable priority bump.
195
191
  const tagged = [
196
192
  ...localResults.map((r) => ({
@@ -212,7 +208,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
212
208
  seen.add(key);
213
209
  return true;
214
210
  });
215
- // T2 note: PLAIN stable score sort on purpose -- see searchBoth above;
211
+ // PLAIN stable score sort on purpose -- see searchBoth above;
216
212
  // same rationale (deterministic inputs + stability; local-first on ties).
217
213
  deduped.sort((a, b) => b.score - a.score);
218
214
  return fitBudget(deduped, budget, minResults ?? 1, cost);
@@ -284,7 +280,7 @@ export function shareMemory(localRoot, id, options = {}) {
284
280
  const entry = readEntry(localRoot, id, options.tenantId);
285
281
  if (!entry)
286
282
  throw new NotFoundError(`Memory not found: ${id}`);
287
- // v39 S4 producer veto: secrets never go to the global store, not even
283
+ // Secret producer veto: secrets never go to the global store, not even
288
284
  // with --force. Explicit and loud - a silent null would read as "low
289
285
  // transfer score" and invite retries.
290
286
  const secret = detectSecret(entry);
@@ -292,7 +288,7 @@ export function shareMemory(localRoot, id, options = {}) {
292
288
  throw new BadRequestError(`Refusing to share ${id} to the global store: content matches secret material (${secret.reason}). ` +
293
289
  `Secrets stay in their owning project's store.`);
294
290
  }
295
- // CD5: a quarantined row is unreviewed input, not a lesson; sharing it would spread poison globally.
291
+ // A quarantined row is unreviewed input, not a lesson; sharing it would spread poison globally.
296
292
  if (isQuarantineScope(entry.scope)) {
297
293
  throw new BadRequestError(`Refusing to share ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
298
294
  }
@@ -326,7 +322,7 @@ export function shareMemory(localRoot, id, options = {}) {
326
322
  * List all projects that have contributed memories to the global store.
327
323
  * Parses the source field for 'shared:<project>:' or 'promoted:<path>' patterns.
328
324
  *
329
- * D4 v1.12.10: `tenantId` is now optional. When provided, the global entries
325
+ * `tenantId` is optional. When provided, the global entries
330
326
  * are filtered to that tenant before aggregation — matches every other
331
327
  * read path's default-safe behaviour. When undefined, host-wide (back-compat
332
328
  * for legacy callers like CLI standalone + dashboard internal use). Operators
@@ -337,7 +333,7 @@ export function listPeers(globalRoot, tenantId) {
337
333
  const root = globalRoot ?? getGlobalRoot();
338
334
  if (!fs.existsSync(root))
339
335
  return [];
340
- // D4: tenant-scoped by default when tenantId provided. Host-wide when
336
+ // Tenant-scoped by default when tenantId provided. Host-wide when
341
337
  // undefined (preserves back-compat).
342
338
  const tallies = tallySources(root, tenantId).sort((a, b) => (a.first < b.first ? -1 : a.first > b.first ? 1 : 0));
343
339
  const peerMap = new Map();
@@ -368,25 +364,85 @@ export function listPeers(globalRoot, tenantId) {
368
364
  .map(([project, data]) => ({ project, ...data }))
369
365
  .sort((a, b) => b.count - a.count);
370
366
  }
367
+ function isAutoShareCandidate(entry, globalContentSet, minScore, stats) {
368
+ // shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
369
+ if (isQuarantineScope(entry.scope ?? null))
370
+ return false;
371
+ // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
372
+ if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t)) || entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX)) {
373
+ if (stats)
374
+ stats.neverAutoShareSkipped = (stats.neverAutoShareSkipped ?? 0) + 1;
375
+ return false;
376
+ }
377
+ const score = transferScore(entry);
378
+ if (score < minScore)
379
+ return false;
380
+ // Skip if already shared (same text apart from spacing)
381
+ if (globalContentSet.has(duplicateKey(entry.content)))
382
+ return false;
383
+ // Secret producer veto: secret rows never auto-share, regardless of
384
+ // transfer score. (shareMemory would throw; filtering here keeps the
385
+ // sleep pipeline fail-safe.) Checked LAST so the stats counter
386
+ // only counts rows the veto actually withheld — a row failing the score
387
+ // or dedupe gates was never going to share, secret or not.
388
+ if (detectSecret(entry).flagged) {
389
+ if (stats)
390
+ stats.secretSkipped++;
391
+ return false;
392
+ }
393
+ return true;
394
+ }
395
+ // Rejection containment (sync/promote/share copy paths must not let ONE rejected
396
+ // candidate kill the batch): shareMemory -> writeEntry hits the LIVE guard
397
+ // against the GLOBAL store's tombstones. A matching candidate throws
398
+ // RejectedValueError, which (uncaught) would abort this whole loop and,
399
+ // via api.ts's sleep pipeline, the entire autoShare sleep phase. Mirrors
400
+ // syncGlobalToLocal's per-item catch just above in this file. writeEntry's
401
+ // own catch already writes the reject_refusal audit before rethrowing,
402
+ // so do not double-audit here, just count and continue.
403
+ function shareCandidates(localRoot, candidates, stats) {
404
+ const shared = [];
405
+ let rejectedSkipped = 0;
406
+ for (const entry of candidates) {
407
+ try {
408
+ // skipEmbed: batching invariant, this is a batch producer, so it embeds
409
+ // once via embedAll() below rather than once per row inside shareMemory.
410
+ const result = shareMemory(localRoot, entry.id, { force: true, skipEmbed: true });
411
+ if (result)
412
+ shared.push(result);
413
+ }
414
+ catch (err) {
415
+ if (err instanceof RejectedValueError) {
416
+ rejectedSkipped++;
417
+ if (stats)
418
+ stats.rejectedSkipped = (stats.rejectedSkipped ?? 0) + 1;
419
+ continue;
420
+ }
421
+ throw err;
422
+ }
423
+ }
424
+ if (rejectedSkipped > 0) {
425
+ log.warn(`autoShare: skipped ${rejectedSkipped} candidate(s) refused by the global store's rejection tombstone`);
426
+ }
427
+ return shared;
428
+ }
371
429
  /**
372
430
  * Auto-share: local memories with high transfer scores, not already global, no NEVER_AUTO_SHARE_TAGS tag.
373
431
  * Returns the list of shared entries.
374
432
  *
375
- * L9: `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
433
+ * `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
376
434
  * scoped to that tenant. When undefined, the local read is host-wide (current
377
435
  * behaviour). The GLOBAL-entries read is always unioned — the global root IS
378
- * the cross-tenant aggregate by design. The only intentional unscoped
379
- * internal caller as of v1.12.1 is `api.sleep` (`src/api.ts:2041`), which
380
- * passes options without tenantId because `sleep` is host-wide by intent;
381
- * see `src/api.ts:2073-2077` for the cross-tenant dedup rationale.
436
+ * the cross-tenant aggregate by design. `api.sleep` passes no tenantId
437
+ * because `sleep` is host-wide by intent.
382
438
  *
383
- * v1.25.0: `options.stats` is an opt-in out-param. When provided,
439
+ * `options.stats` is an opt-in out-param. When provided,
384
440
  * `stats.secretSkipped` is incremented once per row that passed every OTHER
385
441
  * admission gate (transfer score, not-already-global) and was withheld SOLELY
386
442
  * by the secret veto — i.e. it counts shares actually prevented, not secret
387
443
  * rows merely present. Filled identically under `dryRun`.
388
444
  *
389
- * AT1: `stats.rejectedSkipped` (optional) is incremented once per candidate
445
+ * `stats.rejectedSkipped` (optional) is incremented once per candidate
390
446
  * refused by the GLOBAL store's rejection tombstone (RejectedValueError from
391
447
  * shareMemory -> writeEntry). Unlike secretSkipped, this can only be
392
448
  * detected by attempting the write — `dryRun` returns candidates before the
@@ -399,73 +455,15 @@ export function autoShare(localRoot, options = {}) {
399
455
  const localEntries = loadAllEntries(localRoot, options.tenantId);
400
456
  initGlobal();
401
457
  const globalRoot = getGlobalRoot();
402
- // L9: host-wide read. The global store IS the union across all tenants;
458
+ // Host-wide read. The global store IS the union across all tenants;
403
459
  // per-tenant filtering on the global root would defeat the purpose.
404
460
  const globalEntries = loadAllEntries(globalRoot);
405
461
  // Build set of global content hashes to avoid duplicates
406
462
  const globalContentSet = storedTextKeys(globalEntries);
407
- const candidates = localEntries.filter((entry) => {
408
- // CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
409
- if (isQuarantineScope(entry.scope ?? null))
410
- return false;
411
- // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
412
- if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t)) || entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX)) {
413
- if (options.stats)
414
- options.stats.neverAutoShareSkipped = (options.stats.neverAutoShareSkipped ?? 0) + 1;
415
- return false;
416
- }
417
- const score = transferScore(entry);
418
- if (score < minScore)
419
- return false;
420
- // Skip if already shared (same text apart from spacing)
421
- if (globalContentSet.has(duplicateKey(entry.content)))
422
- return false;
423
- // v39 S4 producer veto: secret rows never auto-share, regardless of
424
- // transfer score. (shareMemory would throw; filtering here keeps the
425
- // sleep pipeline fail-safe.) Checked LAST (v1.25.0) so the stats counter
426
- // only counts rows the veto actually withheld — a row failing the score
427
- // or dedupe gates was never going to share, secret or not.
428
- if (detectSecret(entry).flagged) {
429
- if (options.stats)
430
- options.stats.secretSkipped++;
431
- return false;
432
- }
433
- return true;
434
- });
463
+ const candidates = localEntries.filter((entry) => isAutoShareCandidate(entry, globalContentSet, minScore, options.stats));
435
464
  if (dryRun)
436
465
  return candidates;
437
- const shared = [];
438
- // AT1 containment (docs/plans/2026-08-15-at1-rejected-value-tombstone.md
439
- // plan §3 — sync/promote/share copy paths must not let ONE rejected
440
- // candidate kill the batch): shareMemory -> writeEntry hits the LIVE guard
441
- // against the GLOBAL store's tombstones. A matching candidate throws
442
- // RejectedValueError, which (uncaught) would abort this whole loop and,
443
- // via api.ts's sleep pipeline, the entire autoShare sleep phase. Mirrors
444
- // syncGlobalToLocal's per-item catch just above in this file. writeEntry's
445
- // own catch already writes the reject_refusal audit before rethrowing
446
- // (plan §3) — do not double-audit here, just count and continue.
447
- let rejectedSkipped = 0;
448
- for (const entry of candidates) {
449
- try {
450
- // skipEmbed: batching invariant, this is a batch producer, so it embeds
451
- // once via embedAll() below rather than once per row inside shareMemory.
452
- const result = shareMemory(localRoot, entry.id, { force: true, skipEmbed: true });
453
- if (result)
454
- shared.push(result);
455
- }
456
- catch (err) {
457
- if (err instanceof RejectedValueError) {
458
- rejectedSkipped++;
459
- if (options.stats)
460
- options.stats.rejectedSkipped = (options.stats.rejectedSkipped ?? 0) + 1;
461
- continue;
462
- }
463
- throw err;
464
- }
465
- }
466
- if (rejectedSkipped > 0) {
467
- log.warn(`autoShare: skipped ${rejectedSkipped} candidate(s) refused by the global store's rejection tombstone`);
468
- }
466
+ const shared = shareCandidates(localRoot, candidates, options.stats);
469
467
  if (shared.length > 0) {
470
468
  void embedAll(globalRoot).catch((err) => logEmbedAllFailure('autoShare', err));
471
469
  }
@@ -479,18 +477,18 @@ export function autoShare(localRoot, options = {}) {
479
477
  export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
480
478
  if (!fs.existsSync(globalRoot))
481
479
  return 0;
482
- // L9: host-wide read. syncGlobalToLocal copies the global union into a
480
+ // Host-wide read. syncGlobalToLocal copies the global union into a
483
481
  // tenant-scoped local store; writeEntry on each row carries the tenant if
484
482
  // the local-root context provides one.
485
483
  const globalEntries = loadAllEntries(globalRoot);
486
484
  const localIndex = loadIndex(localRoot);
487
485
  const textKey = (e) => `${e.tenantId}\n${e.content}`;
488
486
  const localText = new Set(loadAllEntries(localRoot).map(textKey));
489
- // v39 (codex P1-4): syncing down must not re-import what ambient context
487
+ // Syncing down must not re-import what ambient context
490
488
  // excludes - other-project rows are skipped by default and secret rows
491
489
  // are never copied. origin_project is preserved on the copy (writeEntry
492
490
  // only stamps when the field is missing).
493
- const currentName = deriveOriginProject(path.dirname(path.resolve(localRoot)));
491
+ const currentProject = resolveProjectIdentity(path.dirname(path.resolve(localRoot)));
494
492
  let count = 0;
495
493
  // A locally rejected value must not come back through sync down: caught per item, printed as one line.
496
494
  let rejected = 0;
@@ -506,7 +504,7 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
506
504
  if (detectSecret(entry).flagged)
507
505
  continue;
508
506
  if (!opts.includeCrossProject &&
509
- classifyOriginProject(entry.origin_project, currentName) === 'cross-project')
507
+ classifyOriginProject(entry.origin_project, currentProject) === 'cross-project')
510
508
  continue;
511
509
  try {
512
510
  writeEntry(localRoot, entry);
package/dist/skills.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * E2 skill first-class object (docs/plans/2026-05-30-e2-skill-object.md).
2
+ * Skills as a first-class, versioned object.
3
3
  *
4
4
  * A `skill` is a reusable, agent-followable capability: an `instructions` body
5
5
  * plus an optional `trigger` ("when to apply"), evolving via the supersede delta
@@ -29,8 +29,8 @@ export declare const VALID_SKILL_STATES: ReadonlySet<SkillStatus>;
29
29
  export declare const MAX_SKILL_NAME_LEN = 256;
30
30
  export declare const MAX_SKILL_INSTRUCTIONS_LEN = 8192;
31
31
  export declare const MAX_SKILL_TRIGGER_LEN = 1024;
32
- /** Aggregate bound on a single export render (plan-eng-critic: cap the unbounded
33
- * export body). Realistic active-skill counts are tens; 1000 is a generous bound. */
32
+ /** Aggregate bound on a single export render so the export body is never unbounded.
33
+ * Realistic active-skill counts are tens; 1000 is a generous bound. */
34
34
  export declare const MAX_EXPORT_SKILLS = 1000;
35
35
  export interface Skill {
36
36
  id: number;
package/dist/skills.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * E2 skill first-class object (docs/plans/2026-05-30-e2-skill-object.md).
2
+ * Skills as a first-class, versioned object.
3
3
  *
4
4
  * A `skill` is a reusable, agent-followable capability: an `instructions` body
5
5
  * plus an optional `trigger` ("when to apply"), evolving via the supersede delta
@@ -39,13 +39,13 @@ export const VALID_SKILL_STATES = new Set([
39
39
  export const MAX_SKILL_NAME_LEN = 256;
40
40
  export const MAX_SKILL_INSTRUCTIONS_LEN = 8192;
41
41
  export const MAX_SKILL_TRIGGER_LEN = 1024;
42
- /** Aggregate bound on a single export render (plan-eng-critic: cap the unbounded
43
- * export body). Realistic active-skill counts are tens; 1000 is a generous bound. */
42
+ /** Aggregate bound on a single export render so the export body is never unbounded.
43
+ * Realistic active-skill counts are tens; 1000 is a generous bound. */
44
44
  export const MAX_EXPORT_SKILLS = 1000;
45
45
  /**
46
46
  * Validate + normalise skill fields. skill_name is trimmed and MUST be a single
47
- * line (no newlines) so it cannot break the H2 header in the export render
48
- * (plan-eng-critic). instructions are kept verbatim (operator content) but capped.
47
+ * line (no newlines) so it cannot break the H2 header in the export render.
48
+ * instructions are kept verbatim (operator content) but capped.
49
49
  * Returns the normalised name + trigger (null when absent/empty).
50
50
  */
51
51
  function validateSkillFields(skillName, instructions, trigger) {
@@ -69,8 +69,8 @@ function validateSkillFields(skillName, instructions, trigger) {
69
69
  throw new BadRequestError(`saveSkill: trigger exceeds the ${MAX_SKILL_TRIGGER_LEN}-char cap`);
70
70
  }
71
71
  // Single-line, like skill_name: a trigger is a short "when to apply" phrase,
72
- // and a newline would let it forge a heading inside the export **When:** line
73
- // (independent-review 2026-05-30). Reject rather than emit a multi-line trigger.
72
+ // and a newline would let it forge a heading inside the export **When:** line.
73
+ // Reject rather than emit a multi-line trigger.
74
74
  if (/[\r\n]/.test(trigger)) {
75
75
  throw new BadRequestError('saveSkill: trigger must be a single line (no newlines)');
76
76
  }
@@ -111,9 +111,76 @@ function buildSkillContent(skillName, instructions, trigger) {
111
111
  content += `\n\n${instructions}`;
112
112
  return content;
113
113
  }
114
- // ---------------------------------------------------------------------------
115
- // Public API
116
- // ---------------------------------------------------------------------------
114
+ // Preflight the supersede target BEFORE inserting the new row (so the new
115
+ // autoincrement id can never be its own supersede target); read the
116
+ // predecessor version in the same SELECT for server-derived versioning.
117
+ // Mirrors saveProcess / savePolicy.
118
+ function preflightSkillSupersede(db, tenantId, supersedesId) {
119
+ // SAFETY: row shape matches the `status, version` columns named in the SELECT below.
120
+ const pred = db.prepare(`SELECT status, version FROM skills WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
121
+ if (!pred) {
122
+ throw new NotFoundError(`saveSkill: skill ${supersedesId} to supersede not found for tenant ${tenantId}`);
123
+ }
124
+ if (pred.status !== 'active') {
125
+ throw new ConflictError(`saveSkill: skill ${supersedesId} is not active (status='${pred.status}'); only active skills can be superseded.`);
126
+ }
127
+ return pred.version + 1;
128
+ }
129
+ function insertSkillRow(db, memoryId, w, version) {
130
+ const result = db.prepare(`
131
+ INSERT INTO skills(
132
+ memory_id, tenant_id, skill_name, instructions, trigger_text, version,
133
+ status, superseded_by, superseded_at, change_summary, closed_at, created_at
134
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
135
+ `).run(memoryId, w.tenantId, w.name, w.instructions, w.trigger, version, w.changeSummary, w.now);
136
+ return Number(result.lastInsertRowid ?? 0);
137
+ }
138
+ function supersedeSkillRow(db, w, supersedesId, skillId, version) {
139
+ const sup = db.prepare(`
140
+ UPDATE skills
141
+ SET status = 'superseded', superseded_by = ?, superseded_at = ?
142
+ WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
143
+ `).run(skillId, w.now, supersedesId, w.tenantId, skillId);
144
+ if (sup.changes === 0) {
145
+ throw new ConflictError(`saveSkill: skill ${supersedesId} could not be superseded (no longer active or self-reference).`);
146
+ }
147
+ appendAuditEvent(db, {
148
+ tenantId: w.tenantId,
149
+ actor: w.actor,
150
+ op: 'skill_supersede',
151
+ targetId: String(supersedesId),
152
+ metadata: {
153
+ skill_id: supersedesId,
154
+ superseded_by: skillId,
155
+ new_version: version,
156
+ },
157
+ });
158
+ }
159
+ /** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
160
+ function writeSkillRow(db, memoryId, w) {
161
+ const version = w.supersedesId !== undefined ? preflightSkillSupersede(db, w.tenantId, w.supersedesId) : 1;
162
+ const skillId = insertSkillRow(db, memoryId, w, version);
163
+ if (w.supersedesId !== undefined)
164
+ supersedeSkillRow(db, w, w.supersedesId, skillId, version);
165
+ // SAFETY: row's shape matches the columns named in SKILL_COLS above.
166
+ const row = db.prepare(`SELECT ${SKILL_COLS} FROM skills WHERE id = ?`)
167
+ .get(skillId);
168
+ if (!row)
169
+ throw new Error('saveSkill: failed to reload saved skill row');
170
+ // GDPR-light metadata: ids + flags only, no skill text.
171
+ appendAuditEvent(db, {
172
+ tenantId: w.tenantId,
173
+ actor: w.actor,
174
+ op: 'skill_create',
175
+ targetId: String(skillId),
176
+ metadata: {
177
+ skill_id: skillId,
178
+ version,
179
+ has_trigger: w.trigger !== null,
180
+ },
181
+ });
182
+ return row;
183
+ }
117
184
  /**
118
185
  * Create a skill (or a new version that supersedes an existing one). Writes the
119
186
  * memory mirror + the skills row atomically inside writeEntry's SAVEPOINT. When
@@ -137,72 +204,21 @@ export function saveSkill(hippoRoot, tenantId, opts, actor = 'cli') {
137
204
  baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
138
205
  tenantId,
139
206
  });
207
+ const w = {
208
+ tenantId,
209
+ actor,
210
+ name,
211
+ instructions: opts.instructions,
212
+ trigger,
213
+ changeSummary,
214
+ supersedesId: opts.supersedesSkillId,
215
+ now,
216
+ };
140
217
  let savedRow;
141
218
  writeEntry(hippoRoot, mem, {
142
219
  actor,
143
220
  afterWrite: (db, memoryId) => {
144
- // Preflight the supersede target BEFORE inserting the new row (so the new
145
- // autoincrement id can never be its own supersede target); read the
146
- // predecessor version in the same SELECT for server-derived versioning.
147
- // Mirrors saveProcess / savePolicy (codex P1 2026-05-28).
148
- let version = 1;
149
- if (opts.supersedesSkillId !== undefined) {
150
- // SAFETY: row shape matches the `status, version` columns named in the SELECT below.
151
- const pred = db.prepare(`SELECT status, version FROM skills WHERE id = ? AND tenant_id = ?`).get(opts.supersedesSkillId, tenantId);
152
- if (!pred) {
153
- throw new NotFoundError(`saveSkill: skill ${opts.supersedesSkillId} to supersede not found for tenant ${tenantId}`);
154
- }
155
- if (pred.status !== 'active') {
156
- throw new ConflictError(`saveSkill: skill ${opts.supersedesSkillId} is not active (status='${pred.status}'); only active skills can be superseded.`);
157
- }
158
- version = pred.version + 1;
159
- }
160
- const result = db.prepare(`
161
- INSERT INTO skills(
162
- memory_id, tenant_id, skill_name, instructions, trigger_text, version,
163
- status, superseded_by, superseded_at, change_summary, closed_at, created_at
164
- ) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
165
- `).run(memoryId, tenantId, name, opts.instructions, trigger, version, changeSummary, now);
166
- const skillId = Number(result.lastInsertRowid ?? 0);
167
- if (opts.supersedesSkillId !== undefined) {
168
- const sup = db.prepare(`
169
- UPDATE skills
170
- SET status = 'superseded', superseded_by = ?, superseded_at = ?
171
- WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
172
- `).run(skillId, now, opts.supersedesSkillId, tenantId, skillId);
173
- if (sup.changes === 0) {
174
- throw new ConflictError(`saveSkill: skill ${opts.supersedesSkillId} could not be superseded (no longer active or self-reference).`);
175
- }
176
- appendAuditEvent(db, {
177
- tenantId,
178
- actor,
179
- op: 'skill_supersede',
180
- targetId: String(opts.supersedesSkillId),
181
- metadata: {
182
- skill_id: opts.supersedesSkillId,
183
- superseded_by: skillId,
184
- new_version: version,
185
- },
186
- });
187
- }
188
- // SAFETY: row's shape matches the columns named in SKILL_COLS above.
189
- const row = db.prepare(`SELECT ${SKILL_COLS} FROM skills WHERE id = ?`)
190
- .get(skillId);
191
- if (!row)
192
- throw new Error('saveSkill: failed to reload saved skill row');
193
- savedRow = row;
194
- // GDPR-light metadata: ids + flags only, no skill text.
195
- appendAuditEvent(db, {
196
- tenantId,
197
- actor,
198
- op: 'skill_create',
199
- targetId: String(skillId),
200
- metadata: {
201
- skill_id: skillId,
202
- version,
203
- has_trigger: trigger !== null,
204
- },
205
- });
221
+ savedRow = writeSkillRow(db, memoryId, w);
206
222
  },
207
223
  });
208
224
  if (!savedRow) {
@@ -1,7 +1,7 @@
1
1
  import { openHippoDb } from '../db.js';
2
2
  import { type AuditOp } from '../audit.js';
3
3
  import { RejectedValueError } from '../rejection.js';
4
- import type { JsonValue } from './rows.js';
4
+ import type { JsonValue } from '../json.js';
5
5
  /**
6
6
  * Emit an audit event for a mutation against `db`. Wrapped so a broken audit
7
7
  * log can never crash the surrounding mutation — the SQLite store is still the
@@ -9,7 +9,7 @@ import type { JsonValue } from './rows.js';
9
9
  */
10
10
  export declare function audit(db: ReturnType<typeof openHippoDb>, op: AuditOp, targetId?: string, metadata?: Record<string, JsonValue>, actor?: string, tenantId?: string): void;
11
11
  /**
12
- * Refusal audit for the AT1 rejection guard (plan §3). Written by the
12
+ * Refusal audit for the rejected-value guard. Written by the
13
13
  * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
14
14
  * there, so this lands in a fresh implicit transaction) and api.supersede's
15
15
  * catch (after its own ROLLBACK) — never inside a scope the caller's own
@@ -21,7 +21,7 @@ export function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
21
21
  }
22
22
  }
23
23
  /**
24
- * Refusal audit for the AT1 rejection guard (plan §3). Written by the
24
+ * Refusal audit for the rejected-value guard. Written by the
25
25
  * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
26
26
  * there, so this lands in a fresh implicit transaction) and api.supersede's
27
27
  * catch (after its own ROLLBACK) — never inside a scope the caller's own
@@ -16,8 +16,8 @@ export declare function loadAmbientCandidates(hippoRoot: string, tenantId: strin
16
16
  export interface ContextCandidateFilter {
17
17
  /** Envelope scope asked for by name; absent applies recall's default deny. */
18
18
  exactScope?: string;
19
- /** Rows of this project and user-global rows pass; absent admits every origin. */
20
- project?: string;
19
+ /** Rows carrying one of these project names, and user-global rows, pass; absent admits every origin. */
20
+ project?: readonly string[];
21
21
  /** Most rows returned; past it, the rows decay has worn least win. */
22
22
  cap: number;
23
23
  now: Date;
@@ -2,6 +2,7 @@ import { closeHippoDb } from '../db.js';
2
2
  import { RECALL_DEFAULT_DENY_SCOPES } from '../recall-scope.js';
3
3
  import { MEMORY_SELECT_COLUMNS, rowToEntry, parseJsonArray } from './rows.js';
4
4
  import { openStore } from './open.js';
5
+ import { originInSql } from '../project-identity.js';
5
6
  import { pickRarestFtsQuery, loadRecallSearchEntriesFromDb } from './search-rows.js';
6
7
  const AMBIENT_SCOPED = 'superseded_by IS NULL AND tenant_id = ?';
7
8
  /** Exported so the plan test runs the exact SQL; idx_memories_pinned (db.ts v51) serves it. */
@@ -9,7 +10,7 @@ export const AMBIENT_PINNED_WHERE = `pinned = 1 AND ${AMBIENT_SCOPED} ORDER BY c
9
10
  /** Exported so the plan test runs the exact SQL; idx_memories_created_drift (db.ts v51) serves it. */
10
11
  export const AMBIENT_DRIFT_SQL = `SELECT 1 FROM memories WHERE ${AMBIENT_SCOPED} AND (length(created) <> 24 OR created NOT LIKE '%Z') LIMIT 1`;
11
12
  // The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
12
- // injection. One connection; `recall` piggybacks the Z1 FTS query on it too.
13
+ // injection. One connection; `recall` piggybacks the prompt-recall FTS query on it too.
13
14
  export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit, recall) {
14
15
  // A SQL LIMIT takes an integer; the Array.slice this replaced truncated one,
15
16
  // and include_recent is any non-negative finite number at the HTTP edge.
@@ -78,8 +79,8 @@ export function loadContextCandidates(hippoRoot, tenantId, filter) {
78
79
  params.push(...RECALL_DEFAULT_DENY_SCOPES);
79
80
  }
80
81
  if (filter.project !== undefined) {
81
- where.push(`(origin_project = '' OR origin_project = ?)`);
82
- params.push(filter.project);
82
+ where.push(`(origin_project = '' OR ${originInSql(filter.project)})`);
83
+ params.push(...filter.project);
83
84
  }
84
85
  const db = openStore(hippoRoot);
85
86
  try {