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,6 +1,5 @@
1
1
  /**
2
- * E2 project_brief first-class object
3
- * (docs/plans/2026-05-30-e2-project-brief-object.md).
2
+ * Project_brief first-class object.
4
3
  *
5
4
  * A `project_brief` is the living, repo-scoped summary of a repository's state: a
6
5
  * `summary` body scoped to a `repo`, evolving via the supersede delta lifecycle.
@@ -31,6 +30,7 @@ import { createMemory, Layer } from './memory.js';
31
30
  import { appendAuditEvent } from './audit.js';
32
31
  import { objectHalfLifeDays } from './half-life-migration.js';
33
32
  import { keysetAfter } from './keyset.js';
33
+ import { escapeLike } from './escape.js';
34
34
  export const VALID_BRIEF_STATES = new Set([
35
35
  'active',
36
36
  'superseded',
@@ -103,9 +103,83 @@ const BRIEF_COLS = `
103
103
  function buildBriefContent(repo, summary) {
104
104
  return `${repo}\n\n${summary}`;
105
105
  }
106
- // ---------------------------------------------------------------------------
107
- // Public API
108
- // ---------------------------------------------------------------------------
106
+ // Preflight the supersede target BEFORE inserting the new row (so the new
107
+ // autoincrement id can never be its own supersede target); read the
108
+ // predecessor version in the same SELECT for server-derived versioning.
109
+ // Mirrors saveSkill / saveProcess.
110
+ function preflightBriefSupersede(db, tenantId, supersedesId) {
111
+ // SAFETY: SELECT projects exactly status, version; .get() returns that
112
+ // shape for the matching row, or undefined when no brief/tenant pair matches.
113
+ const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
114
+ if (!pred) {
115
+ throw new NotFoundError(`saveProjectBrief: brief ${supersedesId} to supersede not found for tenant ${tenantId}`);
116
+ }
117
+ if (pred.status !== 'active') {
118
+ throw new ConflictError(`saveProjectBrief: brief ${supersedesId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
119
+ }
120
+ return pred.version + 1;
121
+ }
122
+ function insertBriefRow(db, memoryId, w, version) {
123
+ const result = db.prepare(`
124
+ INSERT INTO project_briefs(
125
+ memory_id, tenant_id, repo, summary, version,
126
+ status, superseded_by, superseded_at, change_summary, closed_at, created_at
127
+ ) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
128
+ `).run(memoryId, w.tenantId, w.repo, w.summary, version, w.changeSummary, w.now);
129
+ return Number(result.lastInsertRowid ?? 0);
130
+ }
131
+ function supersedeBriefRow(db, w, supersedesId, briefId, version) {
132
+ const sup = db.prepare(`
133
+ UPDATE project_briefs
134
+ SET status = 'superseded', superseded_by = ?, superseded_at = ?
135
+ WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
136
+ `).run(briefId, w.now, supersedesId, w.tenantId, briefId);
137
+ if (sup.changes === 0) {
138
+ throw new ConflictError(`saveProjectBrief: brief ${supersedesId} could not be superseded (no longer active or self-reference).`);
139
+ }
140
+ appendAuditEvent(db, {
141
+ tenantId: w.tenantId,
142
+ actor: w.actor,
143
+ op: 'project_brief_supersede',
144
+ targetId: String(supersedesId),
145
+ metadata: {
146
+ brief_id: supersedesId,
147
+ superseded_by: briefId,
148
+ new_version: version,
149
+ refreshed: w.isRefresh,
150
+ ...w.refreshAuditExtra,
151
+ },
152
+ });
153
+ }
154
+ /** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
155
+ function writeBriefRow(db, memoryId, w) {
156
+ const version = w.supersedesId !== undefined ? preflightBriefSupersede(db, w.tenantId, w.supersedesId) : 1;
157
+ const briefId = insertBriefRow(db, memoryId, w, version);
158
+ if (w.supersedesId !== undefined)
159
+ supersedeBriefRow(db, w, w.supersedesId, briefId, version);
160
+ // SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
161
+ // columns; .get() returns that row, or undefined only if the
162
+ // just-inserted id can't be found.
163
+ const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ?`)
164
+ .get(briefId);
165
+ if (!row)
166
+ throw new Error('saveProjectBrief: failed to reload saved brief row');
167
+ // GDPR-light metadata: ids + flags only, no brief text.
168
+ appendAuditEvent(db, {
169
+ tenantId: w.tenantId,
170
+ actor: w.actor,
171
+ op: 'project_brief_create',
172
+ targetId: String(briefId),
173
+ metadata: {
174
+ brief_id: briefId,
175
+ repo: w.repo,
176
+ version,
177
+ refreshed: w.isRefresh,
178
+ ...w.refreshAuditExtra,
179
+ },
180
+ });
181
+ return row;
182
+ }
109
183
  /**
110
184
  * Create a project_brief (or a new version that supersedes an existing one). Writes
111
185
  * the memory mirror + the project_briefs row atomically inside writeEntry's
@@ -133,79 +207,22 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
133
207
  baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
134
208
  tenantId,
135
209
  });
210
+ const w = {
211
+ tenantId,
212
+ actor,
213
+ repo,
214
+ summary: opts.summary,
215
+ changeSummary,
216
+ supersedesId: opts.supersedesBriefId,
217
+ isRefresh,
218
+ refreshAuditExtra,
219
+ now,
220
+ };
136
221
  let savedRow;
137
222
  writeEntry(hippoRoot, mem, {
138
223
  actor,
139
224
  afterWrite: (db, memoryId) => {
140
- // Preflight the supersede target BEFORE inserting the new row (so the new
141
- // autoincrement id can never be its own supersede target); read the
142
- // predecessor version in the same SELECT for server-derived versioning.
143
- // Mirrors saveSkill / saveProcess (codex P1 2026-05-28).
144
- let version = 1;
145
- if (opts.supersedesBriefId !== undefined) {
146
- // SAFETY: SELECT projects exactly status, version; .get() returns that
147
- // shape for the matching row, or undefined when no brief/tenant pair matches.
148
- const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(opts.supersedesBriefId, tenantId);
149
- if (!pred) {
150
- throw new NotFoundError(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
151
- }
152
- if (pred.status !== 'active') {
153
- throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
154
- }
155
- version = pred.version + 1;
156
- }
157
- const result = db.prepare(`
158
- INSERT INTO project_briefs(
159
- memory_id, tenant_id, repo, summary, version,
160
- status, superseded_by, superseded_at, change_summary, closed_at, created_at
161
- ) VALUES (?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
162
- `).run(memoryId, tenantId, repo, opts.summary, version, changeSummary, now);
163
- const briefId = Number(result.lastInsertRowid ?? 0);
164
- if (opts.supersedesBriefId !== undefined) {
165
- const sup = db.prepare(`
166
- UPDATE project_briefs
167
- SET status = 'superseded', superseded_by = ?, superseded_at = ?
168
- WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
169
- `).run(briefId, now, opts.supersedesBriefId, tenantId, briefId);
170
- if (sup.changes === 0) {
171
- throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
172
- }
173
- appendAuditEvent(db, {
174
- tenantId,
175
- actor,
176
- op: 'project_brief_supersede',
177
- targetId: String(opts.supersedesBriefId),
178
- metadata: {
179
- brief_id: opts.supersedesBriefId,
180
- superseded_by: briefId,
181
- new_version: version,
182
- refreshed: isRefresh,
183
- ...refreshAuditExtra,
184
- },
185
- });
186
- }
187
- // SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
188
- // columns; .get() returns that row, or undefined only if the
189
- // just-inserted id can't be found.
190
- const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ?`)
191
- .get(briefId);
192
- if (!row)
193
- throw new Error('saveProjectBrief: failed to reload saved brief row');
194
- savedRow = row;
195
- // GDPR-light metadata: ids + flags only, no brief text.
196
- appendAuditEvent(db, {
197
- tenantId,
198
- actor,
199
- op: 'project_brief_create',
200
- targetId: String(briefId),
201
- metadata: {
202
- brief_id: briefId,
203
- repo,
204
- version,
205
- refreshed: isRefresh,
206
- ...refreshAuditExtra,
207
- },
208
- });
225
+ savedRow = writeBriefRow(db, memoryId, w);
209
226
  },
210
227
  // afterCommit covers refreshBrief (delegates here) + brief new/supersede.
211
228
  afterCommit: () => markGraphDirty(hippoRoot, tenantId, mem.id),
@@ -259,7 +276,7 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
259
276
  // Closing removes the object from the graph. Remove its rows DIRECTLY (deterministic),
260
277
  // not only via an enqueued rebuild whose queue item is lost if the mirror is later
261
278
  // forgotten (the queue row cascade-deletes with the memory), which would leave the closed
262
- // object stale and could block that forget (codex P1). Still enqueue when a mirror exists
279
+ // object stale and could block that forget. Still enqueue when a mirror exists
263
280
  // so a concurrent rebuild re-derives consistently (harmless if it also runs).
264
281
  removeGraphEntitiesForObject(hippoRoot, tenantId, 'project', closed.id);
265
282
  if (closed.memoryId) {
@@ -334,7 +351,7 @@ export function loadProjectBriefs(hippoRoot, tenantId, opts = {}) {
334
351
  /**
335
352
  * The repo's CURRENT active brief, or null. By convention there is one active brief
336
353
  * per (tenant, repo); if an operator created more than one (the DB does not prevent
337
- * it, consistent with every other E2 object), the MOST-RECENT active row wins.
354
+ * it, consistent with every other first-class object), the MOST-RECENT active row wins.
338
355
  */
339
356
  export function loadActiveBriefForRepo(hippoRoot, tenantId, repo) {
340
357
  assertTenantId('loadActiveBriefForRepo', tenantId);
@@ -358,10 +375,6 @@ export function loadActiveBriefForRepo(hippoRoot, tenantId, repo) {
358
375
  // ---------------------------------------------------------------------------
359
376
  // Refresh assembler (the distinguishing deliverable)
360
377
  // ---------------------------------------------------------------------------
361
- /** Escape LIKE wildcards in operator-supplied text (mirror of store/search-rows.ts). */
362
- function escapeLike(term) {
363
- return term.replace(/[%_\\]/g, '\\$&');
364
- }
365
378
  /** Single-line headline for a receipt: first non-empty line, newline-stripped,
366
379
  * truncated. Deterministic + safe for the markdown bullet list. */
367
380
  function receiptHeadline(content) {
@@ -371,34 +384,16 @@ function receiptHeadline(content) {
371
384
  ? `${trimmed.slice(0, MAX_RECEIPT_HEADLINE_LEN)}...`
372
385
  : trimmed;
373
386
  }
374
- /**
375
- * Assemble the repo's recent receipts into a deterministic markdown digest, and
376
- * return it WITH the receipt count (the count feeds refreshBrief's change_summary +
377
- * audit metadata). NO LLM. Always returns a non-empty, valid summary (a brief
378
- * `summary` is NOT NULL), including the zero-receipts case.
379
- *
380
- * A "receipt" = a tenant memory row carrying the repo's `path:<repo>` tag. The
381
- * brief's OWN memory mirror (source='project_brief') is excluded so a brief never
382
- * becomes its own receipt on the next refresh. The match is against the JSON-array
383
- * serialization (each element is a double-quoted string `"path:hippo"`); the
384
- * surrounding quotes are load-bearing — they stop `hip` matching `path:hippo`.
385
- * `repo` is LIKE-escaped + parameterized (operator-supplied; security.md).
386
- */
387
- export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
388
- assertTenantId('assembleBriefFromReceipts', tenantId);
389
- const normalizedRepo = (repo ?? '').trim();
390
- if (normalizedRepo.length === 0) {
391
- throw new BadRequestError('assembleBriefFromReceipts: repo is required');
392
- }
387
+ /** The repo's receipt rows, newest first, capped at MAX_BRIEF_RECEIPTS. */
388
+ function loadBriefReceipts(hippoRoot, tenantId, normalizedRepo) {
393
389
  const tag = `path:${normalizedRepo.toLowerCase()}`;
394
390
  const likeParam = `%"${escapeLike(tag)}"%`;
395
391
  const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
396
392
  const db = openHippoDb(hippoRoot);
397
- let receipts;
398
393
  try {
399
394
  // SAFETY: SELECT projects exactly id, created, source, content (the
400
395
  // ReceiptRow columns); .all() returns rows in that shape.
401
- receipts = db.prepare(`
396
+ return db.prepare(`
402
397
  SELECT id, created, source, content FROM memories
403
398
  WHERE tenant_id = ?
404
399
  AND source != 'project_brief'
@@ -411,17 +406,19 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
411
406
  finally {
412
407
  closeHippoDb(db);
413
408
  }
414
- // NOTE on ordering: the `id DESC` tiebreak is lexical on a random-ish memory id
415
- // (e.g. `sem_<hex>`), NOT chronological — within the same `created` timestamp the
416
- // order is stable-but-arbitrary, not insertion order. `created DESC` is the real
417
- // recency ordering. (plan-eng-critic 2026-05-30, med.)
418
- //
419
- // Budget-aware assembly (codex-review-critic 2026-05-30, P2): the digest is the
420
- // brief `summary`, which saveProjectBrief caps at MAX_BRIEF_SUMMARY_LEN. The
421
- // receipt/headline caps (50 x ~200) could otherwise build an ~11KB body that the
422
- // store then REJECTS, breaking refresh for inputs within the advertised caps. So
423
- // include receipt lines newest-first only while they fit under the cap (reserving
424
- // slack for the header + an omission footer), and note the omitted remainder.
409
+ }
410
+ // NOTE on ordering: the `id DESC` tiebreak is lexical on a random-ish memory id
411
+ // (e.g. `sem_<hex>`), NOT chronological — within the same `created` timestamp the
412
+ // order is stable-but-arbitrary, not insertion order. `created DESC` is the real
413
+ // recency ordering.
414
+ //
415
+ // Budget-aware assembly: the digest is the
416
+ // brief `summary`, which saveProjectBrief caps at MAX_BRIEF_SUMMARY_LEN. The
417
+ // receipt/headline caps (50 x ~200) could otherwise build an ~11KB body that the
418
+ // store then REJECTS, breaking refresh for inputs within the advertised caps. So
419
+ // include receipt lines newest-first only while they fit under the cap (reserving
420
+ // slack for the header + an omission footer), and note the omitted remainder.
421
+ function fitReceiptLines(receipts) {
425
422
  const buildReceiptLine = (r) => `- ${(r.created ?? '').slice(0, 10)} [${r.source}] ${receiptHeadline(r.content)}`;
426
423
  const receiptLines = [];
427
424
  if (receipts.length > 0) {
@@ -437,17 +434,20 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
437
434
  bodyBudget -= line.length + 1;
438
435
  }
439
436
  }
440
- const omitted = receipts.length - receiptLines.length;
437
+ return receiptLines;
438
+ }
439
+ function renderBriefDigest(normalizedRepo, receiptCount, receiptLines) {
440
+ const omitted = receiptCount - receiptLines.length;
441
441
  const lines = [];
442
442
  lines.push(`# Project Brief: ${normalizedRepo}`);
443
443
  lines.push('');
444
444
  lines.push(omitted > 0
445
- ? `_Auto-assembled from ${receiptLines.length} of ${receipts.length} receipt(s)._`
446
- : `_Auto-assembled from ${receipts.length} receipt(s)._`);
445
+ ? `_Auto-assembled from ${receiptLines.length} of ${receiptCount} receipt(s)._`
446
+ : `_Auto-assembled from ${receiptCount} receipt(s)._`);
447
447
  lines.push('');
448
448
  lines.push('## Recent receipts');
449
449
  lines.push('');
450
- if (receipts.length === 0) {
450
+ if (receiptCount === 0) {
451
451
  lines.push(`_No receipts found for ${normalizedRepo}._`);
452
452
  }
453
453
  else {
@@ -464,6 +464,29 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
464
464
  if (markdown.length > MAX_BRIEF_SUMMARY_LEN) {
465
465
  markdown = markdown.slice(0, MAX_BRIEF_SUMMARY_LEN);
466
466
  }
467
+ return markdown;
468
+ }
469
+ /**
470
+ * Assemble the repo's recent receipts into a deterministic markdown digest, and
471
+ * return it WITH the receipt count (the count feeds refreshBrief's change_summary +
472
+ * audit metadata). NO LLM. Always returns a non-empty, valid summary (a brief
473
+ * `summary` is NOT NULL), including the zero-receipts case.
474
+ *
475
+ * A "receipt" = a tenant memory row carrying the repo's `path:<repo>` tag. The
476
+ * brief's OWN memory mirror (source='project_brief') is excluded so a brief never
477
+ * becomes its own receipt on the next refresh. The match is against the JSON-array
478
+ * serialization (each element is a double-quoted string `"path:hippo"`); the
479
+ * surrounding quotes are load-bearing — they stop `hip` matching `path:hippo`.
480
+ * `repo` is LIKE-escaped + parameterized (operator-supplied; security.md).
481
+ */
482
+ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
483
+ assertTenantId('assembleBriefFromReceipts', tenantId);
484
+ const normalizedRepo = (repo ?? '').trim();
485
+ if (normalizedRepo.length === 0) {
486
+ throw new BadRequestError('assembleBriefFromReceipts: repo is required');
487
+ }
488
+ const receipts = loadBriefReceipts(hippoRoot, tenantId, normalizedRepo);
489
+ const markdown = renderBriefDigest(normalizedRepo, receipts.length, fitReceiptLines(receipts));
467
490
  return { markdown, receiptCount: receipts.length };
468
491
  }
469
492
  /**
@@ -491,8 +514,8 @@ export function refreshBrief(hippoRoot, tenantId, repo, actor = 'cli') {
491
514
  supersedesBriefId: active ? active.id : undefined,
492
515
  refreshReceiptCount: receiptCount,
493
516
  // Tag the refreshed brief's mirror as repo-local so path-aware recall boosts
494
- // it like the manual `brief new`/`supersede` paths do (codex-review 2026-05-30,
495
- // P2). Safe vs self-recursion: assembleBriefFromReceipts excludes
517
+ // it like the manual `brief new`/`supersede` paths do.
518
+ // Safe vs self-recursion: assembleBriefFromReceipts excludes
496
519
  // source='project_brief', so the brief never becomes its own receipt.
497
520
  extraTags: [`path:${normalizedRepo.toLowerCase()}`],
498
521
  }, actor);
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Project identity resolution for memory scope isolation (ROADMAP.md Part I
3
- * [Committed] "Memory scope isolation"; plan docs/plans/2026-07-01-memory-scope-isolation.md S1).
2
+ * Project identity resolution for memory scope isolation.
4
3
  *
5
4
  * Resolution rules:
6
5
  * - The nearest ancestor of cwd (including cwd itself) containing a `.hippo`
@@ -11,6 +10,9 @@
11
10
  * - A directory with no marker anywhere up the walk is NOT a project: it
12
11
  * resolves to the user-global identity (empty name), so memories written
13
12
  * there stay injectable everywhere (matches pre-isolation behavior).
13
+ * - A project's name is its id: `.hippo-project.json` at the root, else the
14
+ * checkout's normalised `origin` remote, else the folder rule (legacyName),
15
+ * so two repos in folders of one name stay apart.
14
16
  *
15
17
  * NOTE: this module must stay free of imports from shared.ts / store.ts /
16
18
  * api.ts so any of them can import it without creating a cycle.
@@ -19,8 +21,12 @@
19
21
  export interface ProjectIdentity {
20
22
  /** Realpath-resolved root directory of the project (the start dir when not in a project). */
21
23
  root: string;
22
- /** Lowercased basename of the project root, or of its repo for a linked worktree with no `.hippo` (a store keeps the name its rows carry); '' outside a project. */
24
+ /** The project id rows are stamped with: the project file's id, the origin remote, or legacyName; '' outside a project. */
23
25
  name: string;
26
+ /** The folder rule rows written before ids carry: the root's lowercased basename, or its repo's for a linked worktree with no `.hippo`. */
27
+ legacyName: string;
28
+ /** Every other name this root resolves to (the project file id, the origin remote even with the rule off, legacyName), so rows written under an earlier id stay readable. */
29
+ aliases?: readonly string[];
24
30
  /** True when the directory resolves to the user home working set. */
25
31
  isHome: boolean;
26
32
  }
@@ -33,7 +39,7 @@ export interface ResolveProjectIdentityOpts {
33
39
  homeDir?: string;
34
40
  stopDir?: string;
35
41
  }
36
- /** Clear the per-process identity cache (test seam). */
42
+ /** Clear the per-process identity cache and the remote switch it read (test seam). */
37
43
  export declare function clearProjectIdentityCache(): void;
38
44
  /**
39
45
  * Canonicalize a path via realpath, falling back to path.resolve when the
@@ -46,17 +52,25 @@ export declare function realpathOrResolve(p: string): string;
46
52
  */
47
53
  export declare function resolveProjectIdentity(cwd?: string, opts?: ResolveProjectIdentityOpts): ProjectIdentity;
48
54
  /** Nearest ancestor `.hippo` below home and the temp root (never projects; on Windows the temp root sits inside home).
49
- * Everything is realpath'd so a symlinked temp root or cwd still matches its bound. Design notes: docs/plans/2026-09-05-*.md */
55
+ * Everything is realpath'd so a symlinked temp root or cwd still matches its bound. */
50
56
  export declare function findHippoStoreDir(cwd?: string, opts?: ResolveProjectIdentityOpts): string | null;
57
+ /** A reader's project: a bare name, or an identity whose own rows may also carry its legacy folder name. */
58
+ export type ProjectRef = string | Pick<ProjectIdentity, 'name' | 'legacyName' | 'aliases'>;
59
+ /** The id a reader's new rows are stamped with; '' outside a project. */
60
+ export declare function projectId(project: ProjectRef): string;
61
+ /** Every origin_project value the reader's own rows carry: the id first, then each alias and the legacy name. */
62
+ export declare function projectNames(project: ProjectRef): readonly string[];
63
+ /** `origin_project IN (?, ...)` with one placeholder per name; an empty list matches nothing. */
64
+ export declare function originInSql(names: readonly string[], column?: string): string;
51
65
  /**
52
66
  * v39 memory scope isolation: classify a memory's origin_project against the
53
- * active project. `currentName === ''` means the session is not in a project
67
+ * active project. An empty current id means the session is not in a project
54
68
  * (home dir or markerless cwd) - everything is in scope there, matching
55
69
  * pre-isolation behavior. NULL/undefined origin is a legacy pre-v39 row and
56
70
  * is treated as cross-project (deny by default) - the safe direction for a
57
71
  * security partition.
58
72
  */
59
- export declare function classifyOriginProject(origin: string | null | undefined, currentName: string): 'project' | 'user-global' | 'cross-project';
73
+ export declare function classifyOriginProject(origin: string | null | undefined, current: ProjectRef): 'project' | 'user-global' | 'cross-project';
60
74
  /**
61
75
  * The global Hippo store directory, resolved the same way shared.ts does:
62
76
  * $HIPPO_HOME > $XDG_DATA_HOME/hippo > ~/.hippo. Lives here (leaf module) so
@@ -85,8 +99,7 @@ export declare function originFromSource(source: string | null | undefined, home
85
99
  * Returns the project name, or '' for user-global (written at/under home or
86
100
  * in a markerless directory) - injectable everywhere. Write sites must always
87
101
  * persist this value; a NULL origin_project column is reserved for legacy
88
- * pre-migration rows, which ambient context treats as deny (see plan
89
- * docs/plans/2026-07-01-memory-scope-isolation.md "Origin model").
102
+ * pre-migration rows, which ambient context treats as deny.
90
103
  */
91
104
  export declare function deriveOriginProject(cwd?: string, opts?: ResolveProjectIdentityOpts): string;
92
105
  //# sourceMappingURL=project-identity.d.ts.map
@@ -2,12 +2,31 @@ import { envHippoHome, envXdgDataHome } from './env.js';
2
2
  import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import { loadConfig } from './config.js';
5
6
  import { log } from './log.js';
7
+ import { originRemoteId, projectFileId } from './project-remote.js';
6
8
  const MAX_WALK_DEPTH = 64;
7
9
  const identityCache = new Map();
8
- /** Clear the per-process identity cache (test seam). */
10
+ let remoteSwitch = null;
11
+ /** Clear the per-process identity cache and the remote switch it read (test seam). */
9
12
  export function clearProjectIdentityCache() {
10
13
  identityCache.clear();
14
+ remoteSwitch = null;
15
+ }
16
+ // The global store's config only: a per-store switch would stamp one checkout two ways.
17
+ function remoteRuleOn() {
18
+ const globalRoot = resolveGlobalRootDir();
19
+ if (remoteSwitch?.globalRoot !== globalRoot)
20
+ remoteSwitch = { globalRoot, on: loadConfig(globalRoot).projectIdentity.remote };
21
+ return remoteSwitch.on;
22
+ }
23
+ /** The id a root names itself by plus every rung that resolves; only the root's own `.git` is read, so a store nested in another checkout keeps its folder name. */
24
+ function namesAt(root, legacyName) {
25
+ const fileId = projectFileId(root);
26
+ const remoteId = originRemoteId(root);
27
+ const name = fileId ?? (remoteRuleOn() ? remoteId : null) ?? legacyName;
28
+ const aliases = [...new Set([fileId, remoteId, legacyName])].filter((n) => n !== null && n !== name);
29
+ return aliases.length > 0 ? { name, aliases } : { name };
11
30
  }
12
31
  /**
13
32
  * Canonicalize a path via realpath, falling back to path.resolve when the
@@ -72,16 +91,16 @@ export function resolveProjectIdentity(cwd, opts) {
72
91
  let identity;
73
92
  const root = hippoRoot ?? gitRoot;
74
93
  if (root !== null) {
75
- const name = (hippoRoot === null ? linkedWorktreeRepoName(root, home) : null) ?? path.basename(root).toLowerCase();
76
- identity = { root, name, isHome: false };
94
+ const legacyName = (hippoRoot === null ? linkedWorktreeRepoName(root, home) : null) ?? path.basename(root).toLowerCase();
95
+ identity = { root, ...namesAt(root, legacyName), legacyName, isHome: false };
77
96
  }
78
97
  else if (reachedHome || isUnder(start, home)) {
79
- identity = { root: home, name: '', isHome: true };
98
+ identity = { root: home, name: '', legacyName: '', isHome: true };
80
99
  }
81
100
  else {
82
101
  // No markers anywhere: not a project. Empty name keeps these memories
83
102
  // user-global rather than fabricating an origin from a basename.
84
- identity = { root: start, name: '', isHome: false };
103
+ identity = { root: start, name: '', legacyName: '', isHome: false };
85
104
  }
86
105
  if (cacheable)
87
106
  identityCache.set(startInput, identity);
@@ -137,7 +156,7 @@ function walkProjectMarkers(start, home, stopDirs) {
137
156
  return { hippoRoot, gitRoot, reachedHome };
138
157
  }
139
158
  /** Nearest ancestor `.hippo` below home and the temp root (never projects; on Windows the temp root sits inside home).
140
- * Everything is realpath'd so a symlinked temp root or cwd still matches its bound. Design notes: docs/plans/2026-09-05-*.md */
159
+ * Everything is realpath'd so a symlinked temp root or cwd still matches its bound. */
141
160
  export function findHippoStoreDir(cwd, opts) {
142
161
  const home = realpathOrResolve(opts?.homeDir ?? os.homedir());
143
162
  const stops = [realpathOrResolve(os.tmpdir())];
@@ -147,22 +166,39 @@ export function findHippoStoreDir(cwd, opts) {
147
166
  const { hippoRoot } = walkProjectMarkers(start, home, stops);
148
167
  return hippoRoot === null ? null : path.join(hippoRoot, '.hippo');
149
168
  }
169
+ function isBareName(project) {
170
+ return typeof project === 'string';
171
+ }
172
+ /** The id a reader's new rows are stamped with; '' outside a project. */
173
+ export function projectId(project) {
174
+ return isBareName(project) ? project : project.name;
175
+ }
176
+ /** Every origin_project value the reader's own rows carry: the id first, then each alias and the legacy name. */
177
+ export function projectNames(project) {
178
+ if (isBareName(project) || project.name === '')
179
+ return [projectId(project)];
180
+ return [...new Set([project.name, ...(project.aliases ?? []), project.legacyName])].filter((n) => n !== '');
181
+ }
182
+ /** `origin_project IN (?, ...)` with one placeholder per name; an empty list matches nothing. */
183
+ export function originInSql(names, column = 'origin_project') {
184
+ return names.length === 0 ? '0' : `${column} IN (${names.map(() => '?').join(', ')})`;
185
+ }
150
186
  /**
151
187
  * v39 memory scope isolation: classify a memory's origin_project against the
152
- * active project. `currentName === ''` means the session is not in a project
188
+ * active project. An empty current id means the session is not in a project
153
189
  * (home dir or markerless cwd) - everything is in scope there, matching
154
190
  * pre-isolation behavior. NULL/undefined origin is a legacy pre-v39 row and
155
191
  * is treated as cross-project (deny by default) - the safe direction for a
156
192
  * security partition.
157
193
  */
158
- export function classifyOriginProject(origin, currentName) {
159
- if (currentName === '')
194
+ export function classifyOriginProject(origin, current) {
195
+ if (projectId(current) === '')
160
196
  return 'project';
161
197
  if (origin === undefined || origin === null)
162
198
  return 'cross-project';
163
199
  if (origin === '')
164
200
  return 'user-global';
165
- return origin === currentName ? 'project' : 'cross-project';
201
+ return projectNames(current).includes(origin) ? 'project' : 'cross-project';
166
202
  }
167
203
  /**
168
204
  * The global Hippo store directory, resolved the same way shared.ts does:
@@ -221,8 +257,7 @@ export function originFromSource(source, homeName) {
221
257
  * Returns the project name, or '' for user-global (written at/under home or
222
258
  * in a markerless directory) - injectable everywhere. Write sites must always
223
259
  * persist this value; a NULL origin_project column is reserved for legacy
224
- * pre-migration rows, which ambient context treats as deny (see plan
225
- * docs/plans/2026-07-01-memory-scope-isolation.md "Origin model").
260
+ * pre-migration rows, which ambient context treats as deny.
226
261
  */
227
262
  export function deriveOriginProject(cwd, opts) {
228
263
  return resolveProjectIdentity(cwd, opts).name;
@@ -11,14 +11,29 @@ export interface ProjectSummary {
11
11
  export interface MergeResult {
12
12
  readonly from: string;
13
13
  readonly into: string;
14
- /** Imported note copies moved to dormant storage; the next sync under `into` imports the notes still on disk. */
14
+ /** Imports `into` already holds for the same note, moved to dormant storage; the rest are re-tagged and the next sync files them under `into`. */
15
15
  readonly setAside: readonly string[];
16
16
  readonly restamped: readonly string[];
17
17
  readonly dormantRestamped: readonly string[];
18
18
  readonly compactions: number;
19
19
  readonly backup: string | null;
20
20
  }
21
+ export interface ProjectFold {
22
+ readonly from: string;
23
+ readonly into: string;
24
+ }
25
+ /** An old name whose session folders now resolve to several projects: no fold can say whose rows are whose. */
26
+ export interface ProjectCollision {
27
+ readonly name: string;
28
+ readonly ids: readonly string[];
29
+ }
21
30
  export interface RepairResult {
31
+ /** Imported notes filed under the wrong project, or under a project name when a user-global import holds the same text: set aside. */
32
+ readonly copies: readonly string[];
33
+ /** Names whose recorded session folders all resolve to one other project today, folded as `merge` would. */
34
+ readonly folds: readonly ProjectFold[];
35
+ /** Names left unfolded because their folders now resolve to more than one project; only `merge` by hand can split them. */
36
+ readonly collisions: readonly ProjectCollision[];
22
37
  readonly toProject: ReadonlyArray<{
23
38
  readonly id: string;
24
39
  readonly origin: string;
@@ -42,11 +57,19 @@ export declare function mergeProjects(db: DatabaseSyncLike, hippoRoot: string, o
42
57
  into: string;
43
58
  dryRun: boolean;
44
59
  }): MergeResult;
45
- /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
46
- export declare function planUserGlobalRepair(db: DatabaseSyncLike, tenantId: string): Omit<RepairResult, 'backup'>;
47
- /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
48
- export declare function repairUserGlobalMerges(db: DatabaseSyncLike, hippoRoot: string, opts: {
60
+ /** Names folded, directly or through others, into one of `names`; the sync moves imports still filed under them. */
61
+ export declare function namesFoldedInto(db: DatabaseSyncLike, tenantId: string, names: readonly string[]): string[];
62
+ /** 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. */
63
+ export declare function planProjectRepair(db: DatabaseSyncLike, hippoRoot: string, tenantId: string, globalFolds?: boolean): Omit<RepairResult, 'backup'>;
64
+ /** 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. */
65
+ export declare function repairProjects(db: DatabaseSyncLike, hippoRoot: string, opts: {
49
66
  tenantId: string;
50
67
  dryRun: boolean;
68
+ globalFolds?: boolean;
51
69
  }): RepairResult;
70
+ /**
71
+ * Sleep runs repair once per store, so an upgrade needs no command. Global-store folds stay
72
+ * with `hippo projects repair`: their evidence is compaction folders, blind to a same-named repo that never compacted.
73
+ */
74
+ export declare function repairOnceOnSleep(db: DatabaseSyncLike, hippoRoot: string, tenantId: string): RepairResult | null;
52
75
  //# sourceMappingURL=project-merge.d.ts.map