@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
@@ -30,9 +30,11 @@ import neo4j, { type Session } from "neo4j-driver";
30
30
  import { getSession } from "../lib/neo4j.js";
31
31
  import { notTrashed } from "../lib/voice-corpus.js";
32
32
  import {
33
- VOICE_CORPUS_WHERE,
34
33
  voiceCorpusWhereWithFormat,
34
+ voiceCorpusWhereWithFormatAndAuthor,
35
+ ORG_USER_ID,
35
36
  type VoiceFormat,
37
+ type VoiceScope,
36
38
  } from "../lib/voice-corpus.js";
37
39
 
38
40
  const DEFAULT_TOKEN_BUDGET_SHORT = 16_000;
@@ -56,6 +58,13 @@ export interface VoiceRetrieveConditioningParams {
56
58
  */
57
59
  length?: "short" | "long";
58
60
  register?: string;
61
+ /**
62
+ * Voice scope (Task 676). `'personal'` (default) retrieves the caller's own
63
+ * profile and author-filtered exemplars, falling back to the org profile
64
+ * when the caller has none. `'org'` retrieves the account/house profile and
65
+ * account-wide exemplars, degrading to the default register when absent.
66
+ */
67
+ scope?: VoiceScope;
59
68
  };
60
69
  tokenBudget?: number;
61
70
  }
@@ -71,14 +80,21 @@ export interface VoiceExemplar {
71
80
  * Outcome discriminator (Task 493). Three mutually-exclusive states so the
72
81
  * drafting agent and operator can tell genuine no-data apart from a failed
73
82
  * lookup — both previously collapsed into the same empty payload:
74
- * - "ok" — query ran; a style card and/or exemplars were returned.
75
- * - "no-data" — query ran; no profile exists and the corpus is empty.
76
- * - "error" the lookup failed (blank identity, driver/connection
77
- * fault, or Cypher error). `error` carries the message.
78
- * Drafting degrades gracefully to default register on both "no-data" and
79
- * "error"; the caller must only report "no profile exists" on "no-data".
83
+ * - "ok" — query ran; a style card and/or exemplars were returned
84
+ * for the requested scope.
85
+ * - "fallback-org"— a personal request found no personal profile/corpus, so
86
+ * the org profile was returned instead (Task 676). Drafting
87
+ * uses the org voice; this is the standing signal that the
88
+ * caller has no personal attribution yet.
89
+ * - "no-data" — query ran; no profile exists and the corpus is empty for
90
+ * the requested scope (and, for personal, no org fallback).
91
+ * - "error" — the lookup failed (blank identity, driver/connection
92
+ * fault, or Cypher error). `error` carries the message.
93
+ * Drafting degrades gracefully to default register on "no-data" and "error";
94
+ * "fallback-org" still injects a (org) style card. The caller must only report
95
+ * "no profile exists" on "no-data".
80
96
  */
81
- export type VoiceRetrieveStatus = "ok" | "no-data" | "error";
97
+ export type VoiceRetrieveStatus = "ok" | "fallback-org" | "no-data" | "error";
82
98
 
83
99
  export interface VoiceRetrieveConditioningResult {
84
100
  styleCard: string | null;
@@ -113,39 +129,15 @@ export async function voiceRetrieveConditioning(
113
129
  (length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
114
130
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
115
131
  const format = brief.format;
116
- const corpusWhere = voiceCorpusWhereWithFormat(format);
132
+ const scope: VoiceScope = brief.scope ?? "personal";
133
+ const topic = (brief.topic ?? "").trim();
134
+ const usesTopic = topic.length > 0;
117
135
 
118
- // getSession() is inside the try so driver-construction / connection-config
119
- // faults (NEO4J_URI unset, password file missing) are caught, logged, and
120
- // returned as the error state rather than escaping the function silently
121
- // (Task 493).
122
- let session: Session | undefined;
123
- try {
124
- session = getSession();
125
- // 1. Style card — per-format profile.
126
- const profile = await session.run(
127
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
128
- OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
129
- RETURN p.styleCard AS styleCard`,
130
- { accountId, userId, format },
131
- );
132
- const styleCard = (profile.records[0]?.get("styleCard") as string | null) ?? null;
133
-
134
- // 2. Exemplars — filtered to brief.format. Keyword-aware when topic is set.
135
- //
136
- // Body resolution (Task 471):
137
- // - For :KnowledgeDocument with HAS_SECTION children, concatenate
138
- // child :Section bodies in `position` order. KD.body is null after
139
- // Task 465 server-slicing.
140
- // - Other labels read n.body directly via the COALESCE fallback chain.
141
- //
142
- // The topic filter runs against the computed body, so a topic that
143
- // appears only in n.summary (LLM abstract) no longer matches — by
144
- // design: the corpus surfaces verbatim prose, not the abstract.
145
- const topic = (brief.topic ?? "").trim();
146
- const usesTopic = topic.length > 0;
147
-
148
- const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
136
+ // Body resolution (Task 471): for :KnowledgeDocument with HAS_SECTION
137
+ // children, concatenate child :Section bodies in `position` order (KD.body is
138
+ // null after Task 465 server-slicing); other labels read n.body via the
139
+ // COALESCE chain. The topic filter runs against the computed body.
140
+ const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
149
141
  OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
150
142
  WHERE ${notTrashed("s")}
151
143
  WITH n, ts, s ORDER BY s.position
@@ -156,74 +148,116 @@ export async function voiceRetrieveConditioning(
156
148
  ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
157
149
  END AS body`;
158
150
 
159
- const cypher = usesTopic
160
- ? `MATCH (n)
161
- WHERE ${corpusWhere}
162
- ${bodyComputeBlock}
163
- WHERE toLower(body) CONTAINS toLower($topic)
164
- RETURN elementId(n) AS id,
165
- labels(n) AS labels,
166
- body,
167
- coalesce(n.subject, n.title, n.name, '') AS source,
168
- ts
169
- ORDER BY ts IS NULL, ts DESC
170
- LIMIT $k`
171
- : `MATCH (n)
172
- WHERE ${corpusWhere}
173
- ${bodyComputeBlock}
174
- RETURN elementId(n) AS id,
175
- labels(n) AS labels,
176
- body,
177
- coalesce(n.subject, n.title, n.name, '') AS source,
178
- ts
179
- ORDER BY ts IS NULL, ts DESC
180
- LIMIT $k`;
181
-
182
- const result = await session.run(cypher, {
183
- accountId,
184
- format,
185
- topic,
186
- // neo4j-driver serializes a plain JS number as a Cypher float (15 → 15.0),
187
- // which LIMIT rejects ("not a valid value. Must be a non-negative integer").
188
- // Send it as a Neo4j integer.
189
- k: neo4j.int(k),
190
- });
191
-
192
- let charsUsed = 0;
193
- const exemplars: VoiceExemplar[] = [];
194
- for (const r of result.records) {
195
- const body = (r.get("body") as string | null) ?? "";
196
- if (body.length === 0) continue;
197
- const labels = (r.get("labels") as string[]) ?? [];
198
- const label =
199
- labels.find((l) =>
200
- ["KnowledgeDocument", "Message", "SocialPost", "Conversation"].includes(l),
201
- ) ?? labels[0] ?? "Unknown";
202
- const remaining = charBudget - charsUsed;
203
- if (remaining <= 0) break;
204
- const truncated = body.length > remaining ? body.slice(0, remaining) : body;
205
- exemplars.push({
206
- nodeId: r.get("id") as string,
207
- label,
208
- body: truncated,
209
- source: ((r.get("source") as string | null) ?? "").slice(0, 200),
151
+ // getSession() is inside the try so driver-construction / connection-config
152
+ // faults (NEO4J_URI unset, password file missing) are caught, logged, and
153
+ // returned as the error state rather than escaping the function silently
154
+ // (Task 493).
155
+ let session: Session | undefined;
156
+ try {
157
+ session = getSession();
158
+
159
+ // One scoped lookup: the profile style card (keyed on the profile's userId)
160
+ // plus K voice-matched exemplars over the scope's corpus. Org passes the
161
+ // account-wide corpus + `__org__`; personal passes the author-filtered
162
+ // corpus + the operator's userId. Each call gets a fresh char budget.
163
+ const lookup = async (
164
+ profileUserId: string,
165
+ exemplarWhere: string,
166
+ exemplarParams: Record<string, unknown>,
167
+ ): Promise<{ styleCard: string | null; exemplars: VoiceExemplar[] }> => {
168
+ const profile = await session!.run(
169
+ `OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
170
+ RETURN p.styleCard AS styleCard`,
171
+ { accountId, profileUserId, format },
172
+ );
173
+ const styleCard =
174
+ (profile.records[0]?.get("styleCard") as string | null) ?? null;
175
+
176
+ const cypher = usesTopic
177
+ ? `MATCH (n)
178
+ WHERE ${exemplarWhere}
179
+ ${bodyComputeBlock}
180
+ WHERE toLower(body) CONTAINS toLower($topic)
181
+ RETURN elementId(n) AS id, labels(n) AS labels, body,
182
+ coalesce(n.subject, n.title, n.name, '') AS source, ts
183
+ ORDER BY ts IS NULL, ts DESC
184
+ LIMIT $k`
185
+ : `MATCH (n)
186
+ WHERE ${exemplarWhere}
187
+ ${bodyComputeBlock}
188
+ RETURN elementId(n) AS id, labels(n) AS labels, body,
189
+ coalesce(n.subject, n.title, n.name, '') AS source, ts
190
+ ORDER BY ts IS NULL, ts DESC
191
+ LIMIT $k`;
192
+
193
+ const result = await session!.run(cypher, {
194
+ ...exemplarParams,
195
+ topic,
196
+ // neo4j-driver serializes a plain JS number as a Cypher float (15 →
197
+ // 15.0), which LIMIT rejects. Send a Neo4j integer.
198
+ k: neo4j.int(k),
210
199
  });
211
- charsUsed += truncated.length;
200
+
201
+ let charsUsed = 0;
202
+ const exemplars: VoiceExemplar[] = [];
203
+ for (const r of result.records) {
204
+ const body = (r.get("body") as string | null) ?? "";
205
+ if (body.length === 0) continue;
206
+ const labels = (r.get("labels") as string[]) ?? [];
207
+ const label =
208
+ labels.find((l) =>
209
+ ["KnowledgeDocument", "Message", "SocialPost", "Conversation"].includes(l),
210
+ ) ?? labels[0] ?? "Unknown";
211
+ const remaining = charBudget - charsUsed;
212
+ if (remaining <= 0) break;
213
+ const truncated = body.length > remaining ? body.slice(0, remaining) : body;
214
+ exemplars.push({
215
+ nodeId: r.get("id") as string,
216
+ label,
217
+ body: truncated,
218
+ source: ((r.get("source") as string | null) ?? "").slice(0, 200),
219
+ });
220
+ charsUsed += truncated.length;
221
+ }
222
+ return { styleCard, exemplars };
223
+ };
224
+
225
+ const hasData = (r: { styleCard: string | null; exemplars: VoiceExemplar[] }) =>
226
+ (r.styleCard !== null && r.styleCard.length > 0) || r.exemplars.length > 0;
227
+
228
+ const orgLookup = () =>
229
+ lookup(ORG_USER_ID, voiceCorpusWhereWithFormat(format), { accountId, format });
230
+
231
+ let result: { styleCard: string | null; exemplars: VoiceExemplar[] };
232
+ let status: VoiceRetrieveStatus;
233
+ if (scope === "personal") {
234
+ const personal = await lookup(
235
+ userId,
236
+ voiceCorpusWhereWithFormatAndAuthor(format),
237
+ { accountId, format, voiceAuthor: userId },
238
+ );
239
+ if (hasData(personal)) {
240
+ result = personal;
241
+ status = "ok";
242
+ } else {
243
+ // No personal profile/corpus yet — borrow the org (house) voice rather
244
+ // than the bare default register (Task 676).
245
+ const org = await orgLookup();
246
+ result = org;
247
+ status = hasData(org) ? "fallback-org" : "no-data";
248
+ }
249
+ } else {
250
+ const org = await orgLookup();
251
+ result = org;
252
+ status = hasData(org) ? "ok" : "no-data";
212
253
  }
213
254
 
214
255
  process.stderr.write(
215
- `[voice-retrieve] task=${brief.topic ?? length} format=${format} styleCardBytes=${
216
- styleCard?.length ?? 0
217
- } exemplarCount=${exemplars.length} tokenBudget=${tokenBudget}\n`,
256
+ `[voice-retrieve] scope=${scope} format=${format} status=${status} ` +
257
+ `styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length} tokenBudget=${tokenBudget}\n`,
218
258
  );
219
259
 
220
- // An empty-string styleCard with no exemplars is no-data, not ok — a blank
221
- // card conditions nothing, so reporting "ok" would be the same lie this
222
- // discriminator exists to prevent (Task 493).
223
- const hasStyleCard = styleCard !== null && styleCard.length > 0;
224
- const status: VoiceRetrieveStatus =
225
- hasStyleCard || exemplars.length > 0 ? "ok" : "no-data";
226
- return { styleCard, exemplars, status };
260
+ return { styleCard: result.styleCard, exemplars: result.exemplars, status };
227
261
  } catch (err) {
228
262
  const message = err instanceof Error ? err.message : String(err);
229
263
  process.stderr.write(`[voice-retrieve] error: ${message}\n`);
@@ -34,12 +34,41 @@ export const AUTHORSHIP_MODES: ReadonlySet<AuthorshipMode> = new Set([
34
34
  "unknown",
35
35
  ]);
36
36
 
37
+ /**
38
+ * Resolve the corpus author for a tag write (Task 676). An explicit, non-blank
39
+ * `author` wins; otherwise the tagging operator's `userId`. Throws when neither
40
+ * is available (no `author` argument and no `ADMIN_USER_ID` in spawn env), so a
41
+ * tag can never silently land with a null author.
42
+ *
43
+ * This is why single-operator accounts get personal attribution for free: with
44
+ * `author` omitted, the sole operator is the implicit author, so their personal
45
+ * corpus equals the account-wide corpus and prior behaviour is reproduced.
46
+ */
47
+ export function resolveVoiceAuthor(
48
+ author: string | undefined,
49
+ operatorUserId: string | null,
50
+ ): string {
51
+ const explicit = (author ?? "").trim();
52
+ if (explicit) return explicit;
53
+ if (operatorUserId) return operatorUserId;
54
+ throw new Error(
55
+ "voice-tag-content: author required — no ADMIN_USER_ID in spawn env and no author given.",
56
+ );
57
+ }
58
+
37
59
  export interface VoiceTagContentParams {
38
60
  nodeIds: string[];
39
61
  mode: AuthorshipMode;
40
62
  accountId: string;
41
63
  /** Writing format (required). Editorial classification — operator-supplied. */
42
64
  format: VoiceFormat;
65
+ /**
66
+ * The corpus author (a userId) to stamp on each node (Task 676). Resolve via
67
+ * `resolveVoiceAuthor` before calling — explicit author, else the tagging
68
+ * operator. Personal distillation filters on this; org distillation ignores
69
+ * it (account-wide).
70
+ */
71
+ voiceAuthor: string;
43
72
  }
44
73
 
45
74
  export interface VoiceTagContentResult {
@@ -51,7 +80,7 @@ export interface VoiceTagContentResult {
51
80
  export async function voiceTagContent(
52
81
  params: VoiceTagContentParams,
53
82
  ): Promise<VoiceTagContentResult> {
54
- const { nodeIds, mode, accountId, format } = params;
83
+ const { nodeIds, mode, accountId, format, voiceAuthor } = params;
55
84
 
56
85
  if (!AUTHORSHIP_MODES.has(mode)) {
57
86
  throw new Error(
@@ -74,6 +103,14 @@ export async function voiceTagContent(
74
103
  if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
75
104
  return { updated: 0, skipped: 0, skippedReasons: {} };
76
105
  }
106
+ // voiceAuthor is checked after the empty-nodeIds early return: an empty tag is
107
+ // a no-op, so there is nothing to attribute. With nodes to tag, the author
108
+ // must be resolved (index.ts always supplies it via resolveVoiceAuthor).
109
+ if (!voiceAuthor) {
110
+ throw new Error(
111
+ "voice-tag-content: voiceAuthor is required (resolve via resolveVoiceAuthor before calling).",
112
+ );
113
+ }
77
114
 
78
115
  const session = getSession();
79
116
  const now = new Date().toISOString();
@@ -102,7 +139,8 @@ export async function voiceTagContent(
102
139
  })
103
140
  SET n.authorshipMode = $mode,
104
141
  n.authorshipTaggedAt = $now,
105
- n.format = $format
142
+ n.format = $format,
143
+ n.voiceAuthor = $voiceAuthor
106
144
  RETURN elementId(n) AS id`,
107
145
  {
108
146
  ids: batch,
@@ -110,6 +148,7 @@ export async function voiceTagContent(
110
148
  mode,
111
149
  now,
112
150
  format,
151
+ voiceAuthor,
113
152
  primaryLabels: [...TAGGABLE_PRIMARY_LABELS],
114
153
  },
115
154
  );
@@ -125,8 +164,11 @@ export async function voiceTagContent(
125
164
  await session.close();
126
165
  }
127
166
 
167
+ // Tagging is always a personal-attribution act: every tagged node carries an
168
+ // author and feeds both that author's personal walk and the account-wide org
169
+ // walk. There is no org-only corpus node, so scope is constant `personal`.
128
170
  process.stderr.write(
129
- `[voice-tag] mode=${mode} format=${format} accountId=${accountId} updated=${updated} skipped=${
171
+ `[voice-tag] mode=${mode} format=${format} scope=personal author=${voiceAuthor} accountId=${accountId} updated=${updated} skipped=${
130
172
  nodeIds.length - updated
131
173
  }\n`,
132
174
  );
@@ -12,8 +12,8 @@ The skill is built on five deterministic MCP tools served by the `writer-craft`
12
12
  | Tool | Purpose |
13
13
  |------|---------|
14
14
  | `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode` and operator-supplied `format`. Bulk-tags arrays of node ids. |
15
- | `voice-distil-profile` | Walk the operator's `human-only` corpus for a given `format` and produce a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format}` node. Without a `format`, enumerates all formats present in the corpus and distils each. |
16
- | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (one of the six corpus formats). K=5 for short-form, K=15 for long-form. Token-budget bounded. |
15
+ | `voice-distil-profile` | Walk the `human-only` corpus for a given `format` and `scope` and produce a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format, scope}` node. `scope='personal'` (default) walks one author; `scope='org'` walks the whole account. Without a `format`, enumerates all formats present in that scope's corpus and distils each. |
16
+ | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[], status}` for a drafting brief. Requires `brief.format`; `brief.scope` (default `personal`) picks personal vs org voice, with a personal→org fallback. K=5 for short-form, K=15 for long-form. Token-budget bounded. |
17
17
  | `voice-record-feedback` | When the operator edits an agent draft, write a `:VoiceEdit {originalText, editedText, intent, occurredAt, format}-[:FEEDBACK_FOR]->(:VoiceProfile {format})` node. The `intent` field is a Haiku-summarised diff. |
18
18
  | `voice-ingest-session-text` | Write operator turns from the current session as `:Message {format:'text', authorshipMode:'human-only'}` corpus nodes. The agent reads its own operator turns from context and passes them as `turns`. Deduplicates via `contentHash`. Invoked on demand when the operator asks to capture this session's voice. |
19
19
 
@@ -76,10 +76,11 @@ Query for `:KnowledgeDocument | :SocialPost | :Message` nodes belonging to this
76
76
  - `1,3,7 human-only email` — tag a subset with mode + format.
77
77
  - `2 human-led-agent-assisted note` — single-item.
78
78
  - `5 agent-led-human-reviewed marketing-copy` — single-item.
79
+ - `1-10 human-only article author:joel` — attribute the batch to a named author (a `userId`) rather than the tagging operator.
79
80
  - `skip` — leave the batch as `unknown` and advance.
80
81
  - `stop` — exit the backfill; the next session resumes from where the operator left off.
81
82
 
82
- `format` is required on every command; the tool will not infer it. If the operator omits the format, ask before tagging. Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> count=<n>` per write.
83
+ `format` is required on every command; the tool will not infer it. If the operator omits the format, ask before tagging. `author:<userId>` is optional — **omit it and the content is attributed to the tagging operator**, so single-operator accounts get personal attribution for free and behave exactly as before. Name an author only on a multi-operator account when tagging someone else's writing. Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<n>` per write.
83
84
 
84
85
  ### 2b. Chat archives — paginate per conversation-document parent
85
86
 
@@ -90,7 +91,7 @@ For each conversation-document `:KnowledgeDocument` (with `conversationIdentity`
90
91
  - `not me` — tag every chunk as `agent-only` (e.g. a Slack channel where the operator only forwarded other people's messages).
91
92
  - `skip` / `stop` — same semantics as 2a.
92
93
 
93
- Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains, passing `format='text'` (chat archives are unambiguously text-format). The skill emits `[voice-tag] mode=<mode> format=<format> count=<chunk-count>` once per conversation.
94
+ Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains, passing `format='text'` (chat archives are unambiguously text-format). Attribution defaults to the tagging operator; on a multi-operator account, pass `author:<userId>` to attribute the conversation to the operator who actually owns it. The skill emits `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<chunk-count>` once per conversation.
94
95
 
95
96
  Authorship at conversation granularity is a deliberate UX trade-off: chunks within a conversation almost always share the same operator's voice, and per-chunk tagging at archive scale would burn the operator's attention. The `mixed` opt-in covers the rare case where it matters.
96
97
 
@@ -112,7 +113,7 @@ This runs only on the admin seat (the tool is admin-allowlisted). Accounts witho
112
113
 
113
114
  ## Flow — drafting-time conditioning
114
115
 
115
- Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register}` — where `format` is the target corpus format and returns:
116
+ Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure and the investor/prospectus skills request `org`. It returns:
116
117
 
117
118
  ```yaml
118
119
  styleCard: |
@@ -132,27 +133,28 @@ exemplars:
132
133
 
133
134
  Drafting skills inject both blocks into their prompt. Opt-out is per-skill: declare `voiceMirror: false` in the skill's frontmatter and the calling site skips the fetch. Default is on.
134
135
 
135
- The tool returns `{styleCard, exemplars, status}` with a discriminated `status` (Task 493):
136
+ The tool returns `{styleCard, exemplars, status}` with a discriminated `status` (Task 493, extended Task 676):
136
137
 
137
- - **`ok`** — a profile and/or exemplars were returned; inject both blocks.
138
- - **`no-data`** — the lookup ran but no `:VoiceProfile` exists for this format and the corpus is empty (too small, or distillation never run). Fall back to default register; if the operator asks, this is the only state where you say "you have no voice profile yet".
138
+ - **`ok`** — a profile and/or exemplars were returned for the requested scope; inject both blocks.
139
+ - **`fallback-org`** — a `personal` request found no personal profile/corpus, so the **org (house) voice** was returned instead. Inject the (org) blocks and draft normally. This is the standing signal that the calling operator has no personal attribution yet — the account is running on the house voice. Don't tell the operator they have "no profile"; they have the house one.
140
+ - **`no-data`** — the lookup ran but no `:VoiceProfile` exists for this scope and the corpus is empty (and, for a personal request, no org profile either). Fall back to default register; this is the only state where you say "you have no voice profile yet".
139
141
  - **`error`** — the lookup failed (the payload carries an `error` message). Fall back to default register *exactly as for no-data* so drafting never crashes — but **never** tell the operator they have no profile; the profile state is unknown because the lookup errored.
140
142
 
141
- In all three states drafting proceeds: `no-data` and `error` both degrade to the default register, no draft refusal. The distinction matters only for what you tell the operator about *why* their voice was not applied.
143
+ In all four states drafting proceeds. `ok` and `fallback-org` inject a style card; `no-data` and `error` degrade to the default register. The distinction matters only for what you tell the operator about *why* a given voice was (or was not) applied.
142
144
 
143
- Emit `[voice-retrieve] task=<brief> format=<format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` per call.
145
+ Emit `[voice-retrieve] scope=<scope> format=<format> status=<status> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` per call.
144
146
 
145
147
  ## Flow — feedback capture
146
148
 
147
149
  When the operator edits an agent draft (in the email composer's edit loop, or in any drafting skill that supports inline edits), capture the diff:
148
150
 
149
- 1. The calling skill passes `{originalText, editedText, format}` to `voice-record-feedback`.
151
+ 1. The calling skill passes `{originalText, editedText, format, scope}` to `voice-record-feedback`. `scope` matches the scope the draft was retrieved at — an edit on an org draft passes `scope: "org"`.
150
152
  2. The tool runs a Haiku diff summarisation to produce an `intent` field — a one-sentence description of what the operator changed and why. Example intents: "shorter and dropped the apology", "switched to first person", "removed sign-off", "added a specific date".
151
- 3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, userId, accountId, format})-[:FEEDBACK_FOR]->(:VoiceProfile {format})` for the operator's profile.
153
+ 3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, userId, scope, accountId, format})-[:FEEDBACK_FOR]->(:VoiceProfile {format})` for the scoped profile. An org edit routes to the org profile (`userId='__org__'`); a personal edit to the operator's own. The real editor is always preserved via `(:AdminUser)-[:AUTHORED]->(:VoiceEdit)`, even on org edits.
152
154
 
153
- Feedback accumulates between distillations. The next `voice-distil-profile` run reads every `:VoiceEdit` linked to the profile and feeds the intents into the style-card refinement.
155
+ Feedback accumulates between distillations. The next `voice-distil-profile` run at the matching scope reads every `:VoiceEdit` linked to that profile and feeds the intents into the style-card refinement.
154
156
 
155
- Emit `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>`.
157
+ Emit `[voice-record-feedback] scope=<scope> userId=<id|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>`.
156
158
 
157
159
  ## Distillation cadence
158
160
 
@@ -184,21 +186,30 @@ When `eligible=0` (every named document failed the predicate) surface the `ineli
184
186
 
185
187
  Amend mode does not apply to the on-demand session-text capture path. Session-text ingest stays on the full-corpus path; amend is operator-initiated only.
186
188
 
187
- ## Per-operator scope
189
+ ## Org and personal scope
188
190
 
189
- One `:VoiceProfile` per `(accountId, userId, format)`. Multi-author accounts (an agency with several operators) get one profile per person per format. Identity is the `(accountId, userId, format)` triple, enforced by the schema constraint.
191
+ Every account holds, per format, **one org (house) profile and any number of personal profiles** at once (Task 676):
192
+
193
+ - **Personal** — one operator's own voice. Key `(accountId, userId, format)`, `scope='personal'`, anchored on the operator's `:AdminUser`. Distilled from that operator's author-tagged content only (`n.voiceAuthor = userId`).
194
+ - **Org** — the account/house voice. Key `(accountId, '__org__', format)`, `scope='org'`, anchored on the account's `:LocalBusiness`. Distilled from the **whole account** (every author's content), not one person's.
195
+
196
+ `__org__` is a reserved sentinel userId. It keeps the existing `(accountId, userId, format)` unique constraint valid with no migration — exactly one org profile per `(account, format)` — and a real operator must never hold it.
197
+
198
+ Which voice a draft uses is the `scope` on the retrieval brief: `"my voice"` → personal, `"the house/org voice"` → org. A personal request with no personal profile yet falls back to the org profile (`status: "fallback-org"`); an org request with no org profile degrades to the default register (`status: "no-data"`).
199
+
200
+ **Single-operator accounts are unchanged.** With author omitted at tag time, the sole operator is the implicit author, so their personal corpus equals the account-wide corpus and personal distillation reproduces the prior single profile. Legacy content tagged before Task 676 carries no `voiceAuthor`; it appears only in the account-wide org walk until re-tagged, and personal drafts fall back to the org voice in the meantime (legacy `voiceAuthor` backfill is Task 678; author inference from source metadata is Task 679).
190
201
 
191
202
  ## Observability
192
203
 
193
204
  | Tag | When |
194
205
  |-----|------|
195
- | `[voice-tag] mode=<mode> format=<format> nodeId=<id> count=<n>` | Every tag write. |
196
- | `[voice-distil] userId=<id> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
197
- | `[voice-distil] skip reason=<below-threshold\|recent> format=<format>` | When the cadence guard fires. |
198
- | `[voice-distil] mode=amend userId=<id> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
199
- | `[voice-distil] mode=write userId=<id> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
200
- | `[voice-retrieve] task=<topic> format=<format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. |
201
- | `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
206
+ | `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<n>` | Every tag write. `author=` is the resolved value (operator's id when omitted). |
207
+ | `[voice-distil] scope=<scope> userId=<id\|__org__> anchor=<AdminUser\|LocalBusiness> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation write. Org also carries `sentinel=__org__`. |
208
+ | `[voice-distil] scope=<scope> userId=<id\|__org__> format=<format> skip reason=<below-threshold\|recent\|empty-corpus>` | When the cadence/empty guard fires. |
209
+ | `[voice-distil] scope=<scope> mode=amend userId=<id\|__org__> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
210
+ | `[voice-distil] scope=<scope> mode=write userId=<id\|__org__> anchor=<…> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
211
+ | `[voice-retrieve] scope=<scope> format=<format> status=<ok\|fallback-org\|no-data\|error> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. `status=fallback-org` proves the personal→org fallback fired. |
212
+ | `[voice-record-feedback] scope=<scope> userId=<id\|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
202
213
  | `[voice-ingest-session-text] sessionId=<id> adminUser=<id> turns=<n> bytes=<n> skipped=<m>` | On-demand session-text capture. |
203
214
 
204
215
  **Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active format per operator. `[voice-ingest-session-text] turns≥1` appears when the operator asks to capture a session's voice.
@@ -5590,6 +5590,8 @@ var clientIpMiddleware = async (c, next) => {
5590
5590
  };
5591
5591
 
5592
5592
  export {
5593
+ HtmlEscapedCallbackPhase,
5594
+ resolveCallback,
5593
5595
  Hono2 as Hono,
5594
5596
  getRequestListener,
5595
5597
  serve,
@@ -14,7 +14,7 @@ import {
14
14
  sanitizeClientCorrId,
15
15
  vncLog,
16
16
  websockifyLog
17
- } from "./chunk-SOLVVUST.js";
17
+ } from "./chunk-W4EM7RK4.js";
18
18
  import "./chunk-PFF6I7KP.js";
19
19
 
20
20
  // server/edge.ts