@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.
- package/dist/index.js +131 -9
- package/package.json +1 -1
- package/payload/platform/lib/admins-write/dist/index.d.ts +87 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/admins-write/dist/index.js +248 -0
- package/payload/platform/lib/admins-write/dist/index.js.map +1 -0
- package/payload/platform/lib/admins-write/src/index.ts +311 -0
- package/payload/platform/lib/admins-write/tsconfig.json +8 -0
- package/payload/platform/neo4j/migrations/009-conversation-archive-title.ts +197 -0
- package/payload/platform/neo4j/schema.cypher +1 -1
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js +37 -44
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/internals.md +4 -3
- package/payload/platform/plugins/memory/bin/conversation-archive-ingest.mjs +215 -43
- package/payload/platform/plugins/memory/bin/conversation-archive-ingest.sh +7 -2
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js +75 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/llm-classifier.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts +16 -10
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js +155 -100
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-classifier.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.d.ts +13 -5
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.js +53 -59
- package/payload/platform/plugins/memory/mcp/dist/lib/llm-ranker.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js +9 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +24 -7
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +47 -11
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
- package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +45 -8
- package/payload/platform/scripts/lib/resolve-account-dir.sh +3 -1
- package/payload/platform/scripts/migrate-import.sh +3 -1
- package/payload/platform/scripts/seed-neo4j.sh +13 -3
- package/payload/server/chunk-CRAIGEXY.js +654 -0
- package/payload/server/chunk-GK4WHM3H.js +9961 -0
- package/payload/server/chunk-I2NOLBQA.js +2123 -0
- package/payload/server/chunk-IVTESKFR.js +9961 -0
- package/payload/server/chunk-KD3XP4IK.js +1116 -0
- package/payload/server/chunk-KKGGT5RH.js +654 -0
- package/payload/server/chunk-MRJGG6CS.js +2124 -0
- package/payload/server/chunk-OJZPS4BL.js +367 -0
- package/payload/server/chunk-ZVW5XKPU.js +1116 -0
- package/payload/server/client-pool-FM3YJWV5.js +32 -0
- package/payload/server/client-pool-J5BCVVI2.js +32 -0
- package/payload/server/cloudflare-task-tracker-FSPEJOTH.js +19 -0
- package/payload/server/cloudflare-task-tracker-XCUO4N74.js +19 -0
- package/payload/server/maxy-edge.js +6 -5
- package/payload/server/neo4j-migrations-5AN2U3YO.js +664 -0
- package/payload/server/neo4j-migrations-XP7XDVPX.js +664 -0
- package/payload/server/public/assets/{Checkbox-CTGhpDKq.js → Checkbox-Bq6ORjz2.js} +1 -1
- package/payload/server/public/assets/admin-CstEkw-G.js +352 -0
- package/payload/server/public/assets/data-DwZZ7qbH.js +1 -0
- package/payload/server/public/assets/graph-DceEv42K.js +1 -0
- package/payload/server/public/assets/{jsx-runtime-D4WovFYk.css → jsx-runtime-DidQeNoZ.css} +1 -1
- package/payload/server/public/assets/page-Bpi_jPw6.js +50 -0
- package/payload/server/public/assets/{page-DkBfWy4C.js → page-CFWoVkgV.js} +1 -1
- package/payload/server/public/assets/{public-BdVIVpv8.js → public-BWMwq5Jj.js} +1 -1
- package/payload/server/public/assets/{useAdminFetch-DmHu0oCx.js → useAdminFetch-B93ig7ef.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-CSc_hxjV.js → useVoiceRecorder-Cb0nAtOo.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +376 -167
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts +0 -31
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +0 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +0 -666
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.d.ts +0 -61
- package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.d.ts.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.js +0 -266
- package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.js.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts +0 -27
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.d.ts.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js +0 -477
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-pass.js.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts +0 -27
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.d.ts.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js +0 -160
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-insight-write.js.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +0 -10
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js +0 -29
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts +0 -28
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.d.ts.map +0 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js +0 -34
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-preview.js.map +0 -1
- package/payload/server/public/assets/admin-BNwPsMhJ.js +0 -352
- package/payload/server/public/assets/data-Y77FLKjs.js +0 -1
- package/payload/server/public/assets/graph-N_Bw-8oT.js +0 -1
- package/payload/server/public/assets/page-BKLGP-th.js +0 -50
- /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,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
|
|