hippo-memory 1.63.1 → 1.64.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 (275) hide show
  1. package/README.md +13 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/quality-repair-once.d.ts +3 -0
  76. package/dist/cli/quality-repair-once.js +36 -0
  77. package/dist/cli/recall.js +2 -1
  78. package/dist/cli/remember.d.ts +2 -0
  79. package/dist/cli/remember.js +4 -1
  80. package/dist/cli/session-hooks.d.ts +0 -3
  81. package/dist/cli/session-hooks.js +12 -49
  82. package/dist/cli/setup.js +10 -2
  83. package/dist/cli/shared.d.ts +7 -14
  84. package/dist/cli/shared.js +19 -78
  85. package/dist/cli/sleep.js +6 -3
  86. package/dist/cli/usage.js +18 -9
  87. package/dist/cli.d.ts +1 -1
  88. package/dist/cli.js +3 -2
  89. package/dist/client.js +1 -1
  90. package/dist/compaction-record.d.ts +19 -1
  91. package/dist/compaction-record.js +61 -132
  92. package/dist/compaction-spool.d.ts +45 -0
  93. package/dist/compaction-spool.js +454 -0
  94. package/dist/config.d.ts +8 -0
  95. package/dist/config.js +49 -0
  96. package/dist/connectors/github/deletion.d.ts +1 -1
  97. package/dist/connectors/github/deletion.js +5 -17
  98. package/dist/consolidate/conflicts.js +4 -0
  99. package/dist/consolidate/flush-units.d.ts +9 -0
  100. package/dist/consolidate/flush-units.js +63 -0
  101. package/dist/consolidate/llm-passes.js +5 -4
  102. package/dist/consolidate/merge.js +5 -1
  103. package/dist/consolidate/run.d.ts +2 -1
  104. package/dist/consolidate/sleep.d.ts +2 -0
  105. package/dist/consolidate/sleep.js +82 -15
  106. package/dist/context-render.d.ts +14 -0
  107. package/dist/context-render.js +28 -0
  108. package/dist/dag.js +6 -0
  109. package/dist/dashboard-actions.js +3 -2
  110. package/dist/dashboard.js +4 -3
  111. package/dist/db/backup.d.ts +6 -0
  112. package/dist/db/backup.js +29 -0
  113. package/dist/db/busy.d.ts +9 -0
  114. package/dist/db/busy.js +35 -0
  115. package/dist/db/continuity.d.ts +1 -0
  116. package/dist/db/continuity.js +31 -4
  117. package/dist/db/meta.d.ts +2 -0
  118. package/dist/db/meta.js +7 -0
  119. package/dist/db/migrate.js +2 -3
  120. package/dist/db/migrations/index.d.ts +1 -1
  121. package/dist/db/migrations/index.js +5 -1
  122. package/dist/db/migrations/v39.js +2 -7
  123. package/dist/db/migrations/v53.d.ts +3 -0
  124. package/dist/db/migrations/v53.js +16 -0
  125. package/dist/db/migrations/v54.d.ts +3 -0
  126. package/dist/db/migrations/v54.js +25 -0
  127. package/dist/db/open.d.ts +11 -0
  128. package/dist/db/open.js +27 -0
  129. package/dist/db/tables.d.ts +2 -0
  130. package/dist/db/tables.js +6 -2
  131. package/dist/db.d.ts +2 -2
  132. package/dist/db.js +2 -2
  133. package/dist/doctor.d.ts +1 -1
  134. package/dist/doctor.js +31 -15
  135. package/dist/dormant.d.ts +16 -20
  136. package/dist/dormant.js +24 -30
  137. package/dist/entry/json-hooks.d.ts +3 -0
  138. package/dist/entry/json-hooks.js +4 -0
  139. package/dist/entry/project-identity.d.ts +2 -0
  140. package/dist/entry/project-identity.js +3 -0
  141. package/dist/entry/session-text.d.ts +7 -0
  142. package/dist/entry/session-text.js +8 -0
  143. package/dist/extract.js +6 -0
  144. package/dist/failure-log.d.ts +10 -2
  145. package/dist/failure-log.js +19 -2
  146. package/dist/gated-write.js +1 -1
  147. package/dist/graph/delta.d.ts +44 -0
  148. package/dist/graph/delta.js +109 -0
  149. package/dist/graph/types.d.ts +5 -0
  150. package/dist/graph/write.d.ts +20 -20
  151. package/dist/graph/write.js +103 -36
  152. package/dist/graph-extract.d.ts +43 -9
  153. package/dist/graph-extract.js +140 -157
  154. package/dist/graph-recall.d.ts +1 -0
  155. package/dist/graph-recall.js +2 -2
  156. package/dist/graph-view.d.ts +1 -0
  157. package/dist/graph-view.js +25 -2
  158. package/dist/half-life-migration.js +5 -1
  159. package/dist/handoff-evidence.d.ts +5 -0
  160. package/dist/handoff-evidence.js +30 -0
  161. package/dist/home-path.d.ts +5 -0
  162. package/dist/home-path.js +24 -0
  163. package/dist/hooks/json-hooks.d.ts +13 -2
  164. package/dist/hooks/json-hooks.js +246 -117
  165. package/dist/hooks/opencode.js +1 -1
  166. package/dist/hooks/shared.d.ts +2 -0
  167. package/dist/hooks/shared.js +6 -1
  168. package/dist/http-util.d.ts +16 -7
  169. package/dist/http-util.js +65 -20
  170. package/dist/importers/vault.js +2 -0
  171. package/dist/index.d.ts +2 -1
  172. package/dist/index.js +1 -0
  173. package/dist/json.d.ts +2 -0
  174. package/dist/json.js +7 -0
  175. package/dist/ledger-db.d.ts +10 -0
  176. package/dist/ledger-db.js +42 -0
  177. package/dist/mcp/admin-tools.d.ts +2 -2
  178. package/dist/mcp/admin-tools.js +47 -5
  179. package/dist/mcp/memory-tools.js +7 -2
  180. package/dist/mcp/protocol.d.ts +6 -0
  181. package/dist/mcp/protocol.js +4 -2
  182. package/dist/mcp/recall-tools.js +19 -6
  183. package/dist/mcp/request.js +4 -0
  184. package/dist/mcp/session-state.d.ts +4 -0
  185. package/dist/mcp/session-state.js +15 -6
  186. package/dist/mcp/shared-gate.d.ts +4 -0
  187. package/dist/mcp/shared-gate.js +22 -0
  188. package/dist/mcp/tools.js +1 -0
  189. package/dist/memory-quality.d.ts +35 -0
  190. package/dist/memory-quality.js +191 -0
  191. package/dist/memory.d.ts +4 -4
  192. package/dist/physics-state.js +1 -1
  193. package/dist/pilot-arm.d.ts +14 -3
  194. package/dist/pilot-arm.js +32 -10
  195. package/dist/postinstall.js +2 -2
  196. package/dist/project-briefs.js +4 -4
  197. package/dist/project-identity.d.ts +14 -10
  198. package/dist/project-identity.js +29 -11
  199. package/dist/project-merge.d.ts +0 -2
  200. package/dist/project-merge.js +9 -13
  201. package/dist/prompt-hook.d.ts +65 -0
  202. package/dist/prompt-hook.js +213 -0
  203. package/dist/prompt-recall.js +1 -1
  204. package/dist/quality-repair.d.ts +27 -0
  205. package/dist/quality-repair.js +220 -0
  206. package/dist/rate-limit.d.ts +1 -0
  207. package/dist/rate-limit.js +5 -3
  208. package/dist/raw-archive.d.ts +1 -1
  209. package/dist/raw-archive.js +6 -16
  210. package/dist/recall-scope.d.ts +28 -5
  211. package/dist/recall-scope.js +52 -7
  212. package/dist/recall-trace.js +1 -1
  213. package/dist/reject-flow.d.ts +2 -0
  214. package/dist/reject-flow.js +20 -6
  215. package/dist/secret-detect.js +7 -8
  216. package/dist/server/auth.d.ts +8 -6
  217. package/dist/server/auth.js +85 -38
  218. package/dist/server/client-ip.d.ts +2 -0
  219. package/dist/server/client-ip.js +16 -1
  220. package/dist/server/mcp-http.d.ts +9 -3
  221. package/dist/server/mcp-http.js +56 -13
  222. package/dist/server/request.d.ts +1 -1
  223. package/dist/server/request.js +5 -5
  224. package/dist/server/routes/admin.js +12 -6
  225. package/dist/server/routes/memories.js +9 -1
  226. package/dist/server/routes/recall.js +14 -2
  227. package/dist/server/types.d.ts +42 -5
  228. package/dist/server/validation.d.ts +5 -2
  229. package/dist/server/validation.js +19 -2
  230. package/dist/server.d.ts +21 -4
  231. package/dist/server.js +207 -49
  232. package/dist/session-digest.d.ts +0 -1
  233. package/dist/session-digest.js +3 -12
  234. package/dist/session-owners.d.ts +7 -0
  235. package/dist/session-owners.js +54 -0
  236. package/dist/share-scrub.d.ts +3 -0
  237. package/dist/share-scrub.js +8 -0
  238. package/dist/shared.d.ts +1 -0
  239. package/dist/shared.js +26 -18
  240. package/dist/store/candidates.d.ts +14 -3
  241. package/dist/store/candidates.js +48 -26
  242. package/dist/store/conflicts.d.ts +15 -0
  243. package/dist/store/conflicts.js +68 -44
  244. package/dist/store/delete-and-batch.d.ts +25 -1
  245. package/dist/store/delete-and-batch.js +121 -69
  246. package/dist/store/entry-reads.d.ts +5 -6
  247. package/dist/store/entry-reads.js +24 -12
  248. package/dist/store/entry-row.d.ts +7 -10
  249. package/dist/store/entry-row.js +40 -18
  250. package/dist/store/entry-writes.d.ts +3 -3
  251. package/dist/store/entry-writes.js +9 -23
  252. package/dist/store/handoffs.d.ts +9 -6
  253. package/dist/store/handoffs.js +72 -59
  254. package/dist/store/index-and-stats.js +2 -2
  255. package/dist/store/open.d.ts +3 -1
  256. package/dist/store/open.js +3 -3
  257. package/dist/store/search-rows.d.ts +8 -5
  258. package/dist/store/search-rows.js +15 -18
  259. package/dist/store/sessions.d.ts +25 -6
  260. package/dist/store/sessions.js +61 -19
  261. package/dist/store/summaries.d.ts +1 -1
  262. package/dist/store/summaries.js +3 -18
  263. package/dist/store/tenant-lookup.d.ts +7 -3
  264. package/dist/store/tenant-lookup.js +9 -9
  265. package/dist/store-port.d.ts +13 -0
  266. package/dist/store-port.js +20 -0
  267. package/dist/version.d.ts +5 -1
  268. package/dist/version.js +5 -1
  269. package/dist/working-memory.js +1 -1
  270. package/dist/write-budget.d.ts +15 -0
  271. package/dist/write-budget.js +15 -0
  272. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  273. package/extensions/openclaw-plugin/package.json +1 -1
  274. package/openclaw.plugin.json +1 -1
  275. package/package.json +8 -4
@@ -0,0 +1,35 @@
1
+ import { type MemoryEntry } from './memory.js';
2
+ export declare const STOP_WORDS: Set<string>;
3
+ export type AutomaticMemoryDefect = 'too-short' | 'release-activity' | 'sentence-fragment' | 'possible-fragment' | 'subjectless-outcome' | 'raw-output' | 'too-vague' | 'no-specificity';
4
+ /** `reason` names a defect even when `accepted`: a possible fragment is stored and listed for review. */
5
+ export interface AutomaticMemoryAssessment {
6
+ readonly accepted: boolean;
7
+ readonly reason: AutomaticMemoryDefect | null;
8
+ }
9
+ /** Counts spaced words plus CJK letters without lowering mixed-script counts. */
10
+ export declare function substantiveWordCount(text: string): number;
11
+ /** Preserves the auditor's existing destructive release-noise classification. */
12
+ export declare function isReleaseCommitNoise(text: string): boolean;
13
+ /** Short preferences and rules carry a useful relationship even without a path or number. */
14
+ export declare function hasNoSpecificity(text: string): boolean;
15
+ /** Names automatic-capture defects without inventing missing context. */
16
+ export declare function assessAutomaticMemory(content: string): AutomaticMemoryAssessment;
17
+ export declare function isCertainReason(reason: AutomaticMemoryDefect | null): boolean;
18
+ /** The bar for sleep's derived memories and for repair: a defect the checks are sure of, else null. */
19
+ export declare function certainDefect(content: string): AutomaticMemoryDefect | null;
20
+ export declare const BUNDLE_HEADER: RegExp;
21
+ type Provenance = Pick<MemoryEntry, 'source' | 'confidence' | 'content' | 'extracted_from' | 'dag_level' | 'tags'>;
22
+ /** A row hippo wrote that no person vouched for: hippo's writers stamp observed or inferred, a person's edit or restore verified. */
23
+ export declare function isAutomaticEntry(entry: Provenance): boolean;
24
+ /** The audit's check: a person's row is never judged, and a bundle is flagged only when every part is, as reuse and recent context judge it. */
25
+ export declare function automaticDefect(entry: Provenance): AutomaticMemoryDefect | null;
26
+ /** Whether sleep may derive from a row or share it: a person's text and watch failures always, hippo's own text only without a certain defect. */
27
+ export declare function isReusable(entry: Provenance): boolean;
28
+ /** The person floor's fragment test, on trimmed text. */
29
+ export declare function isFragment(text: string): boolean;
30
+ /** Admission check for text no person typed: capture and compaction items. */
31
+ export declare function isContentWorthStoring(content: string): boolean;
32
+ /** Recent-context floor: rows hippo wrote meet the automatic check, a person's rows only the older, looser floor. */
33
+ export declare function isWorthSurfacing(entry: MemoryEntry): boolean;
34
+ export {};
35
+ //# sourceMappingURL=memory-quality.d.ts.map
@@ -0,0 +1,191 @@
1
+ import { COMPACTION_MEMORY_TAG, COMPACTION_SOURCE_PREFIX } from './memory.js';
2
+ import { heldTexts } from './same-text.js';
3
+ export const STOP_WORDS = new Set([
4
+ 'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
5
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'it',
6
+ 'this', 'that', 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'do',
7
+ 'did', 'does', 'has', 'had', 'have', 'will', 'would', 'could', 'should',
8
+ 'may', 'might', 'can', 'shall', 'we', 'i', 'you', 'they', 'he', 'she',
9
+ 'my', 'our', 'your', 'its', 'his', 'her', 'their', 'up', 'out', 'just',
10
+ 'also', 'then', 'than', 'some', 'all', 'any', 'each', 'very', 'too',
11
+ ]);
12
+ const CJK_LETTERS = /(?=\p{L})(?:\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana})/gu;
13
+ const CHAT_ACRONYMS = /^(?:TODO|FYI|LGTM|IIRC|IMO|IMHO|FWIW|TBD|BTW|ASAP|AFAIK|WIP|NB|PS)$/;
14
+ /** Counts spaced words plus CJK letters without lowering mixed-script counts. */
15
+ export function substantiveWordCount(text) {
16
+ const cjkLetterCount = (text.match(CJK_LETTERS) ?? []).length;
17
+ const spacedCount = text.toLowerCase().split(/\s+/).filter(w => w.length > 2 && !STOP_WORDS.has(w)).length;
18
+ return spacedCount + Math.floor(cjkLetterCount / 2);
19
+ }
20
+ /** Preserves the auditor's existing destructive release-noise classification. */
21
+ export function isReleaseCommitNoise(text) {
22
+ return /^(?:bump|release|prep|tag)\s+(?:to\s+)?v?\d+\.\d+/i.test(text)
23
+ || /^v?\d+\.\d+\.\d+\s*$/i.test(text)
24
+ || /^chore(?:\([^)]+\))?:\s*(?:release|bump|version|tag|prep)\b/i.test(text)
25
+ || /^(?:Merge branch|Merge pull request)\b/i.test(text)
26
+ || /^WIP\b/i.test(text);
27
+ }
28
+ /** Short preferences and rules carry a useful relationship even without a path or number. */
29
+ export function hasNoSpecificity(text) {
30
+ const domainAcronyms = (text.match(/\b[A-Z]{2,6}\b/g) ?? []).filter(a => !CHAT_ACRONYMS.test(a));
31
+ if (/\d|[A-Z][a-z]{2,}|[/\\.]|[`_{}()\[\]]/.test(text))
32
+ return false;
33
+ if (domainAcronyms.length > 0 && /[a-z]/.test(text))
34
+ return false;
35
+ if (/^(?:prefer|use|avoid|never use|don't use)\s+(?!(?:the|that|this|it|them|those|these|one|other|something)\b)\S+/i.test(text))
36
+ return false;
37
+ if (/\b(?:never|always|must)\s+(?!(?:the|that|this|it|them|those|these|one|other|something)\b)\S+/i.test(text))
38
+ return false;
39
+ return text.split(/\s+/).length < 8 && /^[\w\s,.'"-]+$/.test(text);
40
+ }
41
+ const RELATION_WORD = /\b(?:when|if|unless|because|since|after|before|until|while|so|instead)\b/i;
42
+ const RULE_WORD = /\b(?:never|always|must|don't|do not|make sure|ensure|prefer|avoid)\b/i;
43
+ function isRoutineReleaseActivity(text) {
44
+ if (isReleaseCommitNoise(text))
45
+ return true;
46
+ // "Deploy only after the migration has run" is a rule about releases, not a log line.
47
+ if (RELATION_WORD.test(text) || RULE_WORD.test(text))
48
+ return false;
49
+ const subject = text.replace(/^(?:chore|ci|build)(?:\([^)]*\))?:\s*/i, '');
50
+ if (/^(?:bump|increment|increase|update|set)\s+(?:(?:ios|android|app)\s+)?(?:build(?:\s+number)?|version|release)\s+(?:to\s+)?(?:v?\d|#\d)/i.test(subject))
51
+ return true;
52
+ return /^(?:deploy(?:ed|ing)?|ship(?:ped|ping)?|release(?:d|ing)?)\s+(?:the\s+)?(?:build|release|version|v?\d)\s*(?:v?\d|#\d|\.\d)/i.test(subject)
53
+ || /^(?:deployment|release|build)\s+(?:succeeded|completed|finished)\b/i.test(subject);
54
+ }
55
+ const bareText = (text) => text.replace(/[.!?,;:\s]+$/, '');
56
+ // These endings close whole sentences too ("the branch you push to"), so they are only a possible cut.
57
+ const POSSIBLE_CUT_ENDING = /\b(?:needs to|depends on|requires|while|with|without|into|for|of|by|to|is|are|was|were|must|should|could|would|might)$/i;
58
+ /** A short leading condition with no rule word, comma or "then": "If the build fails". */
59
+ function isBareCondition(bare) {
60
+ return !/[,;]|\bthen\b/i.test(bare) && !RULE_WORD.test(bare) && bare.split(/\s+/).length <= 6;
61
+ }
62
+ function isDanglingAssertion(text) {
63
+ const bare = bareText(text);
64
+ // Only a lowercase start is a cut: "To rebuild the index, run ..." opens an instruction.
65
+ if (/^(?:to|and|for)\s/.test(bare) && bare.length < 50)
66
+ return true;
67
+ // Prepositions, modals and "is" can end a whole sentence ("the branch it merges into"), so only cut-off endings count.
68
+ if (/\b(?:to be|has been|will be|instead of|rather than|such as|than|because|if|unless|when|until|and|or|but)$/i.test(bare) || /\b(?:the|a|an)$/.test(bare))
69
+ return true;
70
+ return /^(?:if|unless|when|while|until)\b/.test(bare) && isBareCondition(bare);
71
+ }
72
+ /** A capitalised short condition may carry its rule with no comma: "When CI fails we retry once". */
73
+ function isPossibleFragment(text) {
74
+ const bare = bareText(text);
75
+ return POSSIBLE_CUT_ENDING.test(bare) || (/^(?:If|Unless|When|While|Until)\b/.test(bare) && isBareCondition(bare));
76
+ }
77
+ const OUTCOME_LEAD = /^(?:succeeds?|succeeded|fails?|failed|passes|passed|completed|done|successful|success|returned|returns|inserted|updated|deleted)\b/i;
78
+ const OUTCOME_DETAIL = /\b(?:for|on|in|at|to|from|with|without|by|after|before|under|is|are|was|were|be|been|will|can|must|should|never|always)\b/i;
79
+ function isSubjectlessOutcome(text) {
80
+ const lead = OUTCOME_LEAD.exec(text);
81
+ if (lead === null || RELATION_WORD.test(text))
82
+ return false;
83
+ // "Returns 404 for unknown ids" and "Deleted rows must stay deleted" say when or what; "returned 3 matching rows" does not.
84
+ return !OUTCOME_DETAIL.test(text.slice(lead[0].length).replace(/\([^)]*\)/g, ' '));
85
+ }
86
+ const OUTPUT_LINE = /^(?:\$\s|>\s|(?:stdout|stderr|exit code):|Command\s+['"].*\s+failed\b|Traceback\b|npm\s+(?:ERR|WARN)\b|error\s+TS\d+\b)/i;
87
+ const STACK_FRAME = /^at\s+\S.*:\d+(?::\d+)?\)?$/;
88
+ const ERROR_LEAD = /^(?:[\w.$]+Error|[\w.$]+Exception):\s/;
89
+ // Log lines say "after" and "while" too, so only a rule word frees a timestamp or level lead: a dated freeze saying never merge.
90
+ const LOG_LEADS = [
91
+ /^\d{4}-\d{2}-\d{2}[T ][\d:.]+(?:Z|[+-][\d:]+)?\s+(?:\[?\w+\]?\s+)?/,
92
+ /^\[(?:info|warn|error|debug)\]/i,
93
+ ];
94
+ const DATA_LEADS = [/^\{\s*["']?[\w$-]+["']?\s*:/, /^\[\s*(?:[{["]|-?\d[\d.]*\s*[,\]])/];
95
+ /** Prose after the last closing bracket: "{ retries: 3 } is the default policy". */
96
+ function hasProseAfterData(text) {
97
+ const tail = text.slice(Math.max(text.lastIndexOf('}'), text.lastIndexOf(']')) + 1);
98
+ return (tail.match(/\p{L}{2,}/gu) ?? []).length >= 2;
99
+ }
100
+ function isRawOutput(text) {
101
+ if (OUTPUT_LINE.test(text) || STACK_FRAME.test(text))
102
+ return true;
103
+ if (ERROR_LEAD.test(text))
104
+ return !RELATION_WORD.test(text) && !RULE_WORD.test(text);
105
+ if (LOG_LEADS.some((lead) => lead.test(text)))
106
+ return !RULE_WORD.test(text);
107
+ return DATA_LEADS.some((lead) => lead.test(text)) && !hasProseAfterData(text);
108
+ }
109
+ /** Names automatic-capture defects without inventing missing context. */
110
+ export function assessAutomaticMemory(content) {
111
+ const text = content.trim();
112
+ let reason = null;
113
+ if (text.length < 10)
114
+ reason = 'too-short';
115
+ else if (isRoutineReleaseActivity(text))
116
+ reason = 'release-activity';
117
+ else if (isRawOutput(text))
118
+ reason = 'raw-output';
119
+ else if (isDanglingAssertion(text))
120
+ reason = 'sentence-fragment';
121
+ else if (isSubjectlessOutcome(text))
122
+ reason = 'subjectless-outcome';
123
+ else if (isPossibleFragment(text))
124
+ reason = 'possible-fragment';
125
+ else if (substantiveWordCount(text) < 2)
126
+ reason = 'too-vague';
127
+ else if (text.length < 40 && hasNoSpecificity(text))
128
+ reason = 'no-specificity';
129
+ return { accepted: reason === null || reason === 'possible-fragment', reason };
130
+ }
131
+ /** Reasons the heuristics can get wrong: repair only lists them for review and derivation still admits them. */
132
+ const UNCERTAIN_REASONS = new Set(['too-short', 'possible-fragment', 'too-vague', 'no-specificity']);
133
+ export function isCertainReason(reason) {
134
+ return reason !== null && !UNCERTAIN_REASONS.has(reason);
135
+ }
136
+ /** The bar for sleep's derived memories and for repair: a defect the checks are sure of, else null. */
137
+ export function certainDefect(content) {
138
+ const { reason } = assessAutomaticMemory(content);
139
+ return isCertainReason(reason) ? reason : null;
140
+ }
141
+ // Watch failures ('autolearn') and tool failures keep a fixed "Command 'x' failed" format by design, so they are not judged.
142
+ const AUTOMATIC_SOURCES = new Set(['capture', 'git-learn', 'git', 'consolidation']);
143
+ // Promote and share rewrite the source but keep the tags, so the writer's tag still marks a copy; elsewhere a person may set it.
144
+ const AUTOMATIC_TAGS = new Set(['captured', COMPACTION_MEMORY_TAG, 'git-learned']);
145
+ const COPY_SOURCE = /^(?:promoted|shared):/;
146
+ export const BUNDLE_HEADER = /^\[Consolidated(?: from| pattern from) \d+ related memor(?:y|ies)(?:, newest first)?\]\n\n/;
147
+ /** A row hippo wrote that no person vouched for: hippo's writers stamp observed or inferred, a person's edit or restore verified. */
148
+ export function isAutomaticEntry(entry) {
149
+ if (entry.confidence !== 'observed' && entry.confidence !== 'inferred')
150
+ return false;
151
+ return AUTOMATIC_SOURCES.has(entry.source) || entry.source.startsWith(COMPACTION_SOURCE_PREFIX)
152
+ || (COPY_SOURCE.test(entry.source) && entry.tags.some((tag) => AUTOMATIC_TAGS.has(tag)))
153
+ || entry.extracted_from !== null || entry.dag_level >= 1 || BUNDLE_HEADER.test(entry.content);
154
+ }
155
+ /** A bundle's parts are other rows' words, possibly a person's, so a bundle is sure to be junk only when every part is. */
156
+ function bundleParts(entry) {
157
+ return BUNDLE_HEADER.test(entry.content) ? heldTexts({ content: entry.content, source: 'consolidation' }) : [];
158
+ }
159
+ function hasCertainDefect(entry) {
160
+ const parts = bundleParts(entry);
161
+ return (parts.length > 0 ? parts : [entry.content]).every((text) => certainDefect(text) !== null);
162
+ }
163
+ /** The audit's check: a person's row is never judged, and a bundle is flagged only when every part is, as reuse and recent context judge it. */
164
+ export function automaticDefect(entry) {
165
+ if (!isAutomaticEntry(entry))
166
+ return null;
167
+ const parts = bundleParts(entry);
168
+ const reasons = (parts.length > 0 ? parts : [entry.content.trim()]).map((text) => assessAutomaticMemory(text).reason);
169
+ return reasons.every((reason) => reason !== null) ? reasons[0] ?? null : null;
170
+ }
171
+ /** Whether sleep may derive from a row or share it: a person's text and watch failures always, hippo's own text only without a certain defect. */
172
+ export function isReusable(entry) {
173
+ return !isAutomaticEntry(entry) || !hasCertainDefect(entry);
174
+ }
175
+ /** The person floor's fragment test, on trimmed text. */
176
+ export function isFragment(text) {
177
+ return /^(?:to|for|and) /.test(text) && text.length < 50;
178
+ }
179
+ /** Admission check for text no person typed: capture and compaction items. */
180
+ export function isContentWorthStoring(content) {
181
+ return assessAutomaticMemory(content).accepted;
182
+ }
183
+ /** Recent-context floor: rows hippo wrote meet the automatic check, a person's rows only the older, looser floor. */
184
+ export function isWorthSurfacing(entry) {
185
+ if (isAutomaticEntry(entry))
186
+ return bundleParts(entry).length > 0 ? !hasCertainDefect(entry) : isContentWorthStoring(entry.content);
187
+ const text = entry.content.trim();
188
+ return text.length >= 10 && !isReleaseCommitNoise(text) && !isFragment(text)
189
+ && substantiveWordCount(text) >= 2 && !(text.length < 40 && hasNoSpecificity(text));
190
+ }
191
+ //# sourceMappingURL=memory-quality.js.map
package/dist/memory.d.ts CHANGED
@@ -87,10 +87,10 @@ export interface MemoryEntry {
87
87
  /**
88
88
  * Memory scope isolation: owning project for ambient-context
89
89
  * partitioning. A lowercased project name, '' for user-global (injectable
90
- * everywhere), or null for legacy rows written before the column - ambient
91
- * context treats null as other-project (deny). Stamped from the store's location
92
- * at write time (store.ts stampOriginProject); undefined only on entries not yet
93
- * written.
90
+ * everywhere), or null for no known project (a legacy row, or a shared-store
91
+ * write that named none) - ambient context treats null as other-project (deny).
92
+ * Stamped at write time by store/entry-row.ts stampOriginProject via fallbackOrigin;
93
+ * undefined only on entries not yet written.
94
94
  */
95
95
  origin_project?: string | null;
96
96
  /**
@@ -100,7 +100,7 @@ export function savePhysicsState(db, particles) {
100
100
  last_simulation = excluded.last_simulation,
101
101
  updated_at = datetime('now')
102
102
  `);
103
- db.exec('BEGIN');
103
+ db.exec('BEGIN IMMEDIATE');
104
104
  try {
105
105
  for (const p of particles) {
106
106
  stmt.run(p.memoryId, float32ToBuffer(p.position), float32ToBuffer(p.velocity), p.mass, p.charge, p.temperature, p.lastSimulation);
@@ -1,9 +1,20 @@
1
1
  import { type DatabaseSyncLike } from './db.js';
2
+ import { type LedgerRootOpts } from './ledger-db.js';
2
3
  export type PilotArm = 'hippo' | 'holdout';
4
+ export interface SessionArmOpts extends LedgerRootOpts {
5
+ /** Only the caller tenant's arm row counts, so a caller holding another tenant's session id cannot claim that session's arm. */
6
+ readonly ownTenantOnly?: boolean;
7
+ }
3
8
  /** Deterministic split: the same session and rate always land in the same arm. */
4
9
  export declare function hashArm(sessionId: string, rateBp: number): PilotArm;
5
- /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
6
- export declare function readPilotArm(db: DatabaseSyncLike, sessionId: string): PilotArm | null;
10
+ /** The session's first stored arm, or null; never writes. Without `tenantId` any tenant's row counts, since on one machine a session has one arm. */
11
+ export declare function readPilotArm(db: DatabaseSyncLike, sessionId: string, tenantId?: string): PilotArm | null;
7
12
  /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
8
- export declare function ensurePilotArm(db: DatabaseSyncLike, tenantId: string, sessionId: string, rateBp: number, now?: string): PilotArm;
13
+ export declare function ensurePilotArm(db: DatabaseSyncLike, tenantId: string, sessionId: string, rateBp: number, opts?: {
14
+ ownTenantOnly?: boolean;
15
+ now?: string;
16
+ }): PilotArm;
17
+ /** The session's arm, or null at rate 0, with no session id, or with no store.
18
+ * `write` books the arm row; a read-only caller (env-only id, sub-agent) follows the stored arm, else the hash. */
19
+ export declare function sessionPilotArm(hippoRoot: string, tenantId: string, sessionId: string | undefined, write: boolean, opts?: SessionArmOpts): PilotArm | null;
9
20
  //# sourceMappingURL=pilot-arm.d.ts.map
package/dist/pilot-arm.js CHANGED
@@ -1,34 +1,39 @@
1
1
  // Pilot arm: one token_ledger row per session names its arm, `hippo` or `holdout`, so a pilot can compare them.
2
2
  // `items` holds the holdout rate in basis points; readers outside this repo depend on these rows, so their shape is fixed.
3
3
  import { createHash } from 'node:crypto';
4
- import { execWithBusyRetry, HOOK_DB_WAIT_MS } from './db.js';
4
+ import { loadConfig } from './config.js';
5
+ import { execWithBusyRetry, HOOK_DB_WAIT_MS, scopedBusyWait } from './db.js';
6
+ import { ledgerRoot, withLedgerDb } from './ledger-db.js';
5
7
  import { recordTokenUse } from './token-ledger.js';
6
8
  /** Deterministic split: the same session and rate always land in the same arm. */
7
9
  export function hashArm(sessionId, rateBp) {
8
10
  const bucket = parseInt(createHash('sha256').update(sessionId).digest('hex').slice(0, 8), 16) % 10000;
9
11
  return bucket < rateBp ? 'holdout' : 'hippo';
10
12
  }
11
- /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
12
- export function readPilotArm(db, sessionId) {
13
+ /** The session's first stored arm, or null; never writes. Without `tenantId` any tenant's row counts, since on one machine a session has one arm. */
14
+ export function readPilotArm(db, sessionId, tenantId) {
15
+ const params = tenantId === undefined ? [sessionId] : [sessionId, tenantId];
13
16
  // SAFETY: the SELECT names exactly this one column.
14
- const row = db.prepare(`SELECT block_hash FROM token_ledger WHERE session_id = ? AND surface = 'pilot' AND event = 'arm' ORDER BY id LIMIT 1`).get(sessionId);
17
+ const row = db.prepare(`SELECT block_hash FROM token_ledger WHERE session_id = ?${tenantId === undefined ? '' : ' AND tenant_id = ?'}
18
+ AND surface = 'pilot' AND event = 'arm' ORDER BY id LIMIT 1`).get(...params);
15
19
  return row?.block_hash === 'holdout' || row?.block_hash === 'hippo' ? row.block_hash : null;
16
20
  }
17
21
  /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
18
- // The hook's lock-wait bound holds only on a handle opened with `busyWaitMs: HOOK_DB_WAIT_MS`, as hook commands are.
19
- export function ensurePilotArm(db, tenantId, sessionId, rateBp, now) {
22
+ // The wait is the caller's lock-wait scope (a hook's 1 s, a server request's 250 ms); it holds on a handle opened in that scope.
23
+ export function ensurePilotArm(db, tenantId, sessionId, rateBp, opts = {}) {
20
24
  const hashed = hashArm(sessionId, rateBp);
25
+ const readTenant = opts.ownTenantOnly ? tenantId : undefined;
21
26
  let began = false;
22
27
  try {
23
28
  // A stored row is the common case after the first prompt, so it must not take the write lock.
24
- const existing = readPilotArm(db, sessionId);
29
+ const existing = readPilotArm(db, sessionId, readTenant);
25
30
  if (existing !== null)
26
31
  return existing;
27
- execWithBusyRetry(db, 'BEGIN IMMEDIATE', HOOK_DB_WAIT_MS);
32
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', scopedBusyWait() ?? HOOK_DB_WAIT_MS);
28
33
  began = true;
29
- const stored = readPilotArm(db, sessionId);
34
+ const stored = readPilotArm(db, sessionId, readTenant);
30
35
  if (stored === null) {
31
- recordTokenUse(db, { tenantId, sessionId, surface: 'pilot', event: 'arm', items: rateBp, tokens: 0, hash: hashed, now });
36
+ recordTokenUse(db, { tenantId, sessionId, surface: 'pilot', event: 'arm', items: rateBp, tokens: 0, hash: hashed, now: opts.now });
32
37
  }
33
38
  db.exec('COMMIT');
34
39
  return stored ?? hashed;
@@ -44,4 +49,21 @@ export function ensurePilotArm(db, tenantId, sessionId, rateBp, now) {
44
49
  return hashed;
45
50
  }
46
51
  }
52
+ /** The session's arm, or null at rate 0, with no session id, or with no store.
53
+ * `write` books the arm row; a read-only caller (env-only id, sub-agent) follows the stored arm, else the hash. */
54
+ export function sessionPilotArm(hippoRoot, tenantId, sessionId, write, opts = {}) {
55
+ if (sessionId === undefined || sessionId.trim() === '')
56
+ return null;
57
+ const root = ledgerRoot(hippoRoot, opts);
58
+ if (root === null)
59
+ return null;
60
+ const rate = loadConfig(root).pilot.holdoutRateBp;
61
+ if (rate <= 0)
62
+ return null;
63
+ const { ownTenantOnly } = opts;
64
+ const arm = withLedgerDb(hippoRoot, (db) => write
65
+ ? ensurePilotArm(db, tenantId, sessionId, rate, { ownTenantOnly })
66
+ : readPilotArm(db, sessionId, ownTenantOnly ? tenantId : undefined) ?? hashArm(sessionId, rate), opts);
67
+ return arm ?? hashArm(sessionId, rate);
68
+ }
47
69
  //# sourceMappingURL=pilot-arm.js.map
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { detectRealCodexPath, isCodexWrapperInstalled, repairCodexWrapperIfInstalled } from './hooks/codex-wrapper.js';
6
+ import { claudeConfigDir } from './hooks/shared.js';
6
7
  function main() {
7
8
  if (envSkipPostinstall())
8
9
  return;
@@ -39,8 +40,7 @@ function main() {
39
40
  * middle ground.
40
41
  */
41
42
  function printClaudeCodeNudge() {
42
- const home = os.homedir();
43
- const claudeDir = path.join(home, '.claude');
43
+ const claudeDir = claudeConfigDir(os.homedir());
44
44
  if (!fs.existsSync(claudeDir))
45
45
  return; // Claude Code not installed — silent
46
46
  const settingsPath = path.join(claudeDir, 'settings.json');
@@ -24,7 +24,7 @@ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
24
24
  import { openHippoDb, closeHippoDb } from './db.js';
25
25
  import { writeEntry } from './store/entry-writes.js';
26
26
  import { assertTenantId } from './tenant.js';
27
- import { RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
27
+ import { scopeAdmitSql } from './recall-scope.js';
28
28
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph/write.js';
29
29
  import { createMemory, Layer } from './memory.js';
30
30
  import { appendAuditEvent } from './audit.js';
@@ -388,7 +388,7 @@ function receiptHeadline(content) {
388
388
  function loadBriefReceipts(hippoRoot, tenantId, normalizedRepo) {
389
389
  const tag = `path:${normalizedRepo.toLowerCase()}`;
390
390
  const likeParam = `%"${escapeLike(tag)}"%`;
391
- const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
391
+ const deny = scopeAdmitSql('');
392
392
  const db = openHippoDb(hippoRoot);
393
393
  try {
394
394
  // SAFETY: SELECT projects exactly id, created, source, content (the
@@ -398,10 +398,10 @@ function loadBriefReceipts(hippoRoot, tenantId, normalizedRepo) {
398
398
  WHERE tenant_id = ?
399
399
  AND source != 'project_brief'
400
400
  AND LOWER(tags_json) LIKE ? ESCAPE '\\'
401
- AND (scope IS NULL OR (scope NOT IN (${denyPlaceholders}) AND scope NOT LIKE '%:private:%'))
401
+ AND ${deny.sql}
402
402
  ORDER BY created DESC, id DESC
403
403
  LIMIT ?
404
- `).all(tenantId, likeParam, ...RECALL_DEFAULT_DENY_SCOPES, MAX_BRIEF_RECEIPTS);
404
+ `).all(tenantId, likeParam, ...deny.params, MAX_BRIEF_RECEIPTS);
405
405
  }
406
406
  finally {
407
407
  closeHippoDb(db);
@@ -60,15 +60,22 @@ export type ProjectRef = string | Pick<ProjectIdentity, 'name' | 'legacyName' |
60
60
  export declare function projectId(project: ProjectRef): string;
61
61
  /** Every origin_project value the reader's own rows carry: the id first, then each alias and the legacy name. */
62
62
  export declare function projectNames(project: ProjectRef): readonly string[];
63
+ export declare const MAX_PROJECT_ALIASES = 10;
64
+ export declare const MCP_PROJECT_SCOPED_HEADER = "X-Hippo-Project-Scoped";
65
+ /** Refuses a caller's project that is blank (it would stamp user-global), past the caps, or unlike the resolver's ids: rows match verbatim, so a rewrite would split a project. Inner spaces pass, since a checkout with no remote is named by its folder. */
66
+ export declare function assertCallerProject(project: {
67
+ readonly name: string;
68
+ readonly aliases?: readonly string[];
69
+ }): void;
63
70
  /** `origin_project IN (?, ...)` with one placeholder per name; an empty list matches nothing. */
64
71
  export declare function originInSql(names: readonly string[], column?: string): string;
65
72
  /**
66
73
  * v39 memory scope isolation: classify a memory's origin_project against the
67
74
  * active project. An empty current id means the session is not in a project
68
75
  * (home dir or markerless cwd) - everything is in scope there, matching
69
- * pre-isolation behavior. NULL/undefined origin is a legacy pre-v39 row and
70
- * is treated as cross-project (deny by default) - the safe direction for a
71
- * security partition.
76
+ * pre-isolation behavior. NULL/undefined origin means no known project (a legacy
77
+ * row, or a shared-store write that named none) and is treated as cross-project
78
+ * (deny by default) - the safe direction for a security partition.
72
79
  */
73
80
  export declare function classifyOriginProject(origin: string | null | undefined, current: ProjectRef): 'project' | 'user-global' | 'cross-project';
74
81
  /**
@@ -94,12 +101,9 @@ export declare function isGlobalStoreRoot(p: string): boolean;
94
101
  * origin evidence. Pure string logic - recorded paths may no longer exist.
95
102
  */
96
103
  export declare function originFromSource(source: string | null | undefined, homeName?: string): string | null;
97
- /**
98
- * The origin project to stamp on a memory written from cwd.
99
- * Returns the project name, or '' for user-global (written at/under home or
100
- * in a markerless directory) - injectable everywhere. Write sites must always
101
- * persist this value; a NULL origin_project column is reserved for legacy
102
- * pre-migration rows, which ambient context treats as deny.
103
- */
104
+ /** The origin to stamp on a memory written from cwd: its project name, or '' (user-global, injectable everywhere) outside a project.
105
+ * NULL means no known project: a legacy row with no evidence, or a write to a shared store that named none; ambient context treats it as deny. */
104
106
  export declare function deriveOriginProject(cwd?: string, opts?: ResolveProjectIdentityOpts): string;
107
+ /** The origin for a write that names none: NULL on a shared store, whose folder is no caller's project, else the folder's project. */
108
+ export declare function fallbackOrigin(hippoRoot: string): string | null;
105
109
  //# sourceMappingURL=project-identity.d.ts.map
@@ -2,7 +2,9 @@ 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
+ import { BadRequestError } from './api-errors.js';
6
+ import { isSharedStore, loadConfig } from './config.js';
7
+ import { MAX_ID_LEN } from './http-util.js';
6
8
  import { log } from './log.js';
7
9
  import { originRemoteId, projectFileId } from './project-remote.js';
8
10
  const MAX_WALK_DEPTH = 64;
@@ -179,6 +181,23 @@ export function projectNames(project) {
179
181
  return [projectId(project)];
180
182
  return [...new Set([project.name, ...(project.aliases ?? []), project.legacyName])].filter((n) => n !== '');
181
183
  }
184
+ // Each project name is matched against every candidate row, so the caller's list stays short.
185
+ export const MAX_PROJECT_ALIASES = 10;
186
+ export const MCP_PROJECT_SCOPED_HEADER = 'X-Hippo-Project-Scoped';
187
+ /** Refuses a caller's project that is blank (it would stamp user-global), past the caps, or unlike the resolver's ids: rows match verbatim, so a rewrite would split a project. Inner spaces pass, since a checkout with no remote is named by its folder. */
188
+ export function assertCallerProject(project) {
189
+ const { name, aliases = [] } = project;
190
+ if (name.trim() === '')
191
+ throw new BadRequestError('project name must not be blank');
192
+ if (aliases.length > MAX_PROJECT_ALIASES)
193
+ throw new BadRequestError(`project aliases: at most ${MAX_PROJECT_ALIASES}`);
194
+ if ([name, ...aliases].some((n) => n.length > MAX_ID_LEN)) {
195
+ throw new BadRequestError(`project names: at most ${MAX_ID_LEN} characters each`);
196
+ }
197
+ if ([name, ...aliases].some((n) => n !== n.trim().toLowerCase() || /[:\p{Cc}]/u.test(n))) {
198
+ throw new BadRequestError('project names: lowercase, not padded, with no colon or control character');
199
+ }
200
+ }
182
201
  /** `origin_project IN (?, ...)` with one placeholder per name; an empty list matches nothing. */
183
202
  export function originInSql(names, column = 'origin_project') {
184
203
  return names.length === 0 ? '0' : `${column} IN (${names.map(() => '?').join(', ')})`;
@@ -187,9 +206,9 @@ export function originInSql(names, column = 'origin_project') {
187
206
  * v39 memory scope isolation: classify a memory's origin_project against the
188
207
  * active project. An empty current id means the session is not in a project
189
208
  * (home dir or markerless cwd) - everything is in scope there, matching
190
- * pre-isolation behavior. NULL/undefined origin is a legacy pre-v39 row and
191
- * is treated as cross-project (deny by default) - the safe direction for a
192
- * security partition.
209
+ * pre-isolation behavior. NULL/undefined origin means no known project (a legacy
210
+ * row, or a shared-store write that named none) and is treated as cross-project
211
+ * (deny by default) - the safe direction for a security partition.
193
212
  */
194
213
  export function classifyOriginProject(origin, current) {
195
214
  if (projectId(current) === '')
@@ -252,14 +271,13 @@ export function originFromSource(source, homeName) {
252
271
  }
253
272
  return null;
254
273
  }
255
- /**
256
- * The origin project to stamp on a memory written from cwd.
257
- * Returns the project name, or '' for user-global (written at/under home or
258
- * in a markerless directory) - injectable everywhere. Write sites must always
259
- * persist this value; a NULL origin_project column is reserved for legacy
260
- * pre-migration rows, which ambient context treats as deny.
261
- */
274
+ /** The origin to stamp on a memory written from cwd: its project name, or '' (user-global, injectable everywhere) outside a project.
275
+ * NULL means no known project: a legacy row with no evidence, or a write to a shared store that named none; ambient context treats it as deny. */
262
276
  export function deriveOriginProject(cwd, opts) {
263
277
  return resolveProjectIdentity(cwd, opts).name;
264
278
  }
279
+ /** The origin for a write that names none: NULL on a shared store, whose folder is no caller's project, else the folder's project. */
280
+ export function fallbackOrigin(hippoRoot) {
281
+ return isSharedStore(hippoRoot) ? null : deriveOriginProject(path.dirname(path.resolve(hippoRoot)));
282
+ }
265
283
  //# sourceMappingURL=project-identity.js.map
@@ -46,8 +46,6 @@ export interface RepairResult {
46
46
  }
47
47
  /** Live rows per project name, newest write first, with how many of its imported notes are copies held elsewhere. */
48
48
  export declare function listProjects(db: DatabaseSyncLike, tenantId: string): ProjectSummary[];
49
- /** Copies the database before a repair writes, so the audit ids plus this file are the way back. */
50
- export declare function backupStore(db: DatabaseSyncLike, hippoRoot: string, label: string, now?: Date): string;
51
49
  /** Refuses user-global and unknown: they are not projects, and folding them would leak or hide every row. */
52
50
  export declare function validateMergeNames(from: string, into: string): string | null;
53
51
  /** Folds project `from` into `into` for one tenant in one transaction; a dry run rolls back and writes nothing, mirrors included. */
@@ -6,6 +6,8 @@ import { transcriptNotesProject } from './agent-memories/claude-code.js';
6
6
  import { containerId, containerPrefix } from './agent-memories/source.js';
7
7
  import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
8
8
  import { appendAuditEvent, queryAuditEvents } from './audit.js';
9
+ import { isSharedStore } from './config.js';
10
+ import { withBackup } from './db/backup.js';
9
11
  import { getMeta, setMeta } from './db/meta.js';
10
12
  import { insertDormantRow, listDormantSnapshots, replaceDormantEntry } from './dormant.js';
11
13
  import { processEnv } from './env.js';
@@ -47,14 +49,6 @@ export function listProjects(db, tenantId) {
47
49
  };
48
50
  }).sort((a, b) => b.newest.localeCompare(a.newest));
49
51
  }
50
- /** Copies the database before a repair writes, so the audit ids plus this file are the way back. */
51
- export function backupStore(db, hippoRoot, label, now = new Date()) {
52
- const dir = path.join(hippoRoot, 'backups');
53
- fs.mkdirSync(dir, { recursive: true });
54
- const file = path.join(dir, `hippo-${label}-${now.toISOString().replace(/[:.]/g, '-')}.db`);
55
- db.prepare('VACUUM INTO ?').run(file);
56
- return file;
57
- }
58
52
  function inTransaction(db, dryRun, body) {
59
53
  db.exec(dryRun ? 'BEGIN' : 'BEGIN IMMEDIATE');
60
54
  try {
@@ -84,12 +78,12 @@ export function mergeProjects(db, hippoRoot, opts) {
84
78
  if (refusal)
85
79
  throw new Error(refusal);
86
80
  const { tenantId, from, into, dryRun } = opts;
87
- const backup = dryRun ? null : backupStore(db, hippoRoot, 'before-merge');
88
- const result = inTransaction(db, dryRun, () => {
81
+ const fold = (backup) => inTransaction(db, dryRun, () => {
89
82
  const folded = foldInTx(db, tenantId, from, into);
90
83
  appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_merge', metadata: { from, into, backup, ...folded } });
91
84
  return { from, into, ...folded, backup };
92
85
  });
86
+ const result = dryRun ? fold(null) : withBackup(db, hippoRoot, 'before-merge', fold);
93
87
  if (!dryRun)
94
88
  refreshMirrors(db, hippoRoot, tenantId, result.restamped, result.setAside);
95
89
  return result;
@@ -203,6 +197,9 @@ function planFolds(db, hippoRoot, tenantId, globalFolds) {
203
197
  }
204
198
  /** A project store's rows, dormant snapshots and compaction records written before its id existed carry its folder name. */
205
199
  function ownLegacyFold(db, hippoRoot, tenantId) {
200
+ // A shared store's rows carry their callers' names, so one matching its folder's is a member's project, not a legacy tag.
201
+ if (isSharedStore(hippoRoot))
202
+ return [];
206
203
  const { name, legacyName } = resolveProjectIdentity(path.dirname(path.resolve(hippoRoot)));
207
204
  if (legacyName === '' || legacyName === name)
208
205
  return [];
@@ -278,8 +275,7 @@ export function repairProjects(db, hippoRoot, opts) {
278
275
  const { tenantId, dryRun, globalFolds = true } = opts;
279
276
  if (dryRun)
280
277
  return { ...planProjectRepair(db, hippoRoot, tenantId, globalFolds), backup: null };
281
- const backup = backupStore(db, hippoRoot, 'before-repair');
282
- const { result, rewrite, purge } = inTransaction(db, false, () => {
278
+ const { result, rewrite, purge } = withBackup(db, hippoRoot, 'before-repair', (backup) => inTransaction(db, false, () => {
283
279
  const copies = [];
284
280
  for (const row of strayImports(db, hippoRoot, tenantId)) {
285
281
  const tag = toolTag(row.source);
@@ -304,7 +300,7 @@ export function repairProjects(db, hippoRoot, opts) {
304
300
  rewrite: [...plan.toProject.map((r) => r.id), ...folded.flatMap((f) => f.restamped)],
305
301
  purge: [...copies, ...plan.setAside, ...folded.flatMap((f) => f.setAside)],
306
302
  };
307
- });
303
+ }));
308
304
  refreshMirrors(db, hippoRoot, tenantId, rewrite, purge);
309
305
  return result;
310
306
  }