@rubytech/create-maxy-code 0.1.265 → 0.1.267

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 (81) hide show
  1. package/dist/index.js +16 -0
  2. package/package.json +1 -1
  3. package/payload/platform/lib/models/dist/index.d.ts +1 -1
  4. package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/models/dist/index.js +5 -2
  6. package/payload/platform/lib/models/dist/index.js.map +1 -1
  7. package/payload/platform/lib/models/src/index.ts +5 -2
  8. package/payload/platform/neo4j/schema.cypher +13 -0
  9. package/payload/platform/package-lock.json +16 -0
  10. package/payload/platform/package.json +3 -2
  11. package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js +9 -9
  12. package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js.map +1 -1
  13. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -3
  14. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
  15. package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -5
  16. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  17. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +9 -1
  18. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/http-server.js +36 -1
  20. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +10 -0
  22. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +59 -0
  24. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +19 -0
  26. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -0
  27. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +31 -0
  28. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -0
  29. package/payload/platform/services/whatsapp-channel/package.json +20 -0
  30. package/payload/platform/templates/account.json +1 -1
  31. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  32. package/payload/platform/templates/specialists/agents/librarian.md +1 -1
  33. package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
  34. package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
  35. package/payload/premium-plugins/writer-craft/PLUGIN.md +4 -4
  36. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  37. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +44 -9
  38. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  39. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +31 -0
  40. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  41. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +28 -0
  42. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  43. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +7 -1
  44. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  45. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +93 -44
  46. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  47. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  48. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +1 -0
  49. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  50. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +7 -1
  51. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  52. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -3
  53. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  54. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +22 -8
  55. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  56. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +93 -84
  57. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  58. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +18 -0
  59. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  60. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +32 -3
  61. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
  62. package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +35 -2
  63. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +52 -10
  64. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +39 -0
  65. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +108 -44
  66. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +1 -0
  67. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +24 -4
  68. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +136 -102
  69. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +45 -3
  70. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +34 -23
  71. package/payload/server/{chunk-SOLVVUST.js → chunk-W4EM7RK4.js} +2 -0
  72. package/payload/server/maxy-edge.js +1 -1
  73. package/payload/server/server.js +345 -14
  74. package/payload/platform/plugins/business-assistant/references/quote-engine.md +0 -122
  75. package/payload/platform/plugins/business-assistant/references/quote-generation.md +0 -94
  76. package/payload/platform/plugins/business-assistant/references/quoting.md +0 -85
  77. package/payload/platform/plugins/business-assistant/skills/pricing-method/SKILL.md +0 -78
  78. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/learning-from-history.md +0 -51
  79. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/maintenance.md +0 -32
  80. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/manual-definition.md +0 -42
  81. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/verification.md +0 -37
@@ -29,8 +29,11 @@ import { notTrashed } from "../lib/voice-corpus.js";
29
29
  import {
30
30
  VOICE_CORPUS_WHERE,
31
31
  voiceCorpusWhereWithFormat,
32
+ voiceCorpusWhereWithFormatAndAuthor,
33
+ profileUserIdForScope,
32
34
  FORMAT_VALUES,
33
35
  type VoiceFormat,
36
+ type VoiceScope,
34
37
  } from "../lib/voice-corpus.js";
35
38
 
36
39
  /**
@@ -60,6 +63,12 @@ const RE_RUN_AGE_DAYS = 30;
60
63
  export interface VoiceDistilProfileParams {
61
64
  accountId: string;
62
65
  userId: string;
66
+ /**
67
+ * Profile scope (Task 676). `'personal'` (default) distils the operator's
68
+ * own author-filtered corpus onto `:AdminUser`; `'org'` distils the whole
69
+ * account onto `:LocalBusiness` under the reserved sentinel userId.
70
+ */
71
+ scope?: VoiceScope;
63
72
  force?: boolean;
64
73
  /**
65
74
  * Writing format to distil. When omitted on `sample`, enumerates distinct
@@ -152,42 +161,54 @@ export interface VoiceDistilProfileResult {
152
161
  */
153
162
  async function distilForFormat(
154
163
  params: Required<Pick<VoiceDistilProfileParams, "accountId" | "userId">> &
155
- Pick<VoiceDistilProfileParams, "force" | "mode" | "styleCardYaml"> & {
164
+ Pick<VoiceDistilProfileParams, "force" | "mode" | "styleCardYaml" | "scope"> & {
156
165
  format: VoiceFormat;
157
166
  },
158
167
  ): Promise<VoiceDistilProfileResult> {
159
168
  const { accountId, userId, format, force = false } = params;
169
+ const scope: VoiceScope = params.scope ?? "personal";
170
+ const profileUserId = profileUserIdForScope(scope, userId);
171
+ const anchorLabel = scope === "org" ? "LocalBusiness" : "AdminUser";
172
+ // Org walks the whole account; personal narrows to one author's content.
173
+ const corpusWhere =
174
+ scope === "org"
175
+ ? voiceCorpusWhereWithFormat(format)
176
+ : voiceCorpusWhereWithFormatAndAuthor(format);
177
+ const corpusParams =
178
+ scope === "org"
179
+ ? { accountId, format }
180
+ : { accountId, format, voiceAuthor: userId };
160
181
  const session = getSession();
161
182
  const now = new Date();
162
183
  const nowIso = now.toISOString();
163
184
 
164
185
  try {
165
- // 1. Read existing per-format profile (if any) for cadence-guard comparison.
186
+ // 1. Read existing scoped profile (if any) for cadence-guard comparison.
187
+ // Key-only match — the (accountId, profileUserId, format) triple is the
188
+ // unique constraint key, so no anchor traversal is needed on the read.
166
189
  const existing = await session.run(
167
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
168
- OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
190
+ `OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
169
191
  RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize,
170
192
  p.generatedAt AS generatedAt`,
171
- { accountId, userId, format },
193
+ { accountId, profileUserId, format },
172
194
  );
173
195
  const existingRow = existing.records[0];
174
196
  const prevProfileId = (existingRow?.get("profileId") as string | null) ?? null;
175
197
  const prevCorpusSize = toJsNumber(existingRow?.get("corpusSize"));
176
198
  const prevGeneratedAt = (existingRow?.get("generatedAt") as string | null) ?? null;
177
199
 
178
- // 2. Count corpus for this specific format.
179
- const corpusWhere = voiceCorpusWhereWithFormat(format);
200
+ // 2. Count corpus for this scope + format.
180
201
  const countResult = await session.run(
181
202
  `MATCH (n)
182
203
  WHERE ${corpusWhere}
183
204
  RETURN count(n) AS c`,
184
- { accountId, format },
205
+ corpusParams,
185
206
  );
186
207
  const corpusSize = toJsNumber(countResult.records[0]?.get("c")) ?? 0;
187
208
 
188
209
  if (corpusSize === 0) {
189
210
  process.stderr.write(
190
- `[voice-distil] adminUser=${userId} format=${format} skip reason=empty-corpus\n`,
211
+ `[voice-distil] scope=${scope} userId=${profileUserId} format=${format} skip reason=empty-corpus\n`,
191
212
  );
192
213
  return {
193
214
  profileId: prevProfileId,
@@ -212,7 +233,7 @@ async function distilForFormat(
212
233
  if (!ageTrigger && !growthTrigger) {
213
234
  const reason = ageDays < 1 ? "recent" : "below-threshold";
214
235
  process.stderr.write(
215
- `[voice-distil] adminUser=${userId} format=${format} skip reason=${reason} corpusSize=${corpusSize} prevCorpus=${prevCorpusSize} ageDays=${ageDays.toFixed(1)}\n`,
236
+ `[voice-distil] scope=${scope} userId=${profileUserId} format=${format} skip reason=${reason} corpusSize=${corpusSize} prevCorpus=${prevCorpusSize} ageDays=${ageDays.toFixed(1)}\n`,
216
237
  );
217
238
  return {
218
239
  profileId: prevProfileId,
@@ -265,7 +286,7 @@ async function distilForFormat(
265
286
  ts
266
287
  ORDER BY ts IS NULL, ts DESC
267
288
  LIMIT 500`,
268
- { accountId, format },
289
+ corpusParams,
269
290
  );
270
291
  const samples: { id: string; label: string; body: string }[] = [];
271
292
  let sectionWalkCount = 0;
@@ -290,11 +311,14 @@ async function distilForFormat(
290
311
  if (charBudget <= 0) break;
291
312
  }
292
313
 
293
- // 5. Pull feedback intents from existing :VoiceEdit nodes for this format.
314
+ // 5. Pull feedback intents from existing :VoiceEdit nodes for this scope +
315
+ // format. Keyed by profileUserId: org edits live under the sentinel, so
316
+ // org distil reads org feedback and personal distil reads only this
317
+ // operator's edits — no extra scope predicate needed.
294
318
  const feedback = await session.run(
295
- `MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
319
+ `MATCH (e:VoiceEdit {userId: $profileUserId, accountId: $accountId, format: $format})
296
320
  RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`,
297
- { accountId, userId, format },
321
+ { accountId, profileUserId, format },
298
322
  );
299
323
  const feedbackIntents = feedback.records
300
324
  .map((r) => r.get("intent") as string | null)
@@ -304,7 +328,7 @@ async function distilForFormat(
304
328
  const mode = params.mode ?? "sample";
305
329
  if (mode === "sample") {
306
330
  process.stderr.write(
307
- `[voice-distil] adminUser=${userId} format=${format} mode=sample corpusSize=${corpusSize} ` +
331
+ `[voice-distil] scope=${scope} userId=${profileUserId} format=${format} mode=sample corpusSize=${corpusSize} ` +
308
332
  `exemplars=${samples.length} feedbackEntries=${feedbackIntents.length} ` +
309
333
  `sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`,
310
334
  );
@@ -327,13 +351,27 @@ async function distilForFormat(
327
351
  );
328
352
  }
329
353
 
330
- // 7. Write the :VoiceProfile node — keyed on (accountId, userId, format) —
331
- // plus LEARNED_FROM edges to every sampled node.
354
+ // 7. Write the :VoiceProfile node — keyed on (accountId, profileUserId,
355
+ // format) anchored on :AdminUser (personal) or :LocalBusiness (org),
356
+ // plus LEARNED_FROM edges to every sampled node. `scope` is SET after the
357
+ // MERGE (never inside the pattern) so a legacy scope-less personal
358
+ // profile is matched and backfilled rather than duplicated.
359
+ const anchorMatch =
360
+ scope === "org"
361
+ ? `MATCH (anchor:LocalBusiness {accountId: $accountId})`
362
+ : `MATCH (anchor:AdminUser {accountId: $accountId, userId: $profileUserId})`;
363
+ // Two-step MERGE: the profile node is MERGEd by its unique key alone, then
364
+ // the anchor edge is MERGEd separately. MERGEing the node through the anchor
365
+ // pattern would create a duplicate (constraint error) if the node existed
366
+ // but its anchor edge did not. Keying the node first mirrors the key-only
367
+ // reads and keeps the legacy scope-less backfill matching by key.
332
368
  const writeResult = await session.run(
333
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
334
- MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
369
+ `${anchorMatch}
370
+ MERGE (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
335
371
  ON CREATE SET p.createdAt = $now
336
- SET p.styleCard = $styleCard,
372
+ MERGE (anchor)-[:HAS_VOICE_PROFILE]->(p)
373
+ SET p.scope = $scope,
374
+ p.styleCard = $styleCard,
337
375
  p.generatedAt = $now,
338
376
  p.corpusSize = $corpusSize,
339
377
  p.feedbackEntries = $feedbackEntries,
@@ -345,8 +383,9 @@ async function distilForFormat(
345
383
  RETURN elementId(p) AS profileId`,
346
384
  {
347
385
  accountId,
348
- userId,
386
+ profileUserId,
349
387
  format,
388
+ scope,
350
389
  styleCard: styleCardYaml,
351
390
  now: nowIso,
352
391
  corpusSize,
@@ -356,13 +395,15 @@ async function distilForFormat(
356
395
  );
357
396
  if (writeResult.records.length === 0) {
358
397
  throw new Error(
359
- `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before distillation.`,
398
+ scope === "org"
399
+ ? `voice-mirror: :LocalBusiness {accountId='${accountId}'} not found. The account org node must exist before an org distil (created at onboarding alongside :AdminUser).`
400
+ : `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before distillation.`,
360
401
  );
361
402
  }
362
403
  const profileId = writeResult.records[0].get("profileId") as string;
363
404
 
364
405
  process.stderr.write(
365
- `[voice-distil] adminUser=${userId} format=${format} mode=write corpusSize=${corpusSize} generatedAt=${nowIso} feedbackEntries=${feedbackIntents.length}\n`,
406
+ `[voice-distil] scope=${scope} userId=${profileUserId} anchor=${anchorLabel} format=${format} corpusSize=${corpusSize} generatedAt=${nowIso} feedbackEntries=${feedbackIntents.length}${scope === "org" ? " sentinel=__org__" : ""}\n`,
366
407
  );
367
408
 
368
409
  return {
@@ -393,20 +434,23 @@ async function amendForFormat(params: {
393
434
  userId: string;
394
435
  format: VoiceFormat;
395
436
  nodeIds: string[];
437
+ scope?: VoiceScope;
396
438
  }): Promise<VoiceDistilProfileResult> {
397
439
  const { accountId, userId, format, nodeIds } = params;
440
+ const scope: VoiceScope = params.scope ?? "personal";
441
+ const profileUserId = profileUserIdForScope(scope, userId);
398
442
  const session = getSession();
399
443
 
400
444
  try {
401
- // 1. Existing profile (may be absent — first-time amend with no prior card).
445
+ // 1. Existing scoped profile (may be absent — first-time amend, no prior
446
+ // card). Key-only match on the unique (accountId, profileUserId, format).
402
447
  const profileRow = await session.run(
403
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
404
- OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
448
+ `OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
405
449
  RETURN elementId(p) AS profileId,
406
450
  p.styleCard AS styleCard,
407
451
  p.corpusSize AS corpusSize,
408
452
  p.generatedAt AS generatedAt`,
409
- { accountId, userId, format },
453
+ { accountId, profileUserId, format },
410
454
  );
411
455
  const prow = profileRow.records[0];
412
456
  const prevProfileId = (prow?.get("profileId") as string | null) ?? null;
@@ -453,11 +497,12 @@ async function amendForFormat(params: {
453
497
  }
454
498
 
455
499
  // 3. Feedback intents (same as sample) — agent benefits even on a no-op
456
- // amend ("you've already corrected this voice tic ten times").
500
+ // amend ("you've already corrected this voice tic ten times"). Keyed by
501
+ // profileUserId so org amends read org feedback.
457
502
  const feedback = await session.run(
458
- `MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
503
+ `MATCH (e:VoiceEdit {userId: $profileUserId, accountId: $accountId, format: $format})
459
504
  RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`,
460
- { accountId, userId, format },
505
+ { accountId, profileUserId, format },
461
506
  );
462
507
  const feedbackIntents = feedback.records
463
508
  .map((r) => r.get("intent") as string | null)
@@ -465,7 +510,7 @@ async function amendForFormat(params: {
465
510
 
466
511
  if (eligibleIds.length === 0) {
467
512
  process.stderr.write(
468
- `[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
513
+ `[voice-distil] scope=${scope} mode=amend userId=${profileUserId} format=${format} nodeIds=${nodeIds.length} ` +
469
514
  `existingProfile=${prevProfileId ?? "none"} eligible=0 ineligible=${ineligible.length}\n`,
470
515
  );
471
516
  return {
@@ -537,7 +582,7 @@ async function amendForFormat(params: {
537
582
  }
538
583
 
539
584
  process.stderr.write(
540
- `[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
585
+ `[voice-distil] scope=${scope} mode=amend userId=${profileUserId} format=${format} nodeIds=${nodeIds.length} ` +
541
586
  `existingProfile=${prevProfileId ?? "none"} eligible=${eligibleIds.length} ineligible=${ineligible.length} ` +
542
587
  `exemplars=${exemplars.length} sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`,
543
588
  );
@@ -578,8 +623,12 @@ async function amendWriteForFormat(params: {
578
623
  format: VoiceFormat;
579
624
  styleCardYaml: string;
580
625
  amendedFromNodeIds: string[];
626
+ scope?: VoiceScope;
581
627
  }): Promise<VoiceDistilProfileResult> {
582
628
  const { accountId, userId, format, styleCardYaml, amendedFromNodeIds } = params;
629
+ const scope: VoiceScope = params.scope ?? "personal";
630
+ const profileUserId = profileUserIdForScope(scope, userId);
631
+ const anchorLabel = scope === "org" ? "LocalBusiness" : "AdminUser";
583
632
  const session = getSession();
584
633
  const nowIso = new Date().toISOString();
585
634
  try {
@@ -607,17 +656,24 @@ async function amendWriteForFormat(params: {
607
656
  }
608
657
 
609
658
  const feedback = await session.run(
610
- `MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
659
+ `MATCH (e:VoiceEdit {userId: $profileUserId, accountId: $accountId, format: $format})
611
660
  RETURN count(e) AS c`,
612
- { accountId, userId, format },
661
+ { accountId, profileUserId, format },
613
662
  );
614
663
  const feedbackEntries = toJsNumber(feedback.records[0]?.get("c")) ?? 0;
615
664
 
665
+ const anchorMatch =
666
+ scope === "org"
667
+ ? `MATCH (anchor:LocalBusiness {accountId: $accountId})`
668
+ : `MATCH (anchor:AdminUser {accountId: $accountId, userId: $profileUserId})`;
669
+ // Two-step MERGE (see distilForFormat): node by unique key, then anchor edge.
616
670
  const result = await session.run(
617
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
618
- MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
671
+ `${anchorMatch}
672
+ MERGE (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
619
673
  ON CREATE SET p.createdAt = $now, p.corpusSize = 0
620
- SET p.styleCard = $styleCard,
674
+ MERGE (anchor)-[:HAS_VOICE_PROFILE]->(p)
675
+ SET p.scope = $scope,
676
+ p.styleCard = $styleCard,
621
677
  p.generatedAt = $now,
622
678
  p.feedbackEntries = $feedbackEntries,
623
679
  p.updatedAt = $now
@@ -629,8 +685,9 @@ async function amendWriteForFormat(params: {
629
685
  RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize`,
630
686
  {
631
687
  accountId,
632
- userId,
688
+ profileUserId,
633
689
  format,
690
+ scope,
634
691
  styleCard: styleCardYaml,
635
692
  now: nowIso,
636
693
  feedbackEntries,
@@ -639,15 +696,17 @@ async function amendWriteForFormat(params: {
639
696
  );
640
697
  if (result.records.length === 0) {
641
698
  throw new Error(
642
- `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. ` +
643
- `Onboarding must promote the operator to :AdminUser before distillation.`,
699
+ scope === "org"
700
+ ? `voice-mirror: :LocalBusiness {accountId='${accountId}'} not found. The account org node must exist before an org amend-write.`
701
+ : `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. ` +
702
+ `Onboarding must promote the operator to :AdminUser before distillation.`,
644
703
  );
645
704
  }
646
705
  const profileId = result.records[0].get("profileId") as string;
647
706
  const corpusSize = toJsNumber(result.records[0].get("corpusSize")) ?? 0;
648
707
 
649
708
  process.stderr.write(
650
- `[voice-distil] mode=write userId=${userId} format=${format} amended=true ` +
709
+ `[voice-distil] scope=${scope} mode=write userId=${profileUserId} anchor=${anchorLabel} format=${format} amended=true ` +
651
710
  `amendedFromNodeIds=${amendedFromNodeIds.length} corpusSize=${corpusSize} ` +
652
711
  `generatedAt=${nowIso} feedbackEntries=${feedbackEntries}\n`,
653
712
  );
@@ -668,6 +727,7 @@ export async function voiceDistilProfile(
668
727
  params: VoiceDistilProfileParams,
669
728
  ): Promise<VoiceDistilProfileResult | VoiceDistilProfileResult[]> {
670
729
  const { accountId, userId, force = false } = params;
730
+ const scope: VoiceScope = params.scope ?? "personal";
671
731
  if (!accountId || !userId) {
672
732
  throw new Error("voice-distil-profile: accountId and userId required");
673
733
  }
@@ -688,6 +748,7 @@ export async function voiceDistilProfile(
688
748
  userId,
689
749
  format: params.format,
690
750
  nodeIds,
751
+ scope,
691
752
  });
692
753
  }
693
754
 
@@ -716,6 +777,7 @@ export async function voiceDistilProfile(
716
777
  format: params.format,
717
778
  styleCardYaml,
718
779
  amendedFromNodeIds: params.amendedFromNodeIds,
780
+ scope,
719
781
  });
720
782
  }
721
783
 
@@ -725,14 +787,16 @@ export async function voiceDistilProfile(
725
787
  }
726
788
 
727
789
  // Multi-format path: enumerate distinct format values present in the corpus.
790
+ // Scope-aware: personal enumerates only the operator's authored content, org
791
+ // enumerates the whole account.
728
792
  const session = getSession();
729
793
  let formats: VoiceFormat[] = [];
730
794
  try {
731
795
  const enumResult = await session.run(
732
796
  `MATCH (n)
733
- WHERE ${VOICE_CORPUS_WHERE} AND n.format IS NOT NULL
797
+ WHERE ${VOICE_CORPUS_WHERE} AND n.format IS NOT NULL${scope === "personal" ? " AND n.voiceAuthor = $voiceAuthor" : ""}
734
798
  RETURN DISTINCT n.format AS fmt`,
735
- { accountId },
799
+ scope === "personal" ? { accountId, voiceAuthor: userId } : { accountId },
736
800
  );
737
801
  formats = enumResult.records
738
802
  .map((r) => r.get("fmt") as string)
@@ -745,7 +809,7 @@ export async function voiceDistilProfile(
745
809
 
746
810
  if (formats.length === 0) {
747
811
  process.stderr.write(
748
- `[voice-distil] adminUser=${userId} skip reason=no-format-tagged-content\n`,
812
+ `[voice-distil] scope=${scope} userId=${profileUserIdForScope(scope, userId)} skip reason=no-format-tagged-content\n`,
749
813
  );
750
814
  return {
751
815
  profileId: null,
@@ -94,6 +94,7 @@ export async function voiceIngestSessionText(
94
94
  MERGE (n:Message {contentHash: $contentHash, accountId: $accountId})
95
95
  ON CREATE SET
96
96
  n.userId = $userId,
97
+ n.voiceAuthor = $userId,
97
98
  n.body = $body,
98
99
  n.occurredAt = $now,
99
100
  n.sessionId = $sessionId,
@@ -17,7 +17,11 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { getSession } from "../lib/neo4j.js";
20
- import type { VoiceFormat } from "../lib/voice-corpus.js";
20
+ import {
21
+ profileUserIdForScope,
22
+ type VoiceFormat,
23
+ type VoiceScope,
24
+ } from "../lib/voice-corpus.js";
21
25
 
22
26
  export interface VoiceRecordFeedbackParams {
23
27
  accountId: string;
@@ -29,6 +33,12 @@ export interface VoiceRecordFeedbackParams {
29
33
  * matching per-format :VoiceProfile.
30
34
  */
31
35
  format: VoiceFormat;
36
+ /**
37
+ * Scope of the edited draft (Task 676). `'org'` routes the edit to the
38
+ * account/house profile (`userId='__org__'`); `'personal'` (default) routes
39
+ * to the operator's own profile.
40
+ */
41
+ scope?: VoiceScope;
32
42
  /**
33
43
  * One sentence describing what changed and what voice preference it reveals.
34
44
  * The calling agent summarises this from `originalText` vs `editedText` in
@@ -54,6 +64,8 @@ export async function voiceRecordFeedback(
54
64
  params: VoiceRecordFeedbackParams,
55
65
  ): Promise<VoiceRecordFeedbackResult> {
56
66
  const { accountId, userId, originalText, editedText, format, intent: rawIntent, context } = params;
67
+ const scope: VoiceScope = params.scope ?? "personal";
68
+ const profileUserId = profileUserIdForScope(scope, userId);
57
69
  if (!accountId || !userId) {
58
70
  throw new Error("voice-record-feedback: accountId and userId required");
59
71
  }
@@ -76,12 +88,18 @@ export async function voiceRecordFeedback(
76
88
 
77
89
  const session = getSession();
78
90
  try {
91
+ // The :VoiceEdit carries the profile-key userId ($profileUserId — the
92
+ // sentinel for org) plus `scope`, so distillation's feedback query (keyed by
93
+ // profileUserId) reads org edits for the org profile and personal edits for
94
+ // the operator. The real editor is preserved via the AUTHORED edge from
95
+ // their :AdminUser. FEEDBACK_FOR targets the scoped profile (Task 676).
79
96
  const result = await session.run(
80
97
  `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
81
98
  CREATE (e:VoiceEdit {
82
99
  editId: $editId,
83
100
  accountId: $accountId,
84
- userId: $userId,
101
+ userId: $profileUserId,
102
+ scope: $scope,
85
103
  format: $format,
86
104
  originalText: $originalText,
87
105
  editedText: $editedText,
@@ -92,7 +110,7 @@ export async function voiceRecordFeedback(
92
110
  })
93
111
  CREATE (a)-[:AUTHORED]->(e)
94
112
  WITH e
95
- OPTIONAL MATCH (:AdminUser {accountId: $accountId, userId: $userId})-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
113
+ OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
96
114
  FOREACH (_ IN CASE WHEN p IS NULL THEN [] ELSE [1] END |
97
115
  MERGE (e)-[:FEEDBACK_FOR]->(p)
98
116
  )
@@ -100,6 +118,8 @@ export async function voiceRecordFeedback(
100
118
  {
101
119
  accountId,
102
120
  userId,
121
+ profileUserId,
122
+ scope,
103
123
  format,
104
124
  editId,
105
125
  originalText,
@@ -118,7 +138,7 @@ export async function voiceRecordFeedback(
118
138
  const hadProfile = result.records[0].get("hadProfile") === true;
119
139
 
120
140
  process.stderr.write(
121
- `[voice-record-feedback] adminUser=${userId} format=${format} intent="${cappedIntent.replace(/"/g, "'")}" diffBytes=${editedText.length - originalText.length}\n`,
141
+ `[voice-record-feedback] scope=${scope} userId=${profileUserId} format=${format} intent="${cappedIntent.replace(/"/g, "'")}" diffBytes=${editedText.length - originalText.length}\n`,
122
142
  );
123
143
 
124
144
  return { editId, intent: cappedIntent, occurredAt, hadProfile };