pi-mega-compact 0.13.4 → 0.13.6

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 (217) hide show
  1. package/README.md +11 -8
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints/registry.js +50 -0
  3. package/dist/extensions/dashboard-server/api-contracts/rag-metrics.js +1 -0
  4. package/dist/extensions/dashboard-server/api-contracts/rag-settings.js +3 -3
  5. package/dist/extensions/dashboard-server/api-contracts/wiki.js +1 -0
  6. package/dist/extensions/dashboard-server/routes-rag-metrics.js +60 -0
  7. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +167 -0
  8. package/dist/extensions/dashboard-server/routes-rag-settings.js +136 -77
  9. package/dist/extensions/dashboard-server/routes-wiki-helpers.js +97 -0
  10. package/dist/extensions/dashboard-server/routes-wiki.js +330 -0
  11. package/dist/extensions/dashboard-server/routes.js +2 -0
  12. package/dist/extensions/dashboard-server/server.js +17 -3
  13. package/dist/extensions/mega-events/context-handler/afterCompact.js +35 -2
  14. package/dist/extensions/mega-pipeline/recall.js +31 -0
  15. package/dist/extensions/mega-turn-store.js +2 -1
  16. package/dist/src/config/turns.js +10 -0
  17. package/dist/src/config.js +2 -0
  18. package/dist/src/recall/async.js +10 -1
  19. package/dist/src/recall/format.js +10 -1
  20. package/dist/src/recall/hydeTelemetry.js +22 -0
  21. package/dist/src/recall/sync.js +28 -2
  22. package/dist/src/store/turns/hydeStore.js +32 -0
  23. package/dist/src/store/turns/index.js +1 -0
  24. package/dist/src/store/turns/schema.js +102 -1
  25. package/dist/src/store/turns/sqlite-store/rows.js +23 -0
  26. package/dist/src/store/turns/sqlite-store/write.js +7 -3
  27. package/dist/src/topics/durability.js +187 -0
  28. package/dist/src/topics/index.js +2 -1
  29. package/dist/src/topics/store.js +28 -9
  30. package/dist/src/wiki/curation-helpers.js +71 -0
  31. package/dist/src/wiki/curation.js +147 -0
  32. package/dist/src/wiki/incremental.js +126 -0
  33. package/dist/src/wiki/index.js +5 -0
  34. package/extensions/dashboard-client/dist/assets/AreaChart-KAmp_6bL.js +70 -0
  35. package/extensions/dashboard-client/dist/assets/{SessionsTab-C30AwyoF.js.map → AreaChart-KAmp_6bL.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/CacheTab-Bk-gm-R0.js +2 -0
  37. package/extensions/dashboard-client/dist/assets/CacheTab-Bk-gm-R0.js.map +1 -0
  38. package/extensions/dashboard-client/dist/assets/EventsTab-Cyylo0of.js +2 -0
  39. package/extensions/dashboard-client/dist/assets/EventsTab-Cyylo0of.js.map +1 -0
  40. package/extensions/dashboard-client/dist/assets/HealthTab-D-IzmV29.js +2 -0
  41. package/extensions/dashboard-client/dist/assets/HealthTab-D-IzmV29.js.map +1 -0
  42. package/extensions/dashboard-client/dist/assets/MaintenanceTab-CrlkCFVj.js +2 -0
  43. package/extensions/dashboard-client/dist/assets/MaintenanceTab-CrlkCFVj.js.map +1 -0
  44. package/extensions/dashboard-client/dist/assets/MemoryMapTab-DHUdZ7RR.js +2 -0
  45. package/extensions/dashboard-client/dist/assets/MemoryMapTab-DHUdZ7RR.js.map +1 -0
  46. package/extensions/dashboard-client/dist/assets/MetricsTab-B7I4HWVg.js +2 -0
  47. package/extensions/dashboard-client/dist/assets/MetricsTab-B7I4HWVg.js.map +1 -0
  48. package/extensions/dashboard-client/dist/assets/OverviewTab-K6j4ZI7u.js +2 -0
  49. package/extensions/dashboard-client/dist/assets/OverviewTab-K6j4ZI7u.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/ReposTab-BSRWhn56.js +2 -0
  51. package/extensions/dashboard-client/dist/assets/ReposTab-BSRWhn56.js.map +1 -0
  52. package/extensions/dashboard-client/dist/assets/SessionsTab-BXdizHN1.js +2 -0
  53. package/extensions/dashboard-client/dist/assets/SessionsTab-BXdizHN1.js.map +1 -0
  54. package/extensions/dashboard-client/dist/assets/SetupTab-CAgyfqY3.js +2 -0
  55. package/extensions/dashboard-client/dist/assets/SetupTab-CAgyfqY3.js.map +1 -0
  56. package/extensions/dashboard-client/dist/assets/TimeSavedCard-DwgCzPD9.js +2 -0
  57. package/extensions/dashboard-client/dist/assets/TimeSavedCard-DwgCzPD9.js.map +1 -0
  58. package/extensions/dashboard-client/dist/assets/TurnsTab-CVKcStBp.js +5 -0
  59. package/extensions/dashboard-client/dist/assets/TurnsTab-CVKcStBp.js.map +1 -0
  60. package/extensions/dashboard-client/dist/assets/WikiTab-BNohxTbs.js +2 -0
  61. package/extensions/dashboard-client/dist/assets/WikiTab-BNohxTbs.js.map +1 -0
  62. package/extensions/dashboard-client/dist/assets/button-B4A-_dCA.js +2 -0
  63. package/extensions/dashboard-client/dist/assets/button-B4A-_dCA.js.map +1 -0
  64. package/extensions/dashboard-client/dist/assets/card-Cjn7Ifoe.js +2 -0
  65. package/extensions/dashboard-client/dist/assets/card-Cjn7Ifoe.js.map +1 -0
  66. package/extensions/dashboard-client/dist/assets/index-CQjCLvv7.js +137 -0
  67. package/extensions/dashboard-client/dist/assets/index-CQjCLvv7.js.map +1 -0
  68. package/extensions/dashboard-client/dist/assets/index-CUA6EDRF.css +1 -0
  69. package/extensions/dashboard-client/dist/assets/switch-CcdXX0Zm.js +2 -0
  70. package/extensions/dashboard-client/dist/assets/switch-CcdXX0Zm.js.map +1 -0
  71. package/extensions/dashboard-client/dist/assets/toggle-KdBnFLu3.js +2 -0
  72. package/extensions/dashboard-client/dist/assets/toggle-KdBnFLu3.js.map +1 -0
  73. package/extensions/dashboard-client/dist/assets/{useSSE-p1pOI_-P.js → useSSE-B6t44vzc.js} +2 -2
  74. package/extensions/dashboard-client/dist/assets/{useSSE-p1pOI_-P.js.map → useSSE-B6t44vzc.js.map} +1 -1
  75. package/extensions/dashboard-client/dist/index.html +2 -2
  76. package/extensions/dashboard-client/package-lock.json +1995 -191
  77. package/extensions/dashboard-client/package.json +12 -1
  78. package/extensions/dashboard-client/postcss.config.js +6 -0
  79. package/extensions/dashboard-client/src/App.tsx +127 -88
  80. package/extensions/dashboard-client/src/api/client.ts +109 -10
  81. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +15 -7
  82. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +67 -55
  83. package/extensions/dashboard-client/src/components/CacheHitRateTrendCard.tsx +17 -13
  84. package/extensions/dashboard-client/src/components/CacheHitsCard.tsx +35 -30
  85. package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +33 -28
  86. package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +5 -6
  87. package/extensions/dashboard-client/src/components/EventStream.tsx +17 -13
  88. package/extensions/dashboard-client/src/components/HydeDetailPanel.tsx +173 -0
  89. package/extensions/dashboard-client/src/components/LegendCard.tsx +16 -9
  90. package/extensions/dashboard-client/src/components/PerfCards.tsx +28 -20
  91. package/extensions/dashboard-client/src/components/PerfChart.tsx +9 -6
  92. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +15 -7
  93. package/extensions/dashboard-client/src/components/RagDashboard.tsx +253 -0
  94. package/extensions/dashboard-client/src/components/RepoAllSessionsCard.tsx +15 -10
  95. package/extensions/dashboard-client/src/components/RepoContextStack.tsx +24 -16
  96. package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +52 -49
  97. package/extensions/dashboard-client/src/components/RepoTable.tsx +26 -17
  98. package/extensions/dashboard-client/src/components/SessionContextGauges.tsx +11 -14
  99. package/extensions/dashboard-client/src/components/SessionInfo.tsx +20 -15
  100. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +8 -5
  101. package/extensions/dashboard-client/src/components/SummaryTiles.tsx +26 -17
  102. package/extensions/dashboard-client/src/components/TabBar.tsx +1 -3
  103. package/extensions/dashboard-client/src/components/layout/AppShell.tsx +42 -0
  104. package/extensions/dashboard-client/src/components/layout/BottomBar.tsx +88 -0
  105. package/extensions/dashboard-client/src/components/layout/Sidebar.tsx +83 -0
  106. package/extensions/dashboard-client/src/components/ui/badge.tsx +30 -0
  107. package/extensions/dashboard-client/src/components/ui/button.tsx +44 -0
  108. package/extensions/dashboard-client/src/components/ui/card.tsx +32 -0
  109. package/extensions/dashboard-client/src/components/ui/select.tsx +64 -0
  110. package/extensions/dashboard-client/src/components/ui/switch.tsx +26 -0
  111. package/extensions/dashboard-client/src/components/ui/tabs.tsx +36 -0
  112. package/extensions/dashboard-client/src/components/ui/toggle.tsx +28 -0
  113. package/extensions/dashboard-client/src/components/ui/tooltip.tsx +27 -0
  114. package/extensions/dashboard-client/src/config.ts +10 -0
  115. package/extensions/dashboard-client/src/main.tsx +2 -2
  116. package/extensions/dashboard-client/src/memory-map-layout.ts +1 -2
  117. package/extensions/dashboard-client/src/styles/index.css +79 -0
  118. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +33 -26
  119. package/extensions/dashboard-client/src/tabs/EventsTab.tsx +22 -7
  120. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +77 -46
  121. package/extensions/dashboard-client/src/tabs/MaintenanceTab/ActionsCard.tsx +60 -50
  122. package/extensions/dashboard-client/src/tabs/MaintenanceTab/DbStatsCard.tsx +61 -35
  123. package/extensions/dashboard-client/src/tabs/MaintenanceTab/DebugBundleCard.tsx +34 -23
  124. package/extensions/dashboard-client/src/tabs/MaintenanceTab/HealthMitigationCard.tsx +62 -43
  125. package/extensions/dashboard-client/src/tabs/MaintenanceTab/SchemaHealthCard.tsx +53 -32
  126. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +2 -2
  127. package/extensions/dashboard-client/src/tabs/MemoryMapTab/MemoryMapView.tsx +29 -46
  128. package/extensions/dashboard-client/src/tabs/MemoryMapTab/RaptorTreeView.tsx +10 -34
  129. package/extensions/dashboard-client/src/tabs/MemoryMapTab.tsx +14 -34
  130. package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +5 -2
  131. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +11 -5
  132. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +5 -5
  133. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +47 -33
  134. package/extensions/dashboard-client/src/tabs/SetupTab/EmbedderSetup.tsx +6 -2
  135. package/extensions/dashboard-client/src/tabs/SetupTab/RagHealthCard.tsx +78 -0
  136. package/extensions/dashboard-client/src/tabs/SetupTab/SettingsPanel.tsx +198 -0
  137. package/extensions/dashboard-client/src/tabs/SetupTab/SettingsSection.tsx +50 -0
  138. package/extensions/dashboard-client/src/tabs/SetupTab.tsx +21 -59
  139. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +75 -40
  140. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +150 -112
  141. package/extensions/dashboard-client/src/tabs/WikiTab/TopicEvolutionGraph.tsx +274 -0
  142. package/extensions/dashboard-client/src/tabs/WikiTab/TopicEvolutionView.tsx +71 -0
  143. package/extensions/dashboard-client/src/tabs/WikiTab/TopicTimeline.tsx +103 -0
  144. package/extensions/dashboard-client/src/tabs/WikiTab/WikiPage.tsx +213 -0
  145. package/extensions/dashboard-client/src/tabs/WikiTab/WikiPageControls.tsx +188 -0
  146. package/extensions/dashboard-client/src/tabs/WikiTab.tsx +222 -0
  147. package/extensions/dashboard-client/src/tabs/registry.ts +57 -0
  148. package/extensions/dashboard-client/src/utils/cn.ts +7 -0
  149. package/extensions/dashboard-client/tailwind.config.js +46 -0
  150. package/extensions/dashboard-server/api-contracts/core.ts +59 -0
  151. package/extensions/dashboard-server/api-contracts/endpoints/registry.ts +79 -0
  152. package/extensions/dashboard-server/api-contracts/index.ts +48 -2
  153. package/extensions/dashboard-server/api-contracts/rag-metrics.ts +22 -0
  154. package/extensions/dashboard-server/api-contracts/rag-settings.ts +30 -20
  155. package/extensions/dashboard-server/api-contracts/wiki.ts +138 -0
  156. package/extensions/dashboard-server/routes-rag-metrics.ts +82 -0
  157. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +314 -0
  158. package/extensions/dashboard-server/routes-rag-settings.ts +147 -97
  159. package/extensions/dashboard-server/routes-wiki-helpers.ts +126 -0
  160. package/extensions/dashboard-server/routes-wiki.ts +376 -0
  161. package/extensions/dashboard-server/routes.ts +2 -0
  162. package/extensions/dashboard-server/server.ts +18 -2
  163. package/extensions/mega-events/context-handler/afterCompact.ts +44 -1
  164. package/extensions/mega-pipeline/recall.ts +31 -0
  165. package/extensions/mega-turn-store.ts +13 -0
  166. package/package.json +1 -1
  167. package/src/config/turns.ts +17 -0
  168. package/src/config.ts +3 -0
  169. package/src/recall/async.ts +10 -1
  170. package/src/recall/format.ts +15 -2
  171. package/src/recall/hydeTelemetry.ts +44 -0
  172. package/src/recall/sync.ts +34 -2
  173. package/src/recall/types.ts +36 -0
  174. package/src/store/turns/hydeStore.ts +82 -0
  175. package/src/store/turns/index.ts +4 -0
  176. package/src/store/turns/schema.ts +126 -1
  177. package/src/store/turns/sqlite-store/rows.ts +25 -0
  178. package/src/store/turns/sqlite-store/write.ts +20 -2
  179. package/src/store/turns/types.ts +33 -0
  180. package/src/topics/durability.ts +231 -0
  181. package/src/topics/index.ts +7 -1
  182. package/src/topics/store.ts +35 -9
  183. package/src/wiki/curation-helpers.ts +140 -0
  184. package/src/wiki/curation.ts +217 -0
  185. package/src/wiki/incremental.ts +164 -0
  186. package/src/wiki/index.ts +13 -0
  187. package/extensions/dashboard-client/dist/assets/CacheTab-Cqnc7lMg.js +0 -2
  188. package/extensions/dashboard-client/dist/assets/CacheTab-Cqnc7lMg.js.map +0 -1
  189. package/extensions/dashboard-client/dist/assets/EventsTab-tYk3VFAp.js +0 -2
  190. package/extensions/dashboard-client/dist/assets/EventsTab-tYk3VFAp.js.map +0 -1
  191. package/extensions/dashboard-client/dist/assets/HealthTab-D2_kjgxw.js +0 -2
  192. package/extensions/dashboard-client/dist/assets/HealthTab-D2_kjgxw.js.map +0 -1
  193. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Bc-uPYrz.js +0 -2
  194. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Bc-uPYrz.js.map +0 -1
  195. package/extensions/dashboard-client/dist/assets/MemoryMapTab-D1V9g_NC.js +0 -2
  196. package/extensions/dashboard-client/dist/assets/MemoryMapTab-D1V9g_NC.js.map +0 -1
  197. package/extensions/dashboard-client/dist/assets/MetricsTab-DT_XfL96.js +0 -2
  198. package/extensions/dashboard-client/dist/assets/MetricsTab-DT_XfL96.js.map +0 -1
  199. package/extensions/dashboard-client/dist/assets/OverviewTab-BY6KZCme.js +0 -2
  200. package/extensions/dashboard-client/dist/assets/OverviewTab-BY6KZCme.js.map +0 -1
  201. package/extensions/dashboard-client/dist/assets/ReposTab-CeS-tpDM.js +0 -2
  202. package/extensions/dashboard-client/dist/assets/ReposTab-CeS-tpDM.js.map +0 -1
  203. package/extensions/dashboard-client/dist/assets/SessionsTab-C30AwyoF.js +0 -70
  204. package/extensions/dashboard-client/dist/assets/SetupTab-BJUHOVK2.js +0 -2
  205. package/extensions/dashboard-client/dist/assets/SetupTab-BJUHOVK2.js.map +0 -1
  206. package/extensions/dashboard-client/dist/assets/TimeSavedCard-p55K1tFP.js +0 -2
  207. package/extensions/dashboard-client/dist/assets/TimeSavedCard-p55K1tFP.js.map +0 -1
  208. package/extensions/dashboard-client/dist/assets/TopicsTab-D8FqyAbK.js +0 -2
  209. package/extensions/dashboard-client/dist/assets/TopicsTab-D8FqyAbK.js.map +0 -1
  210. package/extensions/dashboard-client/dist/assets/TurnsTab-ju-nXDys.js +0 -2
  211. package/extensions/dashboard-client/dist/assets/TurnsTab-ju-nXDys.js.map +0 -1
  212. package/extensions/dashboard-client/dist/assets/index-BEj1Zahy.js +0 -42
  213. package/extensions/dashboard-client/dist/assets/index-BEj1Zahy.js.map +0 -1
  214. package/extensions/dashboard-client/dist/assets/index-CUNn22iV.css +0 -1
  215. package/extensions/dashboard-client/src/styles/metrics-extra.css +0 -69
  216. package/extensions/dashboard-client/src/styles/turns.css +0 -142
  217. package/extensions/dashboard-client/src/tabs/SetupTab/RagSettingsCard.tsx +0 -160
@@ -0,0 +1,231 @@
1
+ /**
2
+ * durability.ts — W5 full user-override replay over the topic tables.
3
+ *
4
+ * `replaceTopicModel` rebuilds `topics` + `memory_topics` from scratch, which
5
+ * would silently discard user curation (rename / merge / split). The small
6
+ * `applyOverridesAfterRebuild` in store.ts only replays label overrides; this
7
+ * module is the comprehensive replay — it replays label, merge, AND split
8
+ * overrides in chronological (`overridden_at`) order so the topic model stays
9
+ * durable across rebuilds and the incremental-assignment path (wiki/incremental.ts).
10
+ *
11
+ * Host-agnostic (no pi imports), pure local node:sqlite (PREVENT-PI-004), all
12
+ * SQL parameterized (PREVENT-002). Every override step is wrapped in try/catch
13
+ * and best-effort — a single bad row never aborts the rest of the replay.
14
+ */
15
+ import type { DatabaseSync } from "node:sqlite";
16
+ import { withTx } from "../store/turns/connection.js";
17
+ import {
18
+ moveMemory,
19
+ recordEvolution,
20
+ } from "../wiki/curation-helpers.js";
21
+
22
+ /** One `topic_overrides` row ordered by override time. */
23
+ interface OverrideRow {
24
+ topic_id: string;
25
+ kind: "label" | "merge" | "split";
26
+ custom_label: string | null;
27
+ merged_into: string | null;
28
+ split_from: string | null;
29
+ split_memory_ids: string | null;
30
+ merged_memory_ids: string | null;
31
+ }
32
+
33
+ /** Read all overrides in chronological order (oldest first). */
34
+ function loadOverrides(db: DatabaseSync): OverrideRow[] {
35
+ return db
36
+ .prepare(
37
+ `SELECT topic_id, kind, custom_label, merged_into, split_from, split_memory_ids, merged_memory_ids
38
+ FROM topic_overrides ORDER BY overridden_at ASC`,
39
+ )
40
+ .all() as unknown as OverrideRow[];
41
+ }
42
+
43
+ /** Does a topic row with this id exist? */
44
+ function topicExists(db: DatabaseSync, topicId: string): boolean {
45
+ return !!db.prepare("SELECT 1 FROM topics WHERE id = ?").get(topicId);
46
+ }
47
+
48
+ /** All existing topic ids (for nearest-by-label-overlap fallback). */
49
+ function allTopicIds(db: DatabaseSync): string[] {
50
+ const rows = db
51
+ .prepare("SELECT id FROM topics")
52
+ .all() as Array<{ id: string }>;
53
+ return rows.map((r) => r.id);
54
+ }
55
+
56
+ /**
57
+ * When a curated target topic dissolved in a rebuild, fall back to whichever
58
+ * surviving topic shares the most label tokens, so merged memories still land
59
+ * somewhere sensible. Returns "" when no overlap candidate exists.
60
+ */
61
+ function nearestByLabelOverlap(db: DatabaseSync, label: string): string {
62
+ const tokens = new Set(
63
+ label.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0),
64
+ );
65
+ if (tokens.size === 0) return "";
66
+ let bestId = "";
67
+ let bestOverlap = 0;
68
+ for (const id of allTopicIds(db)) {
69
+ const r = db
70
+ .prepare("SELECT label FROM topics WHERE id = ?")
71
+ .get(id) as { label: string } | undefined;
72
+ if (!r) continue;
73
+ const labelTokens = new Set(
74
+ r.label.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0),
75
+ );
76
+ let overlap = 0;
77
+ for (const t of tokens) if (labelTokens.has(t)) overlap++;
78
+ if (overlap > bestOverlap) {
79
+ bestOverlap = overlap;
80
+ bestId = id;
81
+ }
82
+ }
83
+ return bestOverlap > 0 ? bestId : "";
84
+ }
85
+
86
+ /**
87
+ * Replay a single merge override: force the source topic's merged members back
88
+ * into the target topic (or its nearest-by-label-overlap survivor if the target
89
+ * dissolved). Member ids are read from `merged_memory_ids` on the override row
90
+ * (W5 — survives `replaceTopicModel` because `topic_overrides` is never wiped).
91
+ * Falls back to `topic_evolution` for pre-W5 merges (only works when evolution
92
+ * rows are intact, i.e. the incremental path or no rebuild has fired since).
93
+ */
94
+ function replayMerge(
95
+ db: DatabaseSync,
96
+ row: OverrideRow,
97
+ ): void {
98
+ const target = row.merged_into;
99
+ if (!target) return;
100
+ // Primary source: merged_memory_ids on the override row itself.
101
+ let memoryIds: string[] = [];
102
+ if (row.merged_memory_ids) {
103
+ try {
104
+ const parsed = JSON.parse(row.merged_memory_ids) as unknown;
105
+ if (Array.isArray(parsed)) {
106
+ memoryIds = parsed.filter(
107
+ (m): m is string => typeof m === "string",
108
+ );
109
+ }
110
+ } catch {
111
+ /* non-fatal: malformed member list falls through to evolution */
112
+ }
113
+ }
114
+ // Fallback: topic_evolution rows (only intact when no rebuild has fired).
115
+ let members: Array<{ memory_id: string; session_id: string | null }> = [];
116
+ if (memoryIds.length === 0) {
117
+ members = db
118
+ .prepare(
119
+ `SELECT memory_id, session_id FROM topic_evolution
120
+ WHERE topic_id = ? AND method = 'merge' ORDER BY memory_id ASC`,
121
+ )
122
+ .all(target) as Array<{ memory_id: string; session_id: string | null }>;
123
+ if (members.length === 0) return;
124
+ } else {
125
+ // Resolve session_id for each member from its current assignment (if any).
126
+ for (const mid of memoryIds) {
127
+ const r = db
128
+ .prepare(
129
+ "SELECT session_id FROM memory_topics WHERE memory_id = ? LIMIT 1",
130
+ )
131
+ .get(mid) as { session_id: string | null } | undefined;
132
+ members.push({
133
+ memory_id: mid,
134
+ session_id: r?.session_id ?? null,
135
+ });
136
+ }
137
+ }
138
+ let dest = target;
139
+ if (!topicExists(db, target)) {
140
+ dest = nearestByLabelOverlap(db, target);
141
+ if (!dest) return;
142
+ }
143
+ withTx(db, () => {
144
+ const t = Date.now();
145
+ for (const m of members) {
146
+ moveMemory(db, m.memory_id, dest);
147
+ recordEvolution(db, dest, m.memory_id, m.session_id, t, "merge");
148
+ }
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Replay a single split override: recreate the split topic (if the source
154
+ * still exists or the memories are somewhere in the model) and move the listed
155
+ * memory ids into it.
156
+ */
157
+ function replaySplit(db: DatabaseSync, row: OverrideRow): void {
158
+ let memoryIds: string[] = [];
159
+ if (row.split_memory_ids) {
160
+ try {
161
+ const parsed = JSON.parse(row.split_memory_ids) as unknown;
162
+ if (Array.isArray(parsed)) {
163
+ memoryIds = parsed.filter(
164
+ (m): m is string => typeof m === "string",
165
+ );
166
+ }
167
+ } catch {
168
+ /* non-fatal: malformed member list is skipped */
169
+ }
170
+ }
171
+ if (memoryIds.length === 0 || topicExists(db, row.topic_id)) return;
172
+ // Recreate the split topic reusing its original id + source label.
173
+ const sourceLabel =
174
+ row.split_from && topicExists(db, row.split_from)
175
+ ? (db.prepare("SELECT label FROM topics WHERE id = ?")
176
+ .get(row.split_from) as { label: string }).label
177
+ : row.topic_id;
178
+ withTx(db, () => {
179
+ db.prepare(
180
+ `INSERT INTO topics (id, label, term_scores, memory_count, last_updated, cluster_model_built_at)
181
+ VALUES (?, ?, NULL, 0, ?, NULL)`,
182
+ ).run(row.topic_id, `${sourceLabel} (split)`, Date.now());
183
+ const t = Date.now();
184
+ // Only memories that still exist in the model move; drop the rest.
185
+ for (const memoryId of memoryIds) {
186
+ const src = db
187
+ .prepare(
188
+ "SELECT session_id FROM memory_topics WHERE memory_id = ? LIMIT 1",
189
+ )
190
+ .get(memoryId) as { session_id: string | null } | undefined;
191
+ if (!src) continue;
192
+ moveMemory(db, memoryId, row.topic_id);
193
+ recordEvolution(db, row.topic_id, memoryId, src.session_id, t, "split");
194
+ }
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Apply all user overrides (label / merge / split) after a topic-model rebuild,
200
+ * in `overridden_at` order. Non-fatal end-to-end: each override is isolated so a
201
+ * single corruption never aborts the replay. This is the W5 high-fidelity replay (feature A).
202
+ */
203
+ export function applyFullOverridesAfterRebuild(db: DatabaseSync): void {
204
+ if (!db) return;
205
+ let rows: OverrideRow[];
206
+ try {
207
+ rows = loadOverrides(db);
208
+ } catch {
209
+ return; // non-fatal: no overrides table / unreadable → nothing to replay
210
+ }
211
+ for (const row of rows) {
212
+ try {
213
+ if (row.kind === "label") {
214
+ // Same fast path as store.applyOverridesAfterRebuild, inline here
215
+ // so the full replay owns label + merge + split in one pass.
216
+ if (row.custom_label && row.custom_label.trim() !== "") {
217
+ db.prepare("UPDATE topics SET label = ? WHERE id = ?").run(
218
+ row.custom_label,
219
+ row.topic_id,
220
+ );
221
+ }
222
+ } else if (row.kind === "merge") {
223
+ replayMerge(db, row);
224
+ } else if (row.kind === "split") {
225
+ replaySplit(db, row);
226
+ }
227
+ } catch {
228
+ /* non-fatal: skip this override, keep replaying the rest */
229
+ }
230
+ }
231
+ }
@@ -7,7 +7,13 @@ export {
7
7
  DEFAULT_CLUSTER_CONFIG,
8
8
  type WikiClusterConfig,
9
9
  } from "./cluster.js";
10
- export { createTopicStore, getWikiCompactCounter, bumpWikiCompactCounter } from "./store.js";
10
+ export {
11
+ createTopicStore,
12
+ getWikiCompactCounter,
13
+ bumpWikiCompactCounter,
14
+ applyOverridesAfterRebuild,
15
+ } from "./store.js";
16
+ export { applyFullOverridesAfterRebuild } from "./durability.js";
11
17
  export type { TopicStore, StoredTopic } from "./store.js";
12
18
  export { tokenize, tfidfScores, labelFromScores, membershipConfidence } from "./labels.js";
13
19
  export type {
@@ -55,6 +55,7 @@ interface AssignmentRow {
55
55
  confidence: number | null;
56
56
  assigned_at: number | null;
57
57
  method: string | null;
58
+ session_id: string | null;
58
59
  }
59
60
 
60
61
  function parseTermScores(
@@ -85,10 +86,7 @@ function rowToTopic(r: TopicRow): StoredTopic {
85
86
  function rowToAssignment(r: AssignmentRow): TopicAssignment {
86
87
  return {
87
88
  memoryId: r.memory_id,
88
- // memory_topics has no session_id column; the chunk id is globally unique
89
- // enough for the wiki view (context_chunks PK is (session_id, id)).
90
- // sessionId omitted — memory_topics has no session_id column;
91
- // the TopicAssignment type marks it optional for this reason.
89
+ sessionId: r.session_id ?? "",
92
90
  topicId: r.topic_id,
93
91
  confidence: r.confidence ?? 0,
94
92
  assignedAt: r.assigned_at ?? 0,
@@ -119,11 +117,17 @@ export function createTopicStore(stateDir?: string): TopicStore {
119
117
  );
120
118
  }
121
119
  const insAssign = db.prepare(
122
- `INSERT OR REPLACE INTO memory_topics (memory_id, topic_id, confidence, assigned_at, method)
123
- VALUES (?, ?, ?, ?, 'kmeans+tfidf')`,
120
+ `INSERT OR REPLACE INTO memory_topics (memory_id, topic_id, confidence, assigned_at, method, session_id)
121
+ VALUES (?, ?, ?, ?, 'kmeans+tfidf', ?)`,
124
122
  );
125
123
  for (const a of model.assignments) {
126
- insAssign.run(a.memoryId, a.topicId, a.confidence, a.assignedAt);
124
+ insAssign.run(
125
+ a.memoryId,
126
+ a.topicId,
127
+ a.confidence,
128
+ a.assignedAt,
129
+ a.sessionId ?? null,
130
+ );
127
131
  }
128
132
  });
129
133
  }
@@ -145,7 +149,7 @@ export function createTopicStore(stateDir?: string): TopicStore {
145
149
  ): TopicAssignment[] {
146
150
  const rows = db
147
151
  .prepare(
148
- `SELECT memory_id, topic_id, confidence, assigned_at, method
152
+ `SELECT memory_id, topic_id, confidence, assigned_at, method, session_id
149
153
  FROM memory_topics WHERE topic_id = ?
150
154
  ORDER BY confidence DESC, memory_id ASC LIMIT ? OFFSET ?`,
151
155
  )
@@ -156,7 +160,7 @@ export function createTopicStore(stateDir?: string): TopicStore {
156
160
  getTopicForMemory(memoryId: string): TopicAssignment | null {
157
161
  const r = db
158
162
  .prepare(
159
- `SELECT memory_id, topic_id, confidence, assigned_at, method
163
+ `SELECT memory_id, topic_id, confidence, assigned_at, method, session_id
160
164
  FROM memory_topics WHERE memory_id = ? LIMIT 1`,
161
165
  )
162
166
  .get(memoryId) as unknown as AssignmentRow | undefined;
@@ -186,6 +190,28 @@ export function createTopicStore(stateDir?: string): TopicStore {
186
190
  };
187
191
  }
188
192
 
193
+ /**
194
+ * Re-apply user label overrides after a topic-model rebuild. `replaceTopicModel`
195
+ * deletes + recreates `topics`, so any `label` override rows that resolved to a
196
+ * custom label would otherwise be lost — this copies them back onto the fresh
197
+ * rows. Non-fatal: a failure never breaks a rebuild.
198
+ */
199
+ export function applyOverridesAfterRebuild(db: DatabaseSync): void {
200
+ try {
201
+ const rows = db
202
+ .prepare("SELECT topic_id, custom_label FROM topic_overrides WHERE kind = 'label'")
203
+ .all() as Array<{ topic_id: string; custom_label: string | null }>;
204
+ const upd = db.prepare("UPDATE topics SET label = ? WHERE id = ?");
205
+ for (const r of rows) {
206
+ if (r.custom_label && r.custom_label.trim() !== "") {
207
+ upd.run(r.custom_label, r.topic_id);
208
+ }
209
+ }
210
+ } catch {
211
+ /* non-fatal: override re-apply never breaks a rebuild */
212
+ }
213
+ }
214
+
189
215
  /** Turns-meta counter key for the every-Nth-compaction rebuild trigger. */
190
216
  const WIKI_COUNTER_KEY = "wiki_compact_counter";
191
217
 
@@ -0,0 +1,140 @@
1
+ /**
2
+ * curation-helpers.ts — private DB helpers for the wiki curation store.
3
+ *
4
+ * Pure node:sqlite primitives shared by the rename/merge/split transactions in
5
+ * curation.ts (the factory shell). Parameterized (PREVENT-002), host-agnostic.
6
+ */
7
+ import type { DatabaseSync } from "node:sqlite";
8
+
9
+ interface LabelRow {
10
+ label: string;
11
+ }
12
+ interface CountRow {
13
+ c: number;
14
+ }
15
+
16
+ /** Read a topic's current label, or null when the topic does not exist. */
17
+ export function getTopicLabel(db: DatabaseSync, topicId: string): string | null {
18
+ const r = db
19
+ .prepare("SELECT label FROM topics WHERE id = ?")
20
+ .get(topicId) as LabelRow | undefined;
21
+ return r?.label ?? null;
22
+ }
23
+
24
+ /** Recompute memory_count for a topic from its live member rows. */
25
+ export function recountTopic(db: DatabaseSync, topicId: string): void {
26
+ const c = db
27
+ .prepare("SELECT COUNT(*) AS c FROM memory_topics WHERE topic_id = ?")
28
+ .get(topicId) as unknown as CountRow;
29
+ db.prepare("UPDATE topics SET memory_count = ? WHERE id = ?").run(c.c, topicId);
30
+ }
31
+
32
+ /** Look up a custom label override; empty/blank means "no override". */
33
+ export function customLabelOverride(
34
+ db: DatabaseSync,
35
+ topicId: string,
36
+ ): string | null {
37
+ const r = db
38
+ .prepare(
39
+ `SELECT custom_label FROM topic_overrides
40
+ WHERE topic_id = ? AND kind = 'label'`,
41
+ )
42
+ .get(topicId) as { custom_label: string | null } | undefined;
43
+ const label = r?.custom_label;
44
+ return label && label.trim() !== "" ? label : null;
45
+ }
46
+
47
+ /** Write (or clear) a label override for a topic. */
48
+ export function writeLabelOverride(
49
+ db: DatabaseSync,
50
+ topicId: string,
51
+ label: string,
52
+ now: number,
53
+ ): void {
54
+ if (label.trim() === "") {
55
+ db.prepare(
56
+ "DELETE FROM topic_overrides WHERE topic_id = ? AND kind = 'label'",
57
+ ).run(topicId);
58
+ return;
59
+ }
60
+ db.prepare(
61
+ `INSERT OR REPLACE INTO topic_overrides
62
+ (topic_id, kind, custom_label, overridden_at)
63
+ VALUES (?, 'label', ?, ?)`,
64
+ ).run(topicId, label, now);
65
+ // Keep the live topics.label in sync with the override so reads resolve directly.
66
+ db.prepare("UPDATE topics SET label = ? WHERE id = ?").run(label, topicId);
67
+ }
68
+
69
+ /** Copy a source assignment into the target topic (idempotent, atomic caller). */
70
+ export function moveMemory(
71
+ db: DatabaseSync,
72
+ memoryId: string,
73
+ toTopicId: string,
74
+ ): void {
75
+ const src = db
76
+ .prepare(
77
+ `SELECT topic_id, confidence, assigned_at, session_id FROM memory_topics
78
+ WHERE memory_id = ? LIMIT 1`,
79
+ )
80
+ .get(memoryId) as {
81
+ topic_id: string;
82
+ confidence: number | null;
83
+ assigned_at: number | null;
84
+ session_id: string | null;
85
+ } | undefined;
86
+ // Delete the source row first so a memory already present in the target just stays.
87
+ db.prepare("DELETE FROM memory_topics WHERE memory_id = ?").run(memoryId);
88
+ if (!src) return;
89
+ db.prepare(
90
+ `INSERT OR REPLACE INTO memory_topics
91
+ (memory_id, topic_id, confidence, assigned_at, method, session_id)
92
+ VALUES (?, ?, ?, ?, 'kmeans+tfidf', ?)`,
93
+ ).run(
94
+ memoryId,
95
+ toTopicId,
96
+ src.confidence ?? 0,
97
+ src.assigned_at ?? 0,
98
+ src.session_id ?? null,
99
+ );
100
+ }
101
+
102
+ /** Record a member's provenance evolution row (best-effort, non-fatal). */
103
+ export function recordEvolution(
104
+ db: DatabaseSync,
105
+ topicId: string,
106
+ memoryId: string,
107
+ sessionId: string | null,
108
+ assignedAt: number,
109
+ method: "merge" | "split",
110
+ ): void {
111
+ db.prepare(
112
+ `INSERT OR REPLACE INTO topic_evolution
113
+ (topic_id, memory_id, session_id, assigned_at, method)
114
+ VALUES (?, ?, ?, ?, ?)`,
115
+ ).run(topicId, memoryId, sessionId, assignedAt, method);
116
+ }
117
+
118
+ /** Create a new split topic, returning its id. */
119
+ export function createSplitTopic(
120
+ db: DatabaseSync,
121
+ sourceId: string,
122
+ sourceLabel: string,
123
+ now: number,
124
+ ): string {
125
+ // Two splits in the same millisecond collide on the base id; append a
126
+ // numeric suffix until the id is unused.
127
+ let newId = `topic_${sourceId}_split_${now}`;
128
+ let suffix = 2;
129
+ let exists = db.prepare("SELECT 1 FROM topics WHERE id = ?").get(newId);
130
+ while (exists) {
131
+ newId = `topic_${sourceId}_split_${now}_${suffix}`;
132
+ exists = db.prepare("SELECT 1 FROM topics WHERE id = ?").get(newId);
133
+ suffix++;
134
+ }
135
+ db.prepare(
136
+ `INSERT INTO topics (id, label, term_scores, memory_count, last_updated, cluster_model_built_at)
137
+ VALUES (?, ?, NULL, 0, ?, NULL)`,
138
+ ).run(newId, `${sourceLabel} (split)`, now);
139
+ return newId;
140
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * curation.ts — W2 user curation of the auto-categorized wiki (factory shell).
3
+ *
4
+ * Persist rename / merge / split over the `topic_overrides` +
5
+ * `topic_evolution` tables (reserved in the W1.1 schema). Every mutation is
6
+ * atomic (SAVEPOINT via withTx) so a partial write never leaks. The DB
7
+ * primitives live in curation-helpers.ts; this file owns the three
8
+ * transactions (renameTopic / mergeTopics / splitTopic) + label resolution.
9
+ *
10
+ * Host-agnostic (no pi imports); pure local node:sqlite (PREVENT-PI-004), all
11
+ * SQL parameterized (PREVENT-002). Overrides are immutable per (topic_id, kind).
12
+ * `topics`/`memory_topics` are writable; the append-only invariant applies only
13
+ * to the `turns` table.
14
+ */
15
+
16
+ import type { DatabaseSync } from "node:sqlite";
17
+ import { openTurnStore, withTx } from "../store/turns/connection.js";
18
+ import { getStateDir } from "../store.js";
19
+ import {
20
+ getTopicLabel,
21
+ recountTopic,
22
+ customLabelOverride,
23
+ writeLabelOverride,
24
+ moveMemory,
25
+ recordEvolution,
26
+ createSplitTopic,
27
+ } from "./curation-helpers.js";
28
+
29
+ /** Union of the override kinds a topic may carry (badge provenance). */
30
+ export type OverrideKind = "label" | "merge" | "split";
31
+
32
+ /** Outcome of a curation mutation, shared across rename/merge/split. */
33
+ export interface CurationResult {
34
+ ok: boolean;
35
+ topicId: string;
36
+ label: string;
37
+ edited: boolean;
38
+ merged: boolean;
39
+ split: boolean;
40
+ }
41
+
42
+ /** Public surface of the wiki curation store. */
43
+ export interface WikiCurationStore {
44
+ renameTopic(topicId: string, label: string): CurationResult;
45
+ mergeTopics(sourceTopicId: string, targetTopicId: string): CurationResult;
46
+ splitTopic(topicId: string, memoryIds: string[]): CurationResult;
47
+ resolveLabel(topicId: string, autoLabel: string): {
48
+ label: string;
49
+ edited: boolean;
50
+ };
51
+ overrideKinds(topicId: string): OverrideKind[];
52
+ }
53
+
54
+ /** Open the wiki curation store over the shared turns.db connection. */
55
+ export function createWikiCuration(stateDir?: string): WikiCurationStore {
56
+ const db: DatabaseSync = openTurnStore(stateDir ?? getStateDir());
57
+ const now = (): number => Date.now();
58
+
59
+ function tryCatchResult(
60
+ res: CurationResult,
61
+ fn: () => CurationResult,
62
+ ): CurationResult {
63
+ try {
64
+ return fn();
65
+ } catch {
66
+ return { ...res, ok: false };
67
+ }
68
+ }
69
+
70
+ function renameTopic(topicId: string, label: string): CurationResult {
71
+ const base: CurationResult = {
72
+ ok: false,
73
+ topicId,
74
+ label,
75
+ edited: false,
76
+ merged: false,
77
+ split: false,
78
+ };
79
+ if (!getTopicLabel(db, topicId)) return base;
80
+ return tryCatchResult(base, (): CurationResult => {
81
+ withTx(db, () => writeLabelOverride(db, topicId, label, now()));
82
+ return { ...base, ok: true, edited: label.trim() !== "" };
83
+ });
84
+ }
85
+
86
+ function mergeTopics(
87
+ sourceTopicId: string,
88
+ targetTopicId: string,
89
+ ): CurationResult {
90
+ const base: CurationResult = {
91
+ ok: false,
92
+ topicId: targetTopicId,
93
+ label: "",
94
+ edited: false,
95
+ merged: true,
96
+ split: false,
97
+ };
98
+ if (sourceTopicId === targetTopicId) return base;
99
+ const sourceLabel = getTopicLabel(db, sourceTopicId);
100
+ const targetLabel = getTopicLabel(db, targetTopicId);
101
+ if (!sourceLabel || !targetLabel) return base;
102
+ return tryCatchResult(base, (): CurationResult => {
103
+ withTx(db, () => {
104
+ const t = now();
105
+ const members = db
106
+ .prepare(
107
+ `SELECT memory_id, session_id FROM memory_topics
108
+ WHERE topic_id = ? ORDER BY memory_id ASC`,
109
+ )
110
+ .all(sourceTopicId) as Array<{
111
+ memory_id: string;
112
+ session_id: string | null;
113
+ }>;
114
+ for (const m of members) {
115
+ moveMemory(db, m.memory_id, targetTopicId);
116
+ recordEvolution(
117
+ db,
118
+ targetTopicId,
119
+ m.memory_id,
120
+ m.session_id,
121
+ t,
122
+ "merge",
123
+ );
124
+ }
125
+ db.prepare(
126
+ `INSERT OR REPLACE INTO topic_overrides
127
+ (topic_id, kind, merged_into, merged_memory_ids, overridden_at)
128
+ VALUES (?, 'merge', ?, ?, ?)`,
129
+ ).run(
130
+ sourceTopicId,
131
+ targetTopicId,
132
+ JSON.stringify(members.map((m) => m.memory_id)),
133
+ t,
134
+ );
135
+ db.prepare("DELETE FROM memory_topics WHERE topic_id = ?").run(sourceTopicId);
136
+ db.prepare("DELETE FROM topics WHERE id = ?").run(sourceTopicId);
137
+ recountTopic(db, targetTopicId);
138
+ });
139
+ return {
140
+ ...base,
141
+ ok: true,
142
+ label: getTopicLabel(db, targetTopicId) ?? "",
143
+ edited: customLabelOverride(db, targetTopicId) !== null,
144
+ };
145
+ });
146
+ }
147
+
148
+ function splitTopic(topicId: string, memoryIds: string[]): CurationResult {
149
+ const base: CurationResult = {
150
+ ok: false,
151
+ topicId,
152
+ label: "",
153
+ edited: false,
154
+ merged: false,
155
+ split: true,
156
+ };
157
+ const sourceLabel = getTopicLabel(db, topicId);
158
+ if (!sourceLabel || memoryIds.length === 0) return base;
159
+ return tryCatchResult(base, (): CurationResult => {
160
+ let newTopicId = "";
161
+ withTx(db, () => {
162
+ const t = now();
163
+ newTopicId = createSplitTopic(db, topicId, sourceLabel, t);
164
+ for (const memoryId of memoryIds) {
165
+ const row = db
166
+ .prepare(
167
+ "SELECT session_id FROM memory_topics WHERE memory_id = ? AND topic_id = ?",
168
+ )
169
+ .get(memoryId, topicId) as { session_id: string | null } | undefined;
170
+ if (!row) continue;
171
+ moveMemory(db, memoryId, newTopicId);
172
+ recordEvolution(db, newTopicId, memoryId, row.session_id, t, "split");
173
+ recordEvolution(db, topicId, memoryId, row.session_id, t, "split");
174
+ }
175
+ db.prepare(
176
+ `INSERT OR REPLACE INTO topic_overrides
177
+ (topic_id, kind, split_from, split_memory_ids, overridden_at)
178
+ VALUES (?, 'split', ?, ?, ?)`,
179
+ ).run(newTopicId, topicId, JSON.stringify(memoryIds), t);
180
+ recountTopic(db, topicId);
181
+ recountTopic(db, newTopicId);
182
+ });
183
+ return {
184
+ ...base,
185
+ ok: true,
186
+ topicId: newTopicId,
187
+ label: getTopicLabel(db, newTopicId) ?? "",
188
+ };
189
+ });
190
+ }
191
+
192
+ function resolveLabel(topicId: string, autoLabel: string): {
193
+ label: string;
194
+ edited: boolean;
195
+ } {
196
+ const custom = customLabelOverride(db, topicId);
197
+ if (custom) return { label: custom, edited: true };
198
+ return { label: autoLabel, edited: false };
199
+ }
200
+
201
+ function overrideKinds(topicId: string): OverrideKind[] {
202
+ const rows = db
203
+ .prepare(
204
+ "SELECT kind FROM topic_overrides WHERE topic_id = ? ORDER BY kind",
205
+ )
206
+ .all(topicId) as Array<{ kind: OverrideKind }>;
207
+ return rows.map((r) => r.kind);
208
+ }
209
+
210
+ return {
211
+ renameTopic,
212
+ mergeTopics,
213
+ splitTopic,
214
+ resolveLabel,
215
+ overrideKinds,
216
+ };
217
+ }