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
@@ -1,14 +1,21 @@
1
- // `hippo projects`: list the project names a store holds, fold one into another, and re-tag sleep's old user-global merges.
1
+ // `hippo projects`: list the project names a store holds, fold one into another, and repair old tags in one reversible pass.
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { setAsideRow } from './agent-memories/apply.js';
5
- import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS } from './agent-memories/tools.js';
6
- import { appendAuditEvent } from './audit.js';
5
+ import { transcriptNotesProject } from './agent-memories/claude-code.js';
6
+ import { containerId, containerPrefix } from './agent-memories/source.js';
7
+ import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
8
+ import { appendAuditEvent, queryAuditEvents } from './audit.js';
9
+ import { getMeta, setMeta } from './db/meta.js';
7
10
  import { insertDormantRow, listDormantSnapshots, replaceDormantEntry } from './dormant.js';
11
+ import { processEnv } from './env.js';
8
12
  import { calculateStrength } from './memory.js';
13
+ import { isObjectLike, isStringValue } from './capture-contract.js';
14
+ import { isGlobalStoreRoot, projectNames, resolveProjectIdentity } from './project-identity.js';
15
+ import { duplicateKey } from './same-text.js';
9
16
  import { removeEntryMirrors } from './store/mirrors.js';
10
17
  import { deleteEntryRowInTx, writeEntryMirrors } from './store/entry-writes.js';
11
- import { selectAllEntries } from './store/entry-reads.js';
18
+ import { selectAllEntries, selectLiveEntriesBySourcePrefix } from './store/entry-reads.js';
12
19
  const ACTOR = 'cli';
13
20
  function isImport(entry) {
14
21
  return entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX);
@@ -25,8 +32,9 @@ export function listProjects(db, tenantId) {
25
32
  for (const e of live) {
26
33
  const origin = e.origin_project ?? null;
27
34
  byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), e]);
35
+ const key = duplicateKey(e.content);
28
36
  if (isImport(e))
29
- holders.set(e.content, (holders.get(e.content) ?? new Set()).add(origin));
37
+ holders.set(key, (holders.get(key) ?? new Set()).add(origin));
30
38
  }
31
39
  return [...byOrigin].map(([origin, rows]) => {
32
40
  const imports = rows.filter(isImport);
@@ -35,7 +43,7 @@ export function listProjects(db, tenantId) {
35
43
  live: rows.length,
36
44
  imported: imports.length,
37
45
  newest: rows.reduce((max, e) => (e.created > max ? e.created : max), ''),
38
- copiesElsewhere: imports.filter((e) => (holders.get(e.content)?.size ?? 0) > 1).length,
46
+ copiesElsewhere: imports.filter((e) => (holders.get(duplicateKey(e.content))?.size ?? 0) > 1).length,
39
47
  };
40
48
  }).sort((a, b) => b.newest.localeCompare(a.newest));
41
49
  }
@@ -78,44 +86,174 @@ export function mergeProjects(db, hippoRoot, opts) {
78
86
  const { tenantId, from, into, dryRun } = opts;
79
87
  const backup = dryRun ? null : backupStore(db, hippoRoot, 'before-merge');
80
88
  const result = inTransaction(db, dryRun, () => {
81
- const rows = selectAllEntries(db, tenantId).filter((e) => e.origin_project === from);
82
- const setAside = [];
83
- for (const row of rows) {
84
- const tag = toolTag(row.source);
85
- if (!isImport(row) || row.superseded_by || row.kind === 'raw' || tag === null)
86
- continue;
87
- if (setAsideRow(db, tag, { ...row, origin_project: into }, 'project-merge').kind === 'dormant')
88
- setAside.push(row.id);
89
- }
90
- const gone = new Set(setAside);
91
- const restamped = rows.filter((e) => !gone.has(e.id)).map((e) => e.id);
92
- db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND origin_project = ?`)
93
- .run(into, tenantId, from);
94
- const dormantRestamped = [];
95
- for (const snap of listDormantSnapshots(db, tenantId)) {
96
- if (snap.entry.origin_project !== from || gone.has(snap.entry.id))
97
- continue;
98
- replaceDormantEntry(db, tenantId, snap.entry.id, { ...snap.entry, origin_project: into });
99
- dormantRestamped.push(snap.entry.id);
100
- }
101
- const compactions = Number(db.prepare(`UPDATE compactions SET origin_project = ? WHERE tenant_id = ? AND origin_project = ?`)
102
- .run(into, tenantId, from).changes ?? 0);
103
- appendAuditEvent(db, {
104
- tenantId, actor: ACTOR, op: 'project_merge',
105
- metadata: { from, into, backup, setAside, restamped, dormantRestamped, compactions },
106
- });
107
- return { from, into, setAside, restamped, dormantRestamped, compactions, backup };
89
+ const folded = foldInTx(db, tenantId, from, into);
90
+ appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_merge', metadata: { from, into, backup, ...folded } });
91
+ return { from, into, ...folded, backup };
108
92
  });
109
93
  if (!dryRun)
110
94
  refreshMirrors(db, hippoRoot, tenantId, result.restamped, result.setAside);
111
95
  return result;
112
96
  }
113
- /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
114
- export function planUserGlobalRepair(db, tenantId) {
97
+ function foldInTx(db, tenantId, from, into) {
98
+ const all = selectAllEntries(db, tenantId);
99
+ const rows = all.filter((e) => e.origin_project === from);
100
+ // The sync renames the rest to `into`'s prefix and keeps their ids; only a note `into` already holds would show twice.
101
+ const held = new Set(all.filter((e) => e.origin_project === into && !e.superseded_by && isImport(e)).map(noteKey));
102
+ const setAside = [];
103
+ for (const row of rows) {
104
+ const tag = toolTag(row.source);
105
+ if (!isImport(row) || row.superseded_by || row.kind === 'raw' || tag === null || !held.has(noteKey(row)))
106
+ continue;
107
+ if (setAsideRow(db, tag, { ...row, origin_project: into }, 'project-merge').kind === 'dormant')
108
+ setAside.push(row.id);
109
+ }
110
+ const gone = new Set(setAside);
111
+ const restamped = rows.filter((e) => !gone.has(e.id)).map((e) => e.id);
112
+ db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND origin_project = ?`)
113
+ .run(into, tenantId, from);
114
+ const dormantRestamped = [];
115
+ for (const snap of listDormantSnapshots(db, tenantId)) {
116
+ if (snap.entry.origin_project !== from || gone.has(snap.entry.id))
117
+ continue;
118
+ replaceDormantEntry(db, tenantId, snap.entry.id, { ...snap.entry, origin_project: into });
119
+ dormantRestamped.push(snap.entry.id);
120
+ }
121
+ const compactions = Number(db.prepare(`UPDATE compactions SET origin_project = ? WHERE tenant_id = ? AND origin_project = ?`)
122
+ .run(into, tenantId, from).changes ?? 0);
123
+ return { setAside, restamped, dormantRestamped, compactions };
124
+ }
125
+ /** Tool, note key and hash of an import, without the container, whose id hashes the project name. */
126
+ function noteKey(entry) {
127
+ const rest = entry.source.slice(AGENT_MEMORY_SOURCE_PREFIX.length);
128
+ return `${rest.split(':')[0]}:${rest.slice(rest.indexOf('/') + 1)}`;
129
+ }
130
+ /** Live imports under a project name whose exact text a user-global import holds: a session folder's notes stamped with the folder it ended in. */
131
+ function importCopies(db, tenantId) {
132
+ const live = selectAllEntries(db, tenantId).filter((e) => !e.superseded_by && isImport(e));
133
+ const userGlobal = new Set(live.filter((e) => e.origin_project === '').map((e) => duplicateKey(e.content)));
134
+ return live.filter((e) => e.origin_project && e.kind !== 'raw' && !e.pinned && toolTag(e.source) !== null && userGlobal.has(duplicateKey(e.content)));
135
+ }
136
+ /** Imports to set aside. The global store also checks each Claude session folder a compaction recorded: its notes under any other project are misfiled, edited or not, and a text copy in the right folder is kept. */
137
+ function strayImports(db, hippoRoot, tenantId) {
138
+ const copies = importCopies(db, tenantId);
139
+ if (!isGlobalStoreRoot(hippoRoot))
140
+ return copies;
141
+ const machine = { platform: process.platform, env: processEnv() };
142
+ const { platform } = machine;
143
+ // SAFETY: the SELECT names the two columns of the row type.
144
+ const sessions = db.prepare(`SELECT DISTINCT transcript_path AS transcript, cwd FROM compactions WHERE tenant_id = ? AND transcript_path IS NOT NULL`)
145
+ .all(tenantId);
146
+ const owners = new Map();
147
+ for (const { transcript, cwd } of sessions) {
148
+ const project = transcriptNotesProject(transcript, cwd, machine);
149
+ const dir = path.join(path.dirname(transcript), 'memory');
150
+ if (project === null)
151
+ continue;
152
+ const ids = owners.get(dir) ?? new Map();
153
+ owners.set(dir, ids.set(project.name, [...new Set([...(ids.get(project.name) ?? []), ...projectNames(project)])]));
154
+ }
155
+ const tool = toolSourcePrefix('claude-code');
156
+ const live = selectLiveEntriesBySourcePrefix(db, tenantId, tool).filter((e) => e.kind !== 'raw' && !e.pinned);
157
+ const origins = new Set(live.map((e) => e.origin_project ?? ''));
158
+ const edges = foldEdges(db, tenantId);
159
+ const right = new Set();
160
+ const wrong = new Set();
161
+ for (const [dir, ids] of owners) {
162
+ if (ids.size !== 1)
163
+ continue;
164
+ // Its names before the id, and names folded into it, are the owner's too: the next sync moves those prefixes under the id.
165
+ const [names] = ids.values();
166
+ const own = new Set([...names, ...foldedInto(edges, names)]);
167
+ for (const origin of new Set([...origins, ...own]))
168
+ (own.has(origin) ? right : wrong).add(containerPrefix('claude-code', containerId(dir, 'project', platform, origin)));
169
+ }
170
+ const prefix = (e) => e.source.slice(0, e.source.indexOf('/', tool.length) + 1);
171
+ const misfiled = live.filter((e) => wrong.has(prefix(e)));
172
+ const seen = new Set(misfiled.map((e) => e.id));
173
+ return [...misfiled, ...copies.filter((e) => !seen.has(e.id) && !right.has(prefix(e)))];
174
+ }
175
+ /** The global store reads each name's recorded session folders, where a compaction's name came from its cwd; a project store folds only its own folder name. */
176
+ function planFolds(db, hippoRoot, tenantId, globalFolds) {
177
+ // A name someone merged into by hand stays: undoing their choice would rest on the resolver alone.
178
+ const chosen = new Set(queryAuditEvents(db, { tenantId, op: 'project_merge', limit: 10000 }).map((e) => e.metadata.into));
179
+ if (!isGlobalStoreRoot(hippoRoot))
180
+ return { folds: ownLegacyFold(db, hippoRoot, tenantId).filter((f) => !chosen.has(f.from)), collisions: [] };
181
+ // SAFETY: the SELECT names the two columns of the row type.
182
+ const rows = db.prepare(`SELECT DISTINCT origin_project AS origin, cwd FROM compactions WHERE tenant_id = ? AND origin_project <> '' AND cwd IS NOT NULL`)
183
+ .all(tenantId);
184
+ const today = new Map();
185
+ for (const { origin, cwd } of rows) {
186
+ if (fs.existsSync(cwd))
187
+ today.set(origin, (today.get(origin) ?? new Set()).add(resolveProjectIdentity(cwd).name));
188
+ }
189
+ const folds = [];
190
+ const collisions = [];
191
+ for (const [from, names] of today) {
192
+ const [into] = names;
193
+ if (chosen.has(from))
194
+ continue;
195
+ if (names.size > 1)
196
+ collisions.push({ name: from, ids: [...names].sort() });
197
+ else if (into !== '' && into !== from)
198
+ folds.push({ from, into });
199
+ }
200
+ // A fold into a name that itself folds would land rows by run order; the next repair takes the rest of the chain.
201
+ const sources = new Set(folds.map((f) => f.from));
202
+ return { folds: globalFolds ? folds.filter((f) => !sources.has(f.into)) : [], collisions };
203
+ }
204
+ /** A project store's rows, dormant snapshots and compaction records written before its id existed carry its folder name. */
205
+ function ownLegacyFold(db, hippoRoot, tenantId) {
206
+ const { name, legacyName } = resolveProjectIdentity(path.dirname(path.resolve(hippoRoot)));
207
+ if (legacyName === '' || legacyName === name)
208
+ return [];
209
+ const heldIn = (table) => db.prepare(`SELECT 1 FROM ${table} WHERE tenant_id = ? AND origin_project = ? LIMIT 1`).get(tenantId, legacyName) !== undefined;
210
+ const held = heldIn('memories') || heldIn('compactions') || listDormantSnapshots(db, tenantId).some((s) => s.entry.origin_project === legacyName);
211
+ return held ? [{ from: legacyName, into: name }] : [];
212
+ }
213
+ function foldOf(value) {
214
+ if (!isObjectLike(value) || Array.isArray(value))
215
+ return [];
216
+ const { from, into } = value;
217
+ return isStringValue(from) && isStringValue(into) ? [{ from, into }] : [];
218
+ }
219
+ function foldEdges(db, tenantId) {
220
+ return [
221
+ ...queryAuditEvents(db, { tenantId, op: 'project_merge', limit: 10000 }).flatMap((e) => foldOf(e.metadata)),
222
+ ...queryAuditEvents(db, { tenantId, op: 'project_repair', limit: 10000 })
223
+ .flatMap((e) => (Array.isArray(e.metadata.folds) ? e.metadata.folds.flatMap(foldOf) : [])),
224
+ ];
225
+ }
226
+ function foldedInto(edges, names) {
227
+ const seen = new Set(names);
228
+ let grew = true;
229
+ while (grew) {
230
+ grew = false;
231
+ for (const { from, into } of edges) {
232
+ if (!seen.has(into) || seen.has(from))
233
+ continue;
234
+ seen.add(from);
235
+ grew = true;
236
+ }
237
+ }
238
+ return [...seen].filter((n) => !names.includes(n));
239
+ }
240
+ /** Names folded, directly or through others, into one of `names`; the sync moves imports still filed under them. */
241
+ export function namesFoldedInto(db, tenantId, names) {
242
+ return foldedInto(foldEdges(db, tenantId), names);
243
+ }
244
+ /** Reads only, so doctor and a dry run take no write lock; merged rows are planned before any fold, so a few may re-tag differently once folds apply. */
245
+ export function planProjectRepair(db, hippoRoot, tenantId, globalFolds = true) {
246
+ const { folds, collisions } = planFolds(db, hippoRoot, tenantId, globalFolds);
247
+ return { copies: strayImports(db, hippoRoot, tenantId).map((e) => e.id), folds, collisions, ...planUserGlobalRepair(db, tenantId, folds) };
248
+ }
249
+ /** Parents read with `folds` already applied, so a plan matches what apply does after folding. */
250
+ function planUserGlobalRepair(db, tenantId, folds) {
115
251
  const all = selectAllEntries(db, tenantId);
116
- const origins = new Map(listDormantSnapshots(db, tenantId).map((s) => [s.entry.id, s.entry.origin_project ?? null]));
252
+ const renamed = new Map(folds.map((f) => [f.from, f.into]));
253
+ const after = (origin) => (origin ? renamed.get(origin) ?? origin : origin ?? null);
254
+ const origins = new Map(listDormantSnapshots(db, tenantId).map((s) => [s.entry.id, after(s.entry.origin_project)]));
117
255
  for (const e of all)
118
- origins.set(e.id, e.origin_project ?? null);
256
+ origins.set(e.id, after(e.origin_project));
119
257
  const toProject = [];
120
258
  const setAside = [];
121
259
  const untraced = [];
@@ -135,14 +273,22 @@ export function planUserGlobalRepair(db, tenantId) {
135
273
  }
136
274
  return { toProject, setAside, untraced };
137
275
  }
138
- /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
139
- export function repairUserGlobalMerges(db, hippoRoot, opts) {
140
- const { tenantId, dryRun } = opts;
276
+ /** Sets aside stray imports, folds the names the resolver now maps elsewhere, then re-tags sleep's user-global merges by their parents; a dry run only plans. */
277
+ export function repairProjects(db, hippoRoot, opts) {
278
+ const { tenantId, dryRun, globalFolds = true } = opts;
141
279
  if (dryRun)
142
- return { ...planUserGlobalRepair(db, tenantId), backup: null };
280
+ return { ...planProjectRepair(db, hippoRoot, tenantId, globalFolds), backup: null };
143
281
  const backup = backupStore(db, hippoRoot, 'before-repair');
144
- const result = inTransaction(db, false, () => {
145
- const plan = planUserGlobalRepair(db, tenantId);
282
+ const { result, rewrite, purge } = inTransaction(db, false, () => {
283
+ const copies = [];
284
+ for (const row of strayImports(db, hippoRoot, tenantId)) {
285
+ const tag = toolTag(row.source);
286
+ if (tag !== null && setAsideRow(db, tag, row, 'project-repair').kind === 'dormant')
287
+ copies.push(row.id);
288
+ }
289
+ const { folds, collisions } = planFolds(db, hippoRoot, tenantId, globalFolds);
290
+ const folded = folds.map((f) => foldInTx(db, tenantId, f.from, f.into));
291
+ const plan = planUserGlobalRepair(db, tenantId, []);
146
292
  const stamp = db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND id = ?`);
147
293
  for (const { id, origin } of plan.toProject)
148
294
  stamp.run(origin, tenantId, id);
@@ -152,10 +298,31 @@ export function repairUserGlobalMerges(db, hippoRoot, opts) {
152
298
  insertDormantRow(db, { entry: row, strength: calculateStrength(row, now), reason: 'project-repair', dormantAt: now.toISOString() });
153
299
  deleteEntryRowInTx(db, row, ACTOR);
154
300
  }
155
- appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_repair', metadata: { backup, ...plan } });
156
- return { ...plan, backup };
301
+ appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_repair', metadata: { backup, copies, folds, folded, ...plan } });
302
+ return {
303
+ result: { copies, folds, collisions, ...plan, backup },
304
+ rewrite: [...plan.toProject.map((r) => r.id), ...folded.flatMap((f) => f.restamped)],
305
+ purge: [...copies, ...plan.setAside, ...folded.flatMap((f) => f.setAside)],
306
+ };
157
307
  });
158
- refreshMirrors(db, hippoRoot, tenantId, result.toProject.map((r) => r.id), result.setAside);
308
+ refreshMirrors(db, hippoRoot, tenantId, rewrite, purge);
309
+ return result;
310
+ }
311
+ const AUTO_REPAIR_META_KEY = 'project_repair_auto';
312
+ /** True when a repair would change nothing. */
313
+ function repairIsEmpty(r) {
314
+ return r.copies.length + r.folds.length + r.toProject.length + r.setAside.length === 0;
315
+ }
316
+ /**
317
+ * Sleep runs repair once per store, so an upgrade needs no command. Global-store folds stay
318
+ * with `hippo projects repair`: their evidence is compaction folders, blind to a same-named repo that never compacted.
319
+ */
320
+ export function repairOnceOnSleep(db, hippoRoot, tenantId) {
321
+ if (getMeta(db, AUTO_REPAIR_META_KEY) === '1')
322
+ return null;
323
+ const plan = planProjectRepair(db, hippoRoot, tenantId, false);
324
+ const result = repairIsEmpty(plan) ? null : repairProjects(db, hippoRoot, { tenantId, dryRun: false, globalFolds: false });
325
+ setMeta(db, AUTO_REPAIR_META_KEY, '1');
159
326
  return result;
160
327
  }
161
328
  /** After commit, as the agent memory sync does: a stale mirror would bring the old tag back on the next rebuild. */
@@ -0,0 +1,12 @@
1
+ /** The config file git reads for a checkout: `.git/config`, a linked worktree's `<commondir>/config`, a submodule's `<gitdir>/config`. */
2
+ export declare function gitConfigPath(gitRoot: string): string | null;
3
+ /** `remote.origin.url` from a config file's text, the first value as git fetch uses; the subsection name is case-sensitive. */
4
+ export declare function originUrlFromConfig(text: string): string | null;
5
+ /** `host/path` for a network remote, so ssh, scp and https forms agree; null for a local path, which would put a user path into rows. */
6
+ export declare function normaliseRemote(url: string): string | null;
7
+ /** The normalised origin of the checkout rooted at gitRoot, or null when it has none a project can be named by. */
8
+ export declare function originRemoteId(gitRoot: string): string | null;
9
+ export declare const PROJECT_FILE = ".hippo-project.json";
10
+ /** The `id` a team committed in `.hippo-project.json` at root, lowercased like every project name; null when absent or unusable. */
11
+ export declare function projectFileId(root: string): string | null;
12
+ //# sourceMappingURL=project-remote.d.ts.map
@@ -0,0 +1,138 @@
1
+ // A checkout's `origin` remote, read from its git config file and normalised into a project id; no git process, so the prompt hook can call it.
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { isObjectLike, isStringValue } from './capture-contract.js';
5
+ import { log } from './log.js';
6
+ /** The config file git reads for a checkout: `.git/config`, a linked worktree's `<commondir>/config`, a submodule's `<gitdir>/config`. */
7
+ export function gitConfigPath(gitRoot) {
8
+ const marker = path.join(gitRoot, '.git');
9
+ try {
10
+ if (fs.statSync(marker).isDirectory())
11
+ return path.join(marker, 'config');
12
+ const gitDir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(marker, 'utf8'))?.[1]?.trim();
13
+ if (!gitDir)
14
+ return null;
15
+ const linkDir = path.resolve(gitRoot, gitDir);
16
+ const commondir = path.join(linkDir, 'commondir');
17
+ if (!fs.existsSync(commondir))
18
+ return path.join(linkDir, 'config');
19
+ return path.join(path.resolve(linkDir, fs.readFileSync(commondir, 'utf8').trim()), 'config');
20
+ }
21
+ catch (err) {
22
+ log.debug(`project remote: no git config for ${gitRoot}: ${err instanceof Error ? err.message : String(err)}`);
23
+ return null;
24
+ }
25
+ }
26
+ /** One config value with git's quoting: `"` toggles quoting, backslash escapes, and an unquoted `#` or `;` starts a comment. */
27
+ function configValue(raw) {
28
+ let out = '';
29
+ let quoted = false;
30
+ for (let i = 0; i < raw.length; i++) {
31
+ const c = raw[i];
32
+ if (c === '\\' && i + 1 < raw.length) {
33
+ const next = raw[++i];
34
+ out += next === 'n' ? '\n' : next === 't' ? '\t' : next === 'b' ? '' : next;
35
+ }
36
+ else if (c === '"') {
37
+ quoted = !quoted;
38
+ }
39
+ else if (!quoted && (c === '#' || c === ';')) {
40
+ break;
41
+ }
42
+ else {
43
+ out += c;
44
+ }
45
+ }
46
+ return out.trim();
47
+ }
48
+ const SECTION = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\](.*)$/;
49
+ const ENTRY = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=(.*))?$/;
50
+ /** `remote.origin.url` from a config file's text, the first value as git fetch uses; the subsection name is case-sensitive. */
51
+ export function originUrlFromConfig(text) {
52
+ let inOrigin = false;
53
+ for (const raw of text.split(/\r?\n/)) {
54
+ let line = raw.trim();
55
+ const section = SECTION.exec(line);
56
+ if (section) {
57
+ const [, name, sub, rest] = section;
58
+ inOrigin = sub === undefined
59
+ ? name.toLowerCase() === 'remote.origin'
60
+ : name.toLowerCase() === 'remote' && sub.replace(/\\(.)/g, '$1') === 'origin';
61
+ line = rest.trim();
62
+ }
63
+ const entry = inOrigin ? ENTRY.exec(line) : null;
64
+ if (entry && entry[1].toLowerCase() === 'url' && entry[2] !== undefined) {
65
+ const value = configValue(entry[2]);
66
+ if (value !== '')
67
+ return value;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ /** Azure DevOps names one repo three ways; all become `dev.azure.com/org/project/repo`. */
73
+ function azureDevOps(hostPath) {
74
+ const ssh = /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/(.+)$/.exec(hostPath);
75
+ if (ssh)
76
+ return `dev.azure.com/${ssh[1]}`;
77
+ const legacy = /^([^/.]+)\.visualstudio\.com\/(?:defaultcollection\/)?(.+)$/.exec(hostPath);
78
+ const https = legacy ? `dev.azure.com/${legacy[1]}/${legacy[2]}` : hostPath;
79
+ return https.startsWith('dev.azure.com/') ? https.replace('/_git/', '/') : https;
80
+ }
81
+ /** `host/path` for a network remote, so ssh, scp and https forms agree; null for a local path, which would put a user path into rows. */
82
+ export function normaliseRemote(url) {
83
+ // A query or fragment can carry a token (`?private_token=`) and never names the repo.
84
+ const u = url.trim().replace(/[?#].*$/, '');
85
+ if (u === '' || u.includes('::') || /^file:/i.test(u) || /^[A-Za-z]:[\\/]/.test(u) || /^[./\\~]/.test(u))
86
+ return null;
87
+ let hostPath;
88
+ const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/]*)(.*)$/.exec(u);
89
+ if (withScheme) {
90
+ // Userinfo can hold a token, and a port differs between ssh and https.
91
+ hostPath = withScheme[1].replace(/^.*@/, '').replace(/:\d*$/, '') + withScheme[2];
92
+ }
93
+ else {
94
+ const scp = /^(?:[^@/]+@)?([^:/]+):(.*)$/.exec(u);
95
+ if (!scp)
96
+ return null;
97
+ hostPath = `${scp[1]}/${scp[2]}`;
98
+ }
99
+ const tidy = hostPath.toLowerCase().replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '');
100
+ const id = azureDevOps(tidy);
101
+ // A colon would break the `shared:<project>:` source format; whitespace means this was no URL; an `@` left in a path may be a credential.
102
+ return /^[^/]+\/./.test(id) && !/[:\s@]/.test(id) ? id : null;
103
+ }
104
+ /** The normalised origin of the checkout rooted at gitRoot, or null when it has none a project can be named by. */
105
+ export function originRemoteId(gitRoot) {
106
+ const file = gitConfigPath(gitRoot);
107
+ if (file === null)
108
+ return null;
109
+ try {
110
+ const url = originUrlFromConfig(fs.readFileSync(file, 'utf8'));
111
+ return url === null ? null : normaliseRemote(url);
112
+ }
113
+ catch (err) {
114
+ log.debug(`project remote: ${file} not read: ${err instanceof Error ? err.message : String(err)}`);
115
+ return null;
116
+ }
117
+ }
118
+ export const PROJECT_FILE = '.hippo-project.json';
119
+ /** The `id` a team committed in `.hippo-project.json` at root, lowercased like every project name; null when absent or unusable. */
120
+ export function projectFileId(root) {
121
+ const file = path.join(root, PROJECT_FILE);
122
+ if (!fs.existsSync(file))
123
+ return null;
124
+ try {
125
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
126
+ const raw = isObjectLike(parsed) && 'id' in parsed ? parsed.id : undefined;
127
+ const id = isStringValue(raw) ? raw.trim().toLowerCase() : '';
128
+ // No slash: a committed file in an untrusted clone must not claim a remote-shaped id like `github.com/acme/api`.
129
+ if (id !== '' && id.length <= 200 && !/[:\s/\\]/.test(id))
130
+ return id;
131
+ log.warn(`${file}: "id" must be a non-empty string with no colon, slash or spaces; using the remote or folder name instead`);
132
+ }
133
+ catch (err) {
134
+ log.warn(`${file} not read: ${err instanceof Error ? err.message : String(err)}; using the remote or folder name instead`);
135
+ }
136
+ return null;
137
+ }
138
+ //# sourceMappingURL=project-remote.js.map
@@ -1,5 +1,4 @@
1
- /** Z1: recall gated on the hook prompt, not the five newest memories (pure, no I/O).
2
- * See docs/plans/2026-09-26-z1-prompt-recall.md. */
1
+ /** Recall gated on the hook prompt, not the five newest memories (pure, no I/O). */
3
2
  import { tokenize } from './tokenize.js';
4
3
  import { STOP_WORDS } from './audit.js';
5
4
  // Latency bound, not tuned: fixed in the prereg regardless of gate config.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Per-key token-bucket rate limiter for inbound /v1/* requests.
3
3
  *
4
- * Bounds api-key-id enumeration (the v0.40 follow-up noted in auth.ts): a
4
+ * Bounds api-key-id enumeration: a
5
5
  * client that drains its bucket is denied until it refills. Dependency-free
6
6
  * and unit-testable in isolation via the injectable `now`.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Per-key token-bucket rate limiter for inbound /v1/* requests.
3
3
  *
4
- * Bounds api-key-id enumeration (the v0.40 follow-up noted in auth.ts): a
4
+ * Bounds api-key-id enumeration: a
5
5
  * client that drains its bucket is denied until it refills. Dependency-free
6
6
  * and unit-testable in isolation via the injectable `now`.
7
7
  */
@@ -11,5 +11,14 @@ export interface ArchiveOpts {
11
11
  */
12
12
  afterArchive?: (db: DatabaseSyncLike, archivedMemoryId: string) => void;
13
13
  }
14
+ /**
15
+ * The only legitimate path to remove a `kind='raw'` row from `memories`.
16
+ *
17
+ * Snapshots the full row into `raw_archive`, flips `kind` to `'archived'` so the
18
+ * append-only trigger lets the delete through, then deletes the row. All in one
19
+ * SAVEPOINT so it can be nested inside an outer transaction (e.g. batchWriteAndDelete).
20
+ *
21
+ * Throws if the row does not exist or is not `kind='raw'`.
22
+ */
14
23
  export declare function archiveRawMemory(db: DatabaseSyncLike, id: string, opts: ArchiveOpts): void;
15
24
  //# sourceMappingURL=raw-archive.d.ts.map
@@ -2,7 +2,7 @@ import { BadRequestError, NotFoundError } from './api-errors.js';
2
2
  import { isFtsAvailable } from './db.js';
3
3
  import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
4
4
  import { markSummaryDirtyInTx } from './summary-dirty.js';
5
- export function archiveRawMemory(db, id, opts) {
5
+ function loadRawRow(db, id) {
6
6
  // SAFETY: SELECT * FROM memories returns every column of the memories table; only
7
7
  // kind, tenant_id, and dag_parent_id are read below, all guaranteed present (possibly
8
8
  // null) by the memories schema.
@@ -12,65 +12,82 @@ export function archiveRawMemory(db, id, opts) {
12
12
  if (row.kind !== 'raw') {
13
13
  throw new BadRequestError(`memory ${id} is not raw (kind=${String(row.kind)})`);
14
14
  }
15
+ return row;
16
+ }
17
+ function moveRowToArchive(db, id, row, opts) {
18
+ // GDPR: raw_archive stores ONLY metadata, not the original
19
+ // memory content. The audit_log row appended below carries op='archive_raw'
20
+ // for the compliance audit trail. True right-to-be-forgotten — the original
21
+ // content is unrecoverable from raw_archive after this point.
22
+ const archivedAt = new Date().toISOString();
23
+ const redactedPayload = JSON.stringify({
24
+ redacted: true,
25
+ archived_at: archivedAt,
26
+ tenant_id: row.tenant_id ?? 'default',
27
+ kind: row.kind,
28
+ reason: opts.reason,
29
+ });
30
+ db.prepare(`INSERT INTO raw_archive (memory_id, archived_at, reason, archived_by, payload_json) VALUES (?, ?, ?, ?, ?)`).run(id, archivedAt, opts.reason, opts.who, redactedPayload);
31
+ // Flip kind to 'archived' so the BEFORE DELETE trigger no longer fires, then delete.
32
+ db.prepare(`UPDATE memories SET kind = 'archived' WHERE id = ?`).run(id);
33
+ db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
34
+ // FTS5 is a virtual table — no FK CASCADE applies. Purge the FTS row so the
35
+ // archived content is not searchable after archive. Without this the original
36
+ // raw text remains in memories_fts until the next DB-open backfill, defeating
37
+ // GDPR right-to-be-forgotten.
38
+ if (isFtsAvailable(db)) {
39
+ try {
40
+ db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
41
+ }
42
+ catch {
43
+ // Best effort only. The DELETE on memories already succeeded; FTS will
44
+ // self-heal on next DB open via backfillFtsIndex.
45
+ }
46
+ }
47
+ }
48
+ // Emit the archive_raw audit event inside the SAVEPOINT so the audit row is
49
+ // committed atomically with the row deletion. Use the row's own tenant_id
50
+ // (fetched above as part of SELECT *), not the env. Archives must be
51
+ // attributed to the tenant that owns the row, not whatever HIPPO_TENANT
52
+ // happens to be set to in the calling shell.
53
+ function auditArchive(db, id, row, opts) {
54
+ try {
55
+ appendAuditEvent(db, {
56
+ tenantId: String(row.tenant_id ?? 'default'),
57
+ actor: opts.who || 'cli',
58
+ op: 'archive_raw',
59
+ targetId: id,
60
+ metadata: { reason: opts.reason },
61
+ });
62
+ }
63
+ catch (error) {
64
+ // The archive itself has already succeeded; an unwritable audit table must not undo it.
65
+ reportAuditWriteFailure('archive_raw', String(error), id);
66
+ }
67
+ }
68
+ /**
69
+ * The only legitimate path to remove a `kind='raw'` row from `memories`.
70
+ *
71
+ * Snapshots the full row into `raw_archive`, flips `kind` to `'archived'` so the
72
+ * append-only trigger lets the delete through, then deletes the row. All in one
73
+ * SAVEPOINT so it can be nested inside an outer transaction (e.g. batchWriteAndDelete).
74
+ *
75
+ * Throws if the row does not exist or is not `kind='raw'`.
76
+ */
77
+ export function archiveRawMemory(db, id, opts) {
78
+ const row = loadRawRow(db, id);
15
79
  // SAVEPOINT (not BEGIN) so this works whether or not we're already inside a
16
80
  // transaction. SQLite refuses BEGIN within a transaction; SAVEPOINT nests safely.
17
81
  db.exec('SAVEPOINT archive_raw');
18
82
  try {
19
- // GDPR Path A (v0.39): raw_archive stores ONLY metadata, not the original
20
- // memory content. The audit_log row appended below carries op='archive_raw'
21
- // for the compliance audit trail. True right-to-be-forgotten — the original
22
- // content is unrecoverable from raw_archive after this point.
23
- const archivedAt = new Date().toISOString();
24
- const redactedPayload = JSON.stringify({
25
- redacted: true,
26
- archived_at: archivedAt,
27
- tenant_id: row.tenant_id ?? 'default',
28
- kind: row.kind,
29
- reason: opts.reason,
30
- });
31
- db.prepare(`INSERT INTO raw_archive (memory_id, archived_at, reason, archived_by, payload_json) VALUES (?, ?, ?, ?, ?)`).run(id, archivedAt, opts.reason, opts.who, redactedPayload);
32
- // Flip kind to 'archived' so the BEFORE DELETE trigger no longer fires, then delete.
33
- db.prepare(`UPDATE memories SET kind = 'archived' WHERE id = ?`).run(id);
34
- db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
35
- // FTS5 is a virtual table — no FK CASCADE applies. Purge the FTS row so the
36
- // archived content is not searchable after archive. Without this the original
37
- // raw text remains in memories_fts until the next DB-open backfill, defeating
38
- // GDPR right-to-be-forgotten.
39
- if (isFtsAvailable(db)) {
40
- try {
41
- db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
42
- }
43
- catch {
44
- // Best effort only. The DELETE on memories already succeeded; FTS will
45
- // self-heal on next DB open via backfillFtsIndex.
46
- }
47
- }
48
- // A5 audit: emit archive_raw event inside the SAVEPOINT so the audit row is
49
- // committed atomically with the row deletion. Use the row's own tenant_id
50
- // (fetched above as part of SELECT *), not the env. Archives must be
51
- // attributed to the tenant that owns the row, not whatever HIPPO_TENANT
52
- // happens to be set to in the calling shell.
53
- try {
54
- appendAuditEvent(db, {
55
- tenantId: String(row.tenant_id ?? 'default'),
56
- actor: opts.who || 'cli',
57
- op: 'archive_raw',
58
- targetId: id,
59
- metadata: { reason: opts.reason },
60
- });
61
- }
62
- catch (error) {
63
- // The archive itself has already succeeded; an unwritable audit table must not undo it.
64
- reportAuditWriteFailure('archive_raw', String(error), id);
65
- }
66
- // v0.30 / E2 — DAG live-coupling: archive of a child under a level-2
67
- // summary marks parent dirty. Inside the SAVEPOINT so the dirty-mark
68
- // commits atomically with the archive. row.dag_parent_id was fetched
69
- // via SELECT * at L28 (schema v28 includes it).
83
+ moveRowToArchive(db, id, row, opts);
84
+ auditArchive(db, id, row, opts);
85
+ // Archiving a child under a level-2 summary marks the parent dirty, inside the
86
+ // SAVEPOINT so the dirty-mark commits atomically with the archive.
70
87
  if (row.dag_parent_id) {
71
88
  markSummaryDirtyInTx(db, String(row.dag_parent_id), String(row.tenant_id ?? 'default'), opts.who || 'cli');
72
89
  }
73
- // afterArchive hook (v0.39 commit 3): connector-level idempotency markers
90
+ // afterArchive hook: connector-level idempotency markers
74
91
  // (e.g. slack_event_log) must commit atomically with the archive itself.
75
92
  // Throwing here rolls back the entire SAVEPOINT — both the archive and any
76
93
  // hook side effects. The hook runs INSIDE the SAVEPOINT so its writes