@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.
- package/dist/index.js +16 -0
- package/package.json +1 -1
- package/payload/platform/lib/models/dist/index.d.ts +1 -1
- package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/models/dist/index.js +5 -2
- package/payload/platform/lib/models/dist/index.js.map +1 -1
- package/payload/platform/lib/models/src/index.ts +5 -2
- package/payload/platform/neo4j/schema.cypher +13 -0
- package/payload/platform/package-lock.json +16 -0
- package/payload/platform/package.json +3 -2
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js +9 -9
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js.map +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -3
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
- package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -5
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +9 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +36 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +59 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +19 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +31 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -0
- package/payload/platform/services/whatsapp-channel/package.json +20 -0
- package/payload/platform/templates/account.json +1 -1
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/platform/templates/specialists/agents/librarian.md +1 -1
- package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
- package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
- package/payload/premium-plugins/writer-craft/PLUGIN.md +4 -4
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +44 -9
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +31 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +28 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +93 -44
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +22 -8
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +93 -84
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +18 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +32 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +35 -2
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +52 -10
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +39 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +108 -44
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +1 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +24 -4
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +136 -102
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +45 -3
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +34 -23
- package/payload/server/{chunk-SOLVVUST.js → chunk-W4EM7RK4.js} +2 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +345 -14
- package/payload/platform/plugins/business-assistant/references/quote-engine.md +0 -122
- package/payload/platform/plugins/business-assistant/references/quote-generation.md +0 -94
- package/payload/platform/plugins/business-assistant/references/quoting.md +0 -85
- package/payload/platform/plugins/business-assistant/skills/pricing-method/SKILL.md +0 -78
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/learning-from-history.md +0 -51
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/maintenance.md +0 -32
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/manual-definition.md +0 -42
- 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"
|
|
75
|
-
*
|
|
76
|
-
* - "
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
132
|
+
const scope: VoiceScope = brief.scope ?? "personal";
|
|
133
|
+
const topic = (brief.topic ?? "").trim();
|
|
134
|
+
const usesTopic = topic.length > 0;
|
|
117
135
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
16
|
-
| `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (
|
|
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
|
-
- **`
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
##
|
|
189
|
+
## Org and personal scope
|
|
188
190
|
|
|
189
|
-
|
|
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>
|
|
196
|
-
| `[voice-distil] userId=<id> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
|
|
197
|
-
| `[voice-distil] skip reason=<below-threshold\|recent
|
|
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]
|
|
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.
|