@rubytech/create-realagent 1.0.832 → 1.0.834

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 (97) hide show
  1. package/dist/index.js +131 -9
  2. package/package.json +1 -1
  3. package/payload/platform/lib/admins-write/dist/index.d.ts +87 -0
  4. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -0
  5. package/payload/platform/lib/admins-write/dist/index.js +248 -0
  6. package/payload/platform/lib/admins-write/dist/index.js.map +1 -0
  7. package/payload/platform/lib/admins-write/src/index.ts +311 -0
  8. package/payload/platform/lib/admins-write/tsconfig.json +8 -0
  9. package/payload/platform/neo4j/migrations/009-conversation-archive-title.ts +197 -0
  10. package/payload/platform/neo4j/schema.cypher +1 -1
  11. package/payload/platform/package.json +2 -2
  12. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  13. package/payload/platform/plugins/admin/mcp/dist/index.js +37 -44
  14. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/docs/references/internals.md +4 -3
  16. package/payload/platform/plugins/memory/bin/conversation-archive-ingest.mjs +215 -43
  17. package/payload/platform/plugins/memory/bin/conversation-archive-ingest.sh +7 -2
  18. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +75 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
  20. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts +16 -10
  21. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
  22. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +155 -100
  23. package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
  24. package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.d.ts +13 -5
  25. package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.d.ts.map +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.js +53 -59
  27. package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.js.map +1 -1
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js +9 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js.map +1 -1
  30. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +24 -7
  31. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  32. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +47 -11
  33. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  34. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +45 -8
  35. package/payload/platform/scripts/lib/resolve-account-dir.sh +3 -1
  36. package/payload/platform/scripts/migrate-import.sh +3 -1
  37. package/payload/platform/scripts/seed-neo4j.sh +13 -3
  38. package/payload/server/chunk-CRAIGEXY.js +654 -0
  39. package/payload/server/chunk-GK4WHM3H.js +9961 -0
  40. package/payload/server/chunk-I2NOLBQA.js +2123 -0
  41. package/payload/server/chunk-IVTESKFR.js +9961 -0
  42. package/payload/server/chunk-KD3XP4IK.js +1116 -0
  43. package/payload/server/chunk-KKGGT5RH.js +654 -0
  44. package/payload/server/chunk-MRJGG6CS.js +2124 -0
  45. package/payload/server/chunk-OJZPS4BL.js +367 -0
  46. package/payload/server/chunk-ZVW5XKPU.js +1116 -0
  47. package/payload/server/client-pool-FM3YJWV5.js +32 -0
  48. package/payload/server/client-pool-J5BCVVI2.js +32 -0
  49. package/payload/server/cloudflare-task-tracker-FSPEJOTH.js +19 -0
  50. package/payload/server/cloudflare-task-tracker-XCUO4N74.js +19 -0
  51. package/payload/server/maxy-edge.js +6 -5
  52. package/payload/server/neo4j-migrations-5AN2U3YO.js +664 -0
  53. package/payload/server/neo4j-migrations-XP7XDVPX.js +664 -0
  54. package/payload/server/public/assets/{Checkbox-CTGhpDKq.js → Checkbox-Bq6ORjz2.js} +1 -1
  55. package/payload/server/public/assets/admin-CstEkw-G.js +352 -0
  56. package/payload/server/public/assets/data-DwZZ7qbH.js +1 -0
  57. package/payload/server/public/assets/graph-DceEv42K.js +1 -0
  58. package/payload/server/public/assets/{jsx-runtime-D4WovFYk.css → jsx-runtime-DidQeNoZ.css} +1 -1
  59. package/payload/server/public/assets/page-Bpi_jPw6.js +50 -0
  60. package/payload/server/public/assets/{page-DkBfWy4C.js → page-CFWoVkgV.js} +1 -1
  61. package/payload/server/public/assets/{public-BdVIVpv8.js → public-BWMwq5Jj.js} +1 -1
  62. package/payload/server/public/assets/{useAdminFetch-DmHu0oCx.js → useAdminFetch-B93ig7ef.js} +1 -1
  63. package/payload/server/public/assets/{useVoiceRecorder-CSc_hxjV.js → useVoiceRecorder-Cb0nAtOo.js} +1 -1
  64. package/payload/server/public/data.html +5 -5
  65. package/payload/server/public/graph.html +6 -6
  66. package/payload/server/public/index.html +8 -8
  67. package/payload/server/public/public.html +5 -5
  68. package/payload/server/server.js +376 -167
  69. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts +0 -31
  70. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +0 -1
  71. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +0 -666
  72. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +0 -1
  73. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.d.ts +0 -61
  74. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.d.ts.map +0 -1
  75. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.js +0 -266
  76. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.js.map +0 -1
  77. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts +0 -27
  78. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts.map +0 -1
  79. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js +0 -477
  80. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js.map +0 -1
  81. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts +0 -27
  82. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts.map +0 -1
  83. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js +0 -160
  84. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js.map +0 -1
  85. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +0 -10
  86. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +0 -1
  87. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js +0 -29
  88. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +0 -1
  89. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts +0 -28
  90. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts.map +0 -1
  91. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js +0 -34
  92. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js.map +0 -1
  93. package/payload/server/public/assets/admin-BNwPsMhJ.js +0 -352
  94. package/payload/server/public/assets/data-Y77FLKjs.js +0 -1
  95. package/payload/server/public/assets/graph-N_Bw-8oT.js +0 -1
  96. package/payload/server/public/assets/page-BKLGP-th.js +0 -50
  97. /package/payload/server/public/assets/{jsx-runtime-DkaAusaX.js → jsx-runtime-DH5S-MwB.js} +0 -0
@@ -0,0 +1,311 @@
1
+ // admins-write — single chokepoint for the dual-file admin write
2
+ // (users.json device-level PIN auth + account.json account-level role).
3
+ //
4
+ // Task 904 background: pre-Task-904 the two writers — `admin-add`
5
+ // (platform/plugins/admin/mcp/src/index.ts) and `set-pin` POST
6
+ // (platform/ui/server/routes/onboarding.ts) — each performed the dual write
7
+ // inline. They drifted: admin-add returned `is_error: true` with a per-leg
8
+ // `[admin-auth-store]` line on partial failure; set-pin logged on stderr but
9
+ // otherwise rolled forward. This module collapses both into one routed write
10
+ // so the static check `grep -rnE 'admins\.push|config\.admins\s*=' platform/`
11
+ // returns 0 outside this file (excluding tests, scripts, and the reader paths
12
+ // which never push).
13
+ //
14
+ // Atomicity contract — single file: write-temp + rename, durable.
15
+ // Atomicity contract — across files: NOT atomic. POSIX has no two-file
16
+ // transaction. The helper writes users.json first, then account.json. On
17
+ // account.json failure with users.json already written, the result names the
18
+ // partial state. Callers MUST surface partial state to the operator (admin-add
19
+ // returns is_error: true with the [admins-write] line; set-pin returns 500
20
+ // with the same). Same trust model as Task 850's [admin-auth-store] pair.
21
+
22
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+
25
+ export interface UserEntry {
26
+ userId: string;
27
+ pin: string; // SHA-256 hash
28
+ // Deprecated `name` field (Task 829) — preserved for legacy entries until
29
+ // session.ts strips it. Never written by this helper.
30
+ name?: string;
31
+ }
32
+
33
+ export interface AdminEntry {
34
+ userId: string;
35
+ role: "owner" | "admin";
36
+ }
37
+
38
+ export interface AdminWriteInput {
39
+ userId: string;
40
+ pin: string; // SHA-256 hash
41
+ role: "owner" | "admin";
42
+ usersFile: string; // absolute path to users.json
43
+ accountDir: string; // absolute path to account dir (containing account.json)
44
+ caller: string; // identifier for the [admins-write] log line
45
+ }
46
+
47
+ export interface AdminWriteResult {
48
+ usersJsonResult: "ok" | "fail" | "noop";
49
+ accountJsonResult: "ok" | "fail" | "noop";
50
+ usersError?: string;
51
+ accountError?: string;
52
+ }
53
+
54
+ function logLine(input: AdminWriteInput, result: AdminWriteResult): void {
55
+ const userIdShort = input.userId.slice(0, 8);
56
+ console.error(
57
+ `[admins-write] caller=${input.caller} userId=${userIdShort} ` +
58
+ `usersJsonResult=${result.usersJsonResult} accountJsonResult=${result.accountJsonResult}` +
59
+ (result.usersError ? ` usersError=${result.usersError}` : "") +
60
+ (result.accountError ? ` accountError=${result.accountError}` : ""),
61
+ );
62
+ }
63
+
64
+ function writeFileAtomic(filePath: string, contents: string, mode?: number): void {
65
+ mkdirSync(dirname(filePath), { recursive: true });
66
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
67
+ writeFileSync(tempPath, contents, mode !== undefined ? { mode } : undefined);
68
+ renameSync(tempPath, filePath);
69
+ }
70
+
71
+ /**
72
+ * Write a single admin entry across both auth stores (users.json + account.json admins[]).
73
+ *
74
+ * Idempotent on userId: if the userId already exists in users.json, the PIN
75
+ * field is updated in place rather than duplicated; admins[] is checked for
76
+ * presence before pushing.
77
+ *
78
+ * Always emits exactly one `[admins-write] caller=… userId=<prefix> usersJsonResult=… accountJsonResult=…`
79
+ * line — success and failure paths both fire it. Operators grep for this single
80
+ * predicate when reconstructing a partial-write incident.
81
+ */
82
+ export function writeAdminEntry(input: AdminWriteInput): AdminWriteResult {
83
+ const result: AdminWriteResult = { usersJsonResult: "noop", accountJsonResult: "noop" };
84
+
85
+ // 1. users.json — read-modify-write atomically.
86
+ try {
87
+ let users: UserEntry[] = [];
88
+ if (existsSync(input.usersFile)) {
89
+ const raw = readFileSync(input.usersFile, "utf-8").trim();
90
+ if (raw) {
91
+ const parsed = JSON.parse(raw);
92
+ if (!Array.isArray(parsed)) {
93
+ throw new Error("users.json is not an array");
94
+ }
95
+ users = parsed as UserEntry[];
96
+ }
97
+ }
98
+
99
+ const existing = users.findIndex(u => u.userId === input.userId);
100
+ if (existing === -1) {
101
+ users.push({ userId: input.userId, pin: input.pin });
102
+ } else {
103
+ // Preserve any sibling fields that other writers may have set, but
104
+ // overwrite pin. Strip the deprecated `name` field on touch (Task 829).
105
+ const merged: UserEntry = { ...users[existing], pin: input.pin };
106
+ delete merged.name;
107
+ users[existing] = merged;
108
+ }
109
+
110
+ writeFileAtomic(input.usersFile, JSON.stringify(users, null, 2) + "\n", 0o600);
111
+ result.usersJsonResult = "ok";
112
+ } catch (err) {
113
+ result.usersJsonResult = "fail";
114
+ result.usersError = err instanceof Error ? err.message : String(err);
115
+ logLine(input, result);
116
+ return result;
117
+ }
118
+
119
+ // 2. account.json — read-modify-write atomically.
120
+ try {
121
+ const accountJsonPath = join(input.accountDir, "account.json");
122
+ if (!existsSync(accountJsonPath)) {
123
+ throw new Error(`account.json not found at ${accountJsonPath}`);
124
+ }
125
+ const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
126
+ const admins = (config.admins ?? []) as AdminEntry[];
127
+ if (admins.some(a => a.userId === input.userId)) {
128
+ result.accountJsonResult = "noop";
129
+ } else {
130
+ admins.push({ userId: input.userId, role: input.role });
131
+ config.admins = admins;
132
+ writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n");
133
+ result.accountJsonResult = "ok";
134
+ }
135
+ } catch (err) {
136
+ result.accountJsonResult = "fail";
137
+ result.accountError = err instanceof Error ? err.message : String(err);
138
+ }
139
+
140
+ logLine(input, result);
141
+ return result;
142
+ }
143
+
144
+ export interface AdminRemoveInput {
145
+ userId: string;
146
+ accountDir: string;
147
+ caller: string;
148
+ }
149
+
150
+ export interface AdminRemoveResult {
151
+ accountJsonResult: "ok" | "fail" | "noop";
152
+ accountError?: string;
153
+ }
154
+
155
+ /**
156
+ * Remove a single admin entry from account.json admins[] — does NOT touch
157
+ * users.json (the device-level entry is preserved because the user may admin
158
+ * other accounts). Single-file operation; the [admins-write] line records
159
+ * `usersJsonResult=skip` so the grep-by-helper-call still picks it up.
160
+ */
161
+ export function removeAdminFromAccount(input: AdminRemoveInput): AdminRemoveResult {
162
+ const result: AdminRemoveResult = { accountJsonResult: "noop" };
163
+ const userIdShort = input.userId.slice(0, 8);
164
+
165
+ try {
166
+ const accountJsonPath = join(input.accountDir, "account.json");
167
+ if (!existsSync(accountJsonPath)) {
168
+ throw new Error(`account.json not found at ${accountJsonPath}`);
169
+ }
170
+ const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
171
+ const admins = (config.admins ?? []) as AdminEntry[];
172
+ const targetIndex = admins.findIndex(a => a.userId === input.userId);
173
+ if (targetIndex === -1) {
174
+ result.accountJsonResult = "noop";
175
+ } else {
176
+ admins.splice(targetIndex, 1);
177
+ config.admins = admins;
178
+ writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n");
179
+ result.accountJsonResult = "ok";
180
+ }
181
+ } catch (err) {
182
+ result.accountJsonResult = "fail";
183
+ result.accountError = err instanceof Error ? err.message : String(err);
184
+ }
185
+
186
+ console.error(
187
+ `[admins-write] caller=${input.caller} userId=${userIdShort} ` +
188
+ `usersJsonResult=skip accountJsonResult=${result.accountJsonResult}` +
189
+ (result.accountError ? ` accountError=${result.accountError}` : ""),
190
+ );
191
+ return result;
192
+ }
193
+
194
+ export interface InvariantCheckInput {
195
+ /** Absolute path to users.json (persistent location). */
196
+ usersFile: string;
197
+ /** Absolute path to the accounts root (e.g. <INSTALL_DIR>/data/accounts). */
198
+ accountsDir: string;
199
+ /** Tag for the log prefix — `install-invariant` or `admin-invariant`. */
200
+ tag: "install-invariant" | "admin-invariant";
201
+ }
202
+
203
+ export interface InvariantCheckResult {
204
+ divergences: number;
205
+ /** Userids in account.json admins[] that have no row in users.json. */
206
+ accountWithoutUsers: Array<{ userId: string; source: string }>;
207
+ /** Userids in users.json that no account.json admins[] references. */
208
+ usersWithoutAccount: Array<{ userId: string }>;
209
+ }
210
+
211
+ /**
212
+ * Walk every account.json under accountsDir and compare its admins[] to
213
+ * users.json. Emits one log line per divergence and one summary line.
214
+ *
215
+ * Behaviour: log-only — does NOT throw, does NOT refuse boot or install.
216
+ * Pre-Task-904 installs that already diverged would otherwise brick on the
217
+ * first boot of a Task-904 device. The summary line `divergences=<n>` is
218
+ * always emitted (wired-up assertion); per-divergence lines fire only when
219
+ * divergences > 0. Operators grep `[install-invariant]` or `[admin-invariant]`
220
+ * to detect the regression class without reading every install log.
221
+ *
222
+ * Promotion to refuse-or-degrade is a future sprint; capture as a TODO once
223
+ * the deployed fleet has been audited clean.
224
+ */
225
+ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCheckResult {
226
+ const result: InvariantCheckResult = {
227
+ divergences: 0,
228
+ accountWithoutUsers: [],
229
+ usersWithoutAccount: [],
230
+ };
231
+
232
+ // Read users.json userIds (persistent file). Absent file == empty set; do
233
+ // not abort — first-boot before set-pin has run is a legitimate state.
234
+ const usersUserIds = new Set<string>();
235
+ if (existsSync(input.usersFile)) {
236
+ try {
237
+ const raw = readFileSync(input.usersFile, "utf-8").trim();
238
+ if (raw) {
239
+ const users = JSON.parse(raw) as UserEntry[];
240
+ for (const u of users) {
241
+ if (typeof u.userId === "string") usersUserIds.add(u.userId);
242
+ }
243
+ }
244
+ } catch (err) {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ console.error(`[${input.tag}] users.json unreadable usersFile=${input.usersFile} error=${msg}`);
247
+ }
248
+ }
249
+
250
+ // Walk account dirs. Skip dotfiles (.trash/) and missing account.json.
251
+ const accountUserIds = new Set<string>();
252
+ if (existsSync(input.accountsDir)) {
253
+ let entries: string[];
254
+ try {
255
+ entries = readdirSync(input.accountsDir);
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ console.error(`[${input.tag}] accounts-dir unreadable accountsDir=${input.accountsDir} error=${msg}`);
259
+ console.error(`[${input.tag}] check complete divergences=0 (accounts-dir unreadable)`);
260
+ return result;
261
+ }
262
+
263
+ for (const entry of entries) {
264
+ if (entry.startsWith(".")) continue;
265
+ const accountDir = join(input.accountsDir, entry);
266
+ try {
267
+ if (!statSync(accountDir).isDirectory()) continue;
268
+ } catch {
269
+ continue;
270
+ }
271
+ const accountJsonPath = join(accountDir, "account.json");
272
+ if (!existsSync(accountJsonPath)) continue;
273
+ let admins: AdminEntry[] = [];
274
+ try {
275
+ const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
276
+ admins = (config.admins ?? []) as AdminEntry[];
277
+ } catch (err) {
278
+ const msg = err instanceof Error ? err.message : String(err);
279
+ console.error(`[${input.tag}] account.json unreadable source=${accountJsonPath} error=${msg}`);
280
+ continue;
281
+ }
282
+
283
+ for (const a of admins) {
284
+ if (typeof a.userId !== "string") continue;
285
+ accountUserIds.add(a.userId);
286
+ if (!usersUserIds.has(a.userId)) {
287
+ const userIdShort = a.userId.slice(0, 8);
288
+ console.error(
289
+ `[${input.tag}] direction=account-without-users userId=${userIdShort} source=${accountJsonPath}`,
290
+ );
291
+ result.accountWithoutUsers.push({ userId: a.userId, source: accountJsonPath });
292
+ result.divergences += 1;
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ for (const uid of usersUserIds) {
299
+ if (!accountUserIds.has(uid)) {
300
+ const userIdShort = uid.slice(0, 8);
301
+ console.error(
302
+ `[${input.tag}] direction=users-without-account userId=${userIdShort} source=${input.usersFile}`,
303
+ );
304
+ result.usersWithoutAccount.push({ userId: uid });
305
+ result.divergences += 1;
306
+ }
307
+ }
308
+
309
+ console.error(`[${input.tag}] check complete divergences=${result.divergences}`);
310
+ return result;
311
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Migration 009 — Backfill `title` on every legacy `:ConversationArchive`
3
+ * (Task 902 sub-scope A).
4
+ *
5
+ * Pre-902 the bin wrote per-session counter strings ("Session 1/N: K messages,
6
+ * X chunks") into `:ConversationArchive.summary`. Because the MERGE Cypher
7
+ * set `summary` ONLY in `ON CREATE SET`, the FIRST per-session call's counter
8
+ * was frozen for the lifetime of the archive — and the UI's `pickDisplayName`
9
+ * fell through to `summary` for `:ConversationArchive`, so every archive
10
+ * rendered as the literal "Session 1/N…" string of its first checkpoint.
11
+ *
12
+ * Task 902 added a stable `title` property and a UI branch that prefers it.
13
+ * This migration backfills `title` on every legacy archive that predates the
14
+ * new contract, so the UI never label-falls-through:
15
+ *
16
+ * <source> · <owner> ↔ <other1>, <other2>, … · <YYYY-MM-DD>→<YYYY-MM-DD>
17
+ *
18
+ * Owner / participant resolution: query `(p)-[:PARTICIPANT_IN]->(a)` for the
19
+ * archive's participant set, then pick a name per node by label:
20
+ * :AdminUser → displayName, then slug
21
+ * :Person → givenName + familyName
22
+ * Owner is identified by AdminUser presence in the set; other participants
23
+ * are everyone else. When neither name resolves on a node, fall back to a
24
+ * short elementId prefix (mirrors the bin's degraded behaviour at
25
+ * `computeArchiveTitle`, so the live ingest produces the same shape).
26
+ *
27
+ * Date range: head and tail `:Section:Conversation` chunks via
28
+ * `firstMessageAt` / `lastMessageAt` properties (Task 891 chunk schema).
29
+ * When chunks lack those properties (rare — pre-Task-891 archives), fall
30
+ * back to the parent's `lastIngestedMessageAt` for both ends. When even
31
+ * that is missing, the date segment is `?→?`. The migration NEVER leaves
32
+ * `title` NULL — every row gets a string, even if degraded — so the
33
+ * verification query `MATCH (a:ConversationArchive) WHERE a.title IS NULL
34
+ * RETURN count(a)` returns 0 unconditionally.
35
+ *
36
+ * Idempotency: only archives where `title IS NULL` are touched. A second
37
+ * boot finds zero rows and emits `archives-titled=0`.
38
+ *
39
+ * Observability:
40
+ * `[migration:conversation-archive-title]
41
+ * archives-titled=N degraded-no-dates=N degraded-no-names=N`
42
+ *
43
+ * REMOVE WHEN: every install we ship has been booted once on a version
44
+ * ≥ Task 902 AND no live archive carries `title IS NULL`.
45
+ */
46
+
47
+ type Neo4jDriverLike = {
48
+ session(): {
49
+ run(
50
+ cypher: string,
51
+ params?: Record<string, unknown>,
52
+ ): Promise<{ records: Array<{ get(key: string): unknown }> }>;
53
+ close(): Promise<void>;
54
+ };
55
+ };
56
+
57
+ interface ParticipantRow {
58
+ elementId: string;
59
+ labels: string[];
60
+ displayName?: string;
61
+ slug?: string;
62
+ givenName?: string;
63
+ familyName?: string;
64
+ }
65
+
66
+ const ISO_DATE_RE = /^(\d{4}-\d{2}-\d{2})/;
67
+
68
+ function isoToYmd(iso: unknown): string {
69
+ if (typeof iso !== "string") return "?";
70
+ const m = iso.match(ISO_DATE_RE);
71
+ return m ? m[1] : "?";
72
+ }
73
+
74
+ function pickName(row: ParticipantRow): string {
75
+ if (row.labels.includes("AdminUser")) {
76
+ if (row.displayName && row.displayName.trim()) return row.displayName.trim();
77
+ if (row.slug && row.slug.trim()) return row.slug.trim();
78
+ }
79
+ if (row.labels.includes("Person")) {
80
+ const full = [row.givenName, row.familyName]
81
+ .filter((s): s is string => typeof s === "string" && s.trim().length > 0)
82
+ .map((s) => s.trim())
83
+ .join(" ");
84
+ if (full) return full;
85
+ }
86
+ return row.elementId.slice(0, 8);
87
+ }
88
+
89
+ function asString(v: unknown): string | undefined {
90
+ return typeof v === "string" ? v : undefined;
91
+ }
92
+
93
+ export async function backfillConversationArchiveTitle(
94
+ driver: Neo4jDriverLike,
95
+ ): Promise<void> {
96
+ const session = driver.session();
97
+ let archivesTitled = 0;
98
+ let degradedNoDates = 0;
99
+ let degradedNoNames = 0;
100
+ try {
101
+ // 1. Find every archive lacking a title. For each, pull the participant
102
+ // set + head/tail chunk timestamps in one batch round-trip per archive.
103
+ // Idempotent: re-runs return zero rows.
104
+ const archives = await session.run(
105
+ `MATCH (a:ConversationArchive)
106
+ WHERE a.title IS NULL
107
+ RETURN elementId(a) AS elemId,
108
+ coalesce(a.source, 'whatsapp') AS source,
109
+ a.lastIngestedMessageAt AS lastAt`,
110
+ );
111
+ for (const rec of archives.records) {
112
+ const elemId = rec.get("elemId") as string;
113
+ const source = rec.get("source") as string;
114
+ const fallbackLastAt = rec.get("lastAt") as string | null;
115
+
116
+ // 2. Pull the participant rows.
117
+ const partRes = await session.run(
118
+ `MATCH (p)-[:PARTICIPANT_IN]->(a:ConversationArchive)
119
+ WHERE elementId(a) = $elemId AND (p:Person OR p:AdminUser)
120
+ RETURN elementId(p) AS elemId, labels(p) AS labels, properties(p) AS props`,
121
+ { elemId },
122
+ );
123
+ const owner: ParticipantRow[] = [];
124
+ const others: ParticipantRow[] = [];
125
+ let anyDegraded = false;
126
+ for (const r of partRes.records) {
127
+ const labels = (r.get("labels") as string[]) || [];
128
+ const props = (r.get("props") as Record<string, unknown>) || {};
129
+ const row: ParticipantRow = {
130
+ elementId: r.get("elemId") as string,
131
+ labels,
132
+ displayName: asString(props.displayName),
133
+ slug: asString(props.slug),
134
+ givenName: asString(props.givenName),
135
+ familyName: asString(props.familyName),
136
+ };
137
+ const name = pickName(row);
138
+ if (name === row.elementId.slice(0, 8)) anyDegraded = true;
139
+ if (labels.includes("AdminUser")) owner.push(row);
140
+ else others.push(row);
141
+ }
142
+ if (anyDegraded) degradedNoNames += 1;
143
+ const ownerName = owner.length > 0 ? pickName(owner[0]) : "?";
144
+ const otherNames = others.length > 0
145
+ ? others.map((o) => pickName(o)).join(", ")
146
+ : "?";
147
+
148
+ // 3. Pull the head and tail chunk timestamps. Chunks land in NEXT-chain
149
+ // order; the chain head has no incoming :NEXT edge inside the
150
+ // same archive, the tail has no outgoing :NEXT.
151
+ const datesRes = await session.run(
152
+ `MATCH (a:ConversationArchive)-[:HAS_SECTION]->(c:Section:Conversation)
153
+ WHERE elementId(a) = $elemId
154
+ WITH c, c.firstMessageAt AS first, c.lastMessageAt AS last
155
+ RETURN min(first) AS firstAt, max(last) AS lastAt`,
156
+ { elemId },
157
+ );
158
+ let firstAt = asString(datesRes.records[0]?.get("firstAt"));
159
+ let lastAt = asString(datesRes.records[0]?.get("lastAt"));
160
+ if (!firstAt || !lastAt) {
161
+ if (fallbackLastAt) {
162
+ firstAt = firstAt ?? fallbackLastAt;
163
+ lastAt = lastAt ?? fallbackLastAt;
164
+ }
165
+ }
166
+ if (!firstAt || !lastAt) {
167
+ degradedNoDates += 1;
168
+ }
169
+ const firstYmd = isoToYmd(firstAt);
170
+ const lastYmd = isoToYmd(lastAt);
171
+
172
+ const title = `${source} · ${ownerName} ↔ ${otherNames} · ${firstYmd}→${lastYmd}`;
173
+
174
+ // 4. Stamp the title. Guarded by `title IS NULL` so a concurrent live
175
+ // ingest that has already written a real title is never overwritten.
176
+ const upd = await session.run(
177
+ `MATCH (a:ConversationArchive)
178
+ WHERE elementId(a) = $elemId AND a.title IS NULL
179
+ SET a.title = $title
180
+ RETURN count(a) AS n`,
181
+ { elemId, title },
182
+ );
183
+ const n = upd.records[0]?.get("n");
184
+ const v = typeof n === "number" ? n : (n as { toNumber?: () => number })?.toNumber?.() ?? 0;
185
+ if (v > 0) archivesTitled += 1;
186
+ }
187
+
188
+ console.error(
189
+ `[migration:conversation-archive-title] ` +
190
+ `archives-titled=${archivesTitled} ` +
191
+ `degraded-no-dates=${degradedNoDates} ` +
192
+ `degraded-no-names=${degradedNoNames}`,
193
+ );
194
+ } finally {
195
+ await session.close();
196
+ }
197
+ }
@@ -850,7 +850,7 @@ FOR (a:Agent) ON (a.accountId);
850
850
  // (not account-scoped). Linked to accounts via ADMIN_OF.
851
851
  //
852
852
  // Properties:
853
- // userId — UUID, matches entry in platform/config/users.json
853
+ // userId — UUID, matches entry in ~/<brand.configDir>/users.json (Task 904; pre-Task-904 path was platform/config/users.json)
854
854
  // name — display name (e.g. "Adam")
855
855
  // createdAt — ISO 8601 timestamp
856
856
  //
@@ -6,8 +6,8 @@
6
6
  "plugins/*/mcp"
7
7
  ],
8
8
  "scripts": {
9
- "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
- "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json",
9
+ "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
+ "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json",
11
11
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
12
12
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
13
13
  "build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
@@ -44,7 +44,7 @@ Platform management tools for both the admin and public agents. The `plugin-read
44
44
 
45
45
  Tools are available via the `admin` MCP server.
46
46
 
47
- **Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth, `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same line. Direct `Edit`/`Write` on `account.json` is blocked at the `pre-tool-use` hook — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
47
+ **Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is blocked at the `pre-tool-use` hook — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
48
48
 
49
49
  `logs-read { type: "agent-stream" }` is the canonical name for the per-conversation tool-use/tool-result archive previously called `system`; both names work and the legacy alias is preserved.
50
50