@rubytech/create-maxy-code 0.1.265 → 0.1.266

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 (67) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/models/dist/index.d.ts +1 -1
  3. package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/models/dist/index.js +5 -2
  5. package/payload/platform/lib/models/dist/index.js.map +1 -1
  6. package/payload/platform/lib/models/src/index.ts +5 -2
  7. package/payload/platform/neo4j/schema.cypher +13 -0
  8. package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js +9 -9
  9. package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js.map +1 -1
  10. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -3
  11. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
  12. package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -5
  13. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  14. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +9 -1
  15. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +10 -0
  16. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +59 -0
  18. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  19. package/payload/platform/templates/account.json +1 -1
  20. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  21. package/payload/platform/templates/specialists/agents/librarian.md +1 -1
  22. package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
  23. package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
  24. package/payload/premium-plugins/writer-craft/PLUGIN.md +4 -4
  25. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  26. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +44 -9
  27. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  28. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +31 -0
  29. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  30. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +28 -0
  31. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  32. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +7 -1
  33. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  34. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +93 -44
  35. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  36. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  37. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +1 -0
  38. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  39. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +7 -1
  40. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  41. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -3
  42. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  43. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +22 -8
  44. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  45. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +93 -84
  46. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  47. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +18 -0
  48. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  49. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +32 -3
  50. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
  51. package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +35 -2
  52. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +52 -10
  53. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +39 -0
  54. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +108 -44
  55. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +1 -0
  56. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +24 -4
  57. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +136 -102
  58. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +45 -3
  59. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +34 -23
  60. package/payload/platform/plugins/business-assistant/references/quote-engine.md +0 -122
  61. package/payload/platform/plugins/business-assistant/references/quote-generation.md +0 -94
  62. package/payload/platform/plugins/business-assistant/references/quoting.md +0 -85
  63. package/payload/platform/plugins/business-assistant/skills/pricing-method/SKILL.md +0 -78
  64. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/learning-from-history.md +0 -51
  65. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/maintenance.md +0 -32
  66. package/payload/platform/plugins/business-assistant/skills/pricing-method/references/manual-definition.md +0 -42
  67. 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.
@@ -1,122 +0,0 @@
1
- # Priced-quote compute engine
2
-
3
- This reference is the **compute core** for a structured, priced quote: it turns a job (the items the
4
- owner selected, with their measurements or counts) into priced figures — line prices, group
5
- subtotals, and a final total — computed by **this owner's own pricing method**.
6
-
7
- It carries no items, no units, no rates, no margins, no overheads, no taxes, and no roll-up sequence.
8
- Every such value and rule is the owner's, captured as data. A trade pricing by the square metre, a
9
- caterer pricing by the head, and a printer pricing by per-unit break are all priced by the same
10
- approach — only their captured method differs.
11
-
12
- The compute engine itself is **not shipped**. You generate a small bespoke engine for this owner the
13
- first time they need a priced quote, and regenerate it whenever their business changes. The captured
14
- method is data the engine reads. Both persist in the owner's workspace and are re-run on demand.
15
-
16
- ## When this path applies
17
-
18
- Use this for a **priced quote computed from the owner's method** — measured or counted work that has
19
- to be priced the way this owner prices it, then totalled. This is distinct from the simple
20
- photo-to-quote / chat-to-quote flow in `references/quoting.md`, which formats a quote the owner has
21
- already priced. If the owner has already worked out the numbers, stay in `quoting.md`. Come here when
22
- the numbers have to be **computed**.
23
-
24
- **Precondition:** a captured method for this owner must exist. Its *content* (the owner's items,
25
- rules, dials, roll-up, and past-job fixtures) is produced and maintained by the method-capture flow —
26
- Task 659 — which populates the method/job shape **this reference defines** below. If no captured method
27
- exists, capture it first; do not invent rates or rules here.
28
-
29
- ## Where the engine and method live
30
-
31
- The agent runs in the owner's workspace (the spawn cwd, `$ACCOUNT_DIR`). Keep the engine and the
32
- captured method together under a `quoting/` subdirectory there, addressed by relative path:
33
-
34
- - the **method** — the owner's items, dials, and roll-up, as data;
35
- - the **engine** — the bespoke script that reads a method and a job and prices it;
36
- - the owner's **past jobs**, each with the figures they actually issued, used to verify the engine.
37
-
38
- These persist across sessions, are edited when the business changes, and are the engine's only inputs.
39
- Nothing about the owner's business lives in this reference or in the plugin.
40
-
41
- ## The method shape (owner data)
42
-
43
- This section is the authoritative definition of the method/job shape; the method-capture flow (Task
44
- 659) fills it with one owner's content. The method describes how this owner prices. For each
45
- **priceable item** it records:
46
-
47
- - what the item is (an identifier, a human label, a unit);
48
- - **how a quantity arises** — the item's own rule for turning a measurement into a quantity (for
49
- example, an area from supplied dimensions, a length, a volume), or that the item carries **no
50
- measurement rule** because it is counted or taken as a single unit. This is the item's *default*
51
- quantity rule; a quantity entered on the job line always overrides it (see the job shape);
52
- - **how a line price is computed** — the owner's own rule for that item, in whatever form their
53
- history shows it takes;
54
- - which **group** the line rolls into (the owner's own section headings);
55
- - whether the item is **standing-rule priced** (a rule fixes its price) or **judgement priced** (the
56
- owner sets the price per job) — so the quote flow knows when it must ask the owner for a figure
57
- rather than computing one.
58
-
59
- Alongside the items the method holds:
60
-
61
- - named **dials** — the owner's own scalars that their rules reference (a labour day-rate, a margin
62
- multiplier, a percentage — whatever their method uses, named by them);
63
- - the **roll-up** — an ordered list of steps applied above the line items to reach the final total.
64
- Each step names its rule, the prior figures it reads, and the label of the figure it produces. This
65
- is whatever sequence the owner actually applies. It is **not** a fixed margin-then-overhead-then-tax
66
- chain: an owner may apply an overhead, a contingency, a discount, a tax, or nothing, in their own
67
- order. The engine implements exactly the step kinds this owner's roll-up uses, and no others.
68
-
69
- ## The job shape (per quote)
70
-
71
- A job is the work selected for one quote: a list of lines, each naming an item and supplying **either**
72
- measurements **or** a directly-entered quantity, plus — for a judgement-priced item — the price the
73
- owner set for this line. The quantity source is decided per line by precedence: a quantity entered on
74
- the line is used as-is; otherwise the engine derives the quantity from the line's measurements via the
75
- item's measurement rule. An item with no measurement rule therefore requires an entered quantity (or
76
- its single-unit default). The job also carries the header the issued quote needs (who it is for, the
77
- address, the reference).
78
-
79
- ## What the generated engine must do
80
-
81
- The engine reads one method and one job and produces the priced result. It must:
82
-
83
- 1. **Resolve each line's quantity** — an entered quantity takes precedence; otherwise derive it from
84
- the line's measurements via the item's measurement rule — and record which source was used.
85
- 2. **Resolve each line's price** — by the item's own rule, or by the per-line figure the owner gave for
86
- a judgement item — and record which was used.
87
- 3. **Group** the priced lines under the owner's own headings.
88
- 4. **Run the roll-up** steps in their defined order to reach the final total.
89
- 5. **Emit observability** so a wrong figure is diagnosable from the log, not just the document: per
90
- line, the resolved quantity and its source (measurement-derived vs entered) and the price rule
91
- applied and its source (item rule vs per-line override); and each roll-up step with its result.
92
- 6. **Reconcile** the sum of the group subtotals against the running line-item total and report it as an
93
- explicit pass/fail — not a silent assumption.
94
- 7. **Verify** on demand: given one of the owner's own past jobs, compare every computed figure
95
- (line, group, final) against the figures the owner actually issued and report each as a match or
96
- a mismatch.
97
-
98
- ## Expressiveness — bounded by the owner's history
99
-
100
- Build the engine to support **exactly** the quantity, pricing, and roll-up forms the owner's own
101
- history exercises — discovered when their method was captured. Do not add quantity bases, price-rule
102
- forms, or roll-up step kinds the owner does not use. Unused generality is a liability: it is untested
103
- against their figures and invites a wrong quote later. If a new job needs a form the method has never
104
- expressed, that is a change to the captured method (and a re-verification), not a guess made here.
105
-
106
- ## Acceptance — reproduction of the owner's own figures
107
-
108
- The engine is correct only when it **reproduces the owner's own past jobs** — line prices, group
109
- subtotals, and final totals — to the owner's own precision. The fixtures are that owner's
110
- history, supplied with their method; there are no built-in figures to test against. Reproducing their
111
- history, not parsing cleanly, is the signal that the engine prices the way they do. Verify on every
112
- generate and every method change; a reproduction failure blocks issuing a computed quote.
113
-
114
- ## Boundaries
115
-
116
- - **Capturing and maintaining** the content of the method (items, rules, dials, roll-up, past-job
117
- fixtures) is the method-capture flow — Task 659; it populates the shape this reference defines. This
118
- reference consumes that method; it does not build it.
119
- - **Rendering** the priced result into the documents the owner issues (internal view, client
120
- breakdown, branded quote) is Task 658. Those documents are printed to PDF through the existing
121
- `browser-pdf-save` path (see `references/invoicing.md`); this compute reference produces only the
122
- figures, not the documents.