@rubytech/create-maxy-code 0.1.172 → 0.1.175
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 +12 -1
- package/package.json +1 -1
- package/payload/platform/neo4j/schema.cypher +49 -10
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +18 -16
- package/payload/platform/scripts/setup-account.sh +8 -0
- package/payload/platform/scripts/verify-skill-tool-surface.sh +117 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
- package/payload/platform/templates/specialists/agents/content-producer.md +4 -4
- package/payload/platform/templates/specialists/agents/librarian.md +2 -2
- package/payload/platform/templates/specialists/agents/personal-assistant.md +4 -4
- package/payload/platform/templates/specialists/agents/project-manager.md +2 -2
- package/payload/platform/templates/specialists/agents/research-assistant.md +2 -2
- package/payload/premium-plugins/writer-craft/PLUGIN.md +11 -7
- package/payload/premium-plugins/writer-craft/hooks/__tests__/voice-session-end-text.test.sh +284 -0
- package/payload/premium-plugins/writer-craft/hooks/voice-session-end-text.sh +75 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts +3 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js +58 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +113 -36
- 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 +47 -6
- 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 +69 -6
- 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 +9 -3
- 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 +86 -57
- 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 +23 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +191 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -0
- 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 +20 -12
- 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 +13 -2
- 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 +26 -15
- 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 +3 -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 +33 -5
- 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 +289 -28
- package/payload/premium-plugins/writer-craft/mcp/src/cli/ingest-session-text.ts +65 -0
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +126 -36
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +78 -6
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +120 -61
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +229 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +27 -13
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +42 -17
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +42 -5
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +65 -31
- package/payload/server/server.js +12 -3
|
@@ -2,21 +2,35 @@
|
|
|
2
2
|
* voice-distil-profile — walk the operator's `human-only` corpus and
|
|
3
3
|
* persist a `:VoiceProfile` style card.
|
|
4
4
|
*
|
|
5
|
+
* Task 462: one `:VoiceProfile` per `(accountId, userId, format)`.
|
|
6
|
+
* `format` ∈ `{text, email, social-post, article, marketing-copy, note}`.
|
|
7
|
+
*
|
|
8
|
+
* - When `format` is supplied, distils that format only.
|
|
9
|
+
* - When `format` is omitted, enumerates distinct format values present
|
|
10
|
+
* in the corpus (nodes where `n.format IS NOT NULL`) and distils each
|
|
11
|
+
* that fires the cadence guard. Returns `skipReason: 'no-format-tagged-content'`
|
|
12
|
+
* when enumeration yields zero formats.
|
|
13
|
+
*
|
|
5
14
|
* Cadence guard: re-runs on ≥20% corpus growth since last profile, OR
|
|
6
15
|
* ≥30 days since `generatedAt`. `force: true` overrides both.
|
|
7
16
|
*
|
|
8
17
|
* Body-text sampling caps at ~120k chars (rough proxy for tokens at
|
|
9
18
|
* 4 chars/token = 30k tokens) — well inside Haiku's 200k context window
|
|
10
19
|
* with headroom for the system prompt + tool schema.
|
|
20
|
+
*
|
|
21
|
+
* Task 424 — the Haiku style-card composition moved into the calling agent's
|
|
22
|
+
* turn. The agent reads the corpus exemplars and feedback intents (this tool
|
|
23
|
+
* returns them when called with `mode='sample'`), composes the style-card
|
|
24
|
+
* YAML, then calls this tool again with `mode='write'` + `styleCardYaml` set.
|
|
11
25
|
*/
|
|
12
26
|
import neo4j from "neo4j-driver";
|
|
13
27
|
import { getSession } from "../lib/neo4j.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
import {
|
|
29
|
+
VOICE_CORPUS_WHERE,
|
|
30
|
+
voiceCorpusWhereWithFormat,
|
|
31
|
+
FORMAT_VALUES,
|
|
32
|
+
type VoiceFormat,
|
|
33
|
+
} from "../lib/voice-corpus.js";
|
|
20
34
|
|
|
21
35
|
/**
|
|
22
36
|
* Coerce a neo4j cypher RETURN value to a plain JS number.
|
|
@@ -44,8 +58,13 @@ const RE_RUN_AGE_DAYS = 30;
|
|
|
44
58
|
|
|
45
59
|
export interface VoiceDistilProfileParams {
|
|
46
60
|
accountId: string;
|
|
47
|
-
|
|
61
|
+
userId: string;
|
|
48
62
|
force?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Writing format to distil. When omitted, enumerates distinct format
|
|
65
|
+
* values in the corpus and distils each that fires the cadence guard.
|
|
66
|
+
*/
|
|
67
|
+
format?: VoiceFormat;
|
|
49
68
|
/**
|
|
50
69
|
* Operation mode (Task 424):
|
|
51
70
|
* - `'sample'` (default) — read the corpus + feedback intents and return
|
|
@@ -71,7 +90,12 @@ export interface VoiceDistilProfileResult {
|
|
|
71
90
|
generatedAt: string | null;
|
|
72
91
|
feedbackEntries: number;
|
|
73
92
|
skipped: boolean;
|
|
74
|
-
skipReason?:
|
|
93
|
+
skipReason?:
|
|
94
|
+
| "below-threshold"
|
|
95
|
+
| "recent"
|
|
96
|
+
| "empty-corpus"
|
|
97
|
+
| "missing-style-card"
|
|
98
|
+
| "no-format-tagged-content";
|
|
75
99
|
errorReason?: string;
|
|
76
100
|
/** Present when `mode='sample'`: corpus exemplars for the agent to read. */
|
|
77
101
|
exemplars?: VoiceCorpusExemplar[];
|
|
@@ -84,46 +108,47 @@ export interface VoiceDistilProfileResult {
|
|
|
84
108
|
// openingPattern, closingPattern, exemplarSentences
|
|
85
109
|
// Exemplar sentences should be quoted verbatim from the returned corpus.
|
|
86
110
|
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Single-format distil. Opens a new session for each call.
|
|
113
|
+
*/
|
|
114
|
+
async function distilForFormat(
|
|
115
|
+
params: Required<Pick<VoiceDistilProfileParams, "accountId" | "userId">> &
|
|
116
|
+
Pick<VoiceDistilProfileParams, "force" | "mode" | "styleCardYaml"> & {
|
|
117
|
+
format: VoiceFormat;
|
|
118
|
+
},
|
|
89
119
|
): Promise<VoiceDistilProfileResult> {
|
|
90
|
-
const { accountId,
|
|
91
|
-
if (!accountId || !adminUserId) {
|
|
92
|
-
throw new Error("voice-distil-profile: accountId and adminUserId required");
|
|
93
|
-
}
|
|
120
|
+
const { accountId, userId, format, force = false } = params;
|
|
94
121
|
const session = getSession();
|
|
95
122
|
const now = new Date();
|
|
96
123
|
const nowIso = now.toISOString();
|
|
97
124
|
|
|
98
125
|
try {
|
|
99
|
-
// 1. Read existing profile (if any) for cadence-guard comparison.
|
|
126
|
+
// 1. Read existing per-format profile (if any) for cadence-guard comparison.
|
|
100
127
|
const existing = await session.run(
|
|
101
|
-
`MATCH (a:AdminUser {accountId: $accountId,
|
|
102
|
-
OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile)
|
|
128
|
+
`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
|
|
129
|
+
OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
|
|
103
130
|
RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize,
|
|
104
131
|
p.generatedAt AS generatedAt`,
|
|
105
|
-
{ accountId,
|
|
132
|
+
{ accountId, userId, format },
|
|
106
133
|
);
|
|
107
134
|
const existingRow = existing.records[0];
|
|
108
135
|
const prevProfileId = (existingRow?.get("profileId") as string | null) ?? null;
|
|
109
136
|
const prevCorpusSize = toJsNumber(existingRow?.get("corpusSize"));
|
|
110
137
|
const prevGeneratedAt = (existingRow?.get("generatedAt") as string | null) ?? null;
|
|
111
138
|
|
|
112
|
-
// 2. Count corpus
|
|
113
|
-
|
|
114
|
-
// :Section chunks (chat archives — children of a :KnowledgeDocument
|
|
115
|
-
// with `conversationIdentity` set). See VOICE_CORPUS_WHERE above.
|
|
139
|
+
// 2. Count corpus for this specific format.
|
|
140
|
+
const corpusWhere = voiceCorpusWhereWithFormat(format);
|
|
116
141
|
const countResult = await session.run(
|
|
117
142
|
`MATCH (n)
|
|
118
|
-
WHERE ${
|
|
143
|
+
WHERE ${corpusWhere}
|
|
119
144
|
RETURN count(n) AS c`,
|
|
120
|
-
{ accountId },
|
|
145
|
+
{ accountId, format },
|
|
121
146
|
);
|
|
122
147
|
const corpusSize = toJsNumber(countResult.records[0]?.get("c")) ?? 0;
|
|
123
148
|
|
|
124
149
|
if (corpusSize === 0) {
|
|
125
150
|
process.stderr.write(
|
|
126
|
-
`[voice-distil] adminUser=${
|
|
151
|
+
`[voice-distil] adminUser=${userId} format=${format} skip reason=empty-corpus\n`,
|
|
127
152
|
);
|
|
128
153
|
return {
|
|
129
154
|
profileId: prevProfileId,
|
|
@@ -139,9 +164,6 @@ export async function voiceDistilProfile(
|
|
|
139
164
|
if (!force && prevProfileId && prevCorpusSize !== null && prevGeneratedAt) {
|
|
140
165
|
const ageMs = now.getTime() - new Date(prevGeneratedAt).getTime();
|
|
141
166
|
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
|
142
|
-
// prevCorpusSize is non-null here (the guard above checks). When it's
|
|
143
|
-
// zero we treat growth as infinite — first real corpus always triggers
|
|
144
|
-
// a re-run.
|
|
145
167
|
const growth =
|
|
146
168
|
prevCorpusSize > 0
|
|
147
169
|
? (corpusSize - prevCorpusSize) / prevCorpusSize
|
|
@@ -151,7 +173,7 @@ export async function voiceDistilProfile(
|
|
|
151
173
|
if (!ageTrigger && !growthTrigger) {
|
|
152
174
|
const reason = ageDays < 1 ? "recent" : "below-threshold";
|
|
153
175
|
process.stderr.write(
|
|
154
|
-
`[voice-distil] adminUser=${
|
|
176
|
+
`[voice-distil] adminUser=${userId} format=${format} skip reason=${reason} corpusSize=${corpusSize} prevCorpus=${prevCorpusSize} ageDays=${ageDays.toFixed(1)}\n`,
|
|
155
177
|
);
|
|
156
178
|
return {
|
|
157
179
|
profileId: prevProfileId,
|
|
@@ -165,12 +187,9 @@ export async function voiceDistilProfile(
|
|
|
165
187
|
}
|
|
166
188
|
|
|
167
189
|
// 4. Sample corpus bodies up to the char cap. Pick recent first.
|
|
168
|
-
// Timestamp coalesces include `firstMessageAt` for conversation-document
|
|
169
|
-
// :Section chunks so chat archives sort by when the conversation
|
|
170
|
-
// actually happened.
|
|
171
190
|
const sampled = await session.run(
|
|
172
191
|
`MATCH (n)
|
|
173
|
-
WHERE ${
|
|
192
|
+
WHERE ${corpusWhere}
|
|
174
193
|
WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
|
|
175
194
|
OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
|
|
176
195
|
WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
|
|
@@ -181,17 +200,13 @@ export async function voiceDistilProfile(
|
|
|
181
200
|
ts
|
|
182
201
|
ORDER BY ts IS NULL, ts DESC
|
|
183
202
|
LIMIT 500`,
|
|
184
|
-
{ accountId },
|
|
203
|
+
{ accountId, format },
|
|
185
204
|
);
|
|
186
205
|
const samples: { id: string; label: string; body: string }[] = [];
|
|
187
206
|
let charBudget = SAMPLE_CHAR_CAP;
|
|
188
207
|
for (const r of sampled.records) {
|
|
189
208
|
const body = (r.get("body") as string | null) ?? "";
|
|
190
209
|
const labels = (r.get("labels") as string[]) ?? [];
|
|
191
|
-
// Conversation-document sections carry `:Section` only after the Task
|
|
192
|
-
// 324 collapse; surface "Conversation" via the parent's
|
|
193
|
-
// `conversationIdentity` so the LLM exemplar header still says "this
|
|
194
|
-
// is chat" rather than the bare base label.
|
|
195
210
|
const isConversationChunk = Boolean(r.get("isConversationChunk"));
|
|
196
211
|
const label = isConversationChunk
|
|
197
212
|
? "Conversation"
|
|
@@ -205,26 +220,21 @@ export async function voiceDistilProfile(
|
|
|
205
220
|
if (charBudget <= 0) break;
|
|
206
221
|
}
|
|
207
222
|
|
|
208
|
-
// 5. Pull feedback intents from existing :VoiceEdit nodes for this
|
|
223
|
+
// 5. Pull feedback intents from existing :VoiceEdit nodes for this format.
|
|
209
224
|
const feedback = await session.run(
|
|
210
|
-
`MATCH (
|
|
211
|
-
MATCH (e:VoiceEdit {adminUserId: $adminUserId, accountId: $accountId})
|
|
225
|
+
`MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
|
|
212
226
|
RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`,
|
|
213
|
-
{ accountId,
|
|
227
|
+
{ accountId, userId, format },
|
|
214
228
|
);
|
|
215
229
|
const feedbackIntents = feedback.records
|
|
216
230
|
.map((r) => r.get("intent") as string | null)
|
|
217
231
|
.filter((x): x is string => typeof x === "string" && x.length > 0);
|
|
218
232
|
|
|
219
233
|
// 6. Mode branch (Task 424).
|
|
220
|
-
// - `sample` (default): return the exemplars + feedback intents to the
|
|
221
|
-
// calling agent. The agent composes the YAML style card in its own
|
|
222
|
-
// turn and re-invokes with mode='write'.
|
|
223
|
-
// - `write`: persist the agent-produced style card.
|
|
224
234
|
const mode = params.mode ?? "sample";
|
|
225
235
|
if (mode === "sample") {
|
|
226
236
|
process.stderr.write(
|
|
227
|
-
`[voice-distil] adminUser=${
|
|
237
|
+
`[voice-distil] adminUser=${userId} format=${format} mode=sample corpusSize=${corpusSize} ` +
|
|
228
238
|
`exemplars=${samples.length} feedbackEntries=${feedbackIntents.length}\n`,
|
|
229
239
|
);
|
|
230
240
|
return {
|
|
@@ -246,16 +256,11 @@ export async function voiceDistilProfile(
|
|
|
246
256
|
);
|
|
247
257
|
}
|
|
248
258
|
|
|
249
|
-
// 7. Write the :VoiceProfile node
|
|
250
|
-
//
|
|
251
|
-
// The :AdminUser must already exist (onboarding/auth is the only
|
|
252
|
-
// legitimate origin for :AdminUser). MATCH not MERGE: a typo in
|
|
253
|
-
// adminUserId, an env mismatch, or a dropped credential surfaces
|
|
254
|
-
// here as a typed error rather than silently minting an orphan
|
|
255
|
-
// AdminUser that bypasses the platform's label-origin protection.
|
|
259
|
+
// 7. Write the :VoiceProfile node — keyed on (accountId, userId, format) —
|
|
260
|
+
// plus LEARNED_FROM edges to every sampled node.
|
|
256
261
|
const writeResult = await session.run(
|
|
257
|
-
`MATCH (a:AdminUser {accountId: $accountId,
|
|
258
|
-
MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId,
|
|
262
|
+
`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
|
|
263
|
+
MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
|
|
259
264
|
ON CREATE SET p.createdAt = $now
|
|
260
265
|
SET p.styleCard = $styleCard,
|
|
261
266
|
p.generatedAt = $now,
|
|
@@ -269,7 +274,8 @@ export async function voiceDistilProfile(
|
|
|
269
274
|
RETURN elementId(p) AS profileId`,
|
|
270
275
|
{
|
|
271
276
|
accountId,
|
|
272
|
-
|
|
277
|
+
userId,
|
|
278
|
+
format,
|
|
273
279
|
styleCard: styleCardYaml,
|
|
274
280
|
now: nowIso,
|
|
275
281
|
corpusSize,
|
|
@@ -277,17 +283,15 @@ export async function voiceDistilProfile(
|
|
|
277
283
|
sampleIds: samples.map((s) => s.id),
|
|
278
284
|
},
|
|
279
285
|
);
|
|
280
|
-
// MATCH-on-:AdminUser failure yields zero records. Throw the typed
|
|
281
|
-
// error here rather than returning a half-baked result.
|
|
282
286
|
if (writeResult.records.length === 0) {
|
|
283
287
|
throw new Error(
|
|
284
|
-
`voice-mirror: :AdminUser {accountId='${accountId}',
|
|
288
|
+
`voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before distillation.`,
|
|
285
289
|
);
|
|
286
290
|
}
|
|
287
291
|
const profileId = writeResult.records[0].get("profileId") as string;
|
|
288
292
|
|
|
289
293
|
process.stderr.write(
|
|
290
|
-
`[voice-distil] adminUser=${
|
|
294
|
+
`[voice-distil] adminUser=${userId} format=${format} mode=write corpusSize=${corpusSize} generatedAt=${nowIso} feedbackEntries=${feedbackIntents.length}\n`,
|
|
291
295
|
);
|
|
292
296
|
|
|
293
297
|
return {
|
|
@@ -301,3 +305,58 @@ export async function voiceDistilProfile(
|
|
|
301
305
|
await session.close();
|
|
302
306
|
}
|
|
303
307
|
}
|
|
308
|
+
|
|
309
|
+
export async function voiceDistilProfile(
|
|
310
|
+
params: VoiceDistilProfileParams,
|
|
311
|
+
): Promise<VoiceDistilProfileResult | VoiceDistilProfileResult[]> {
|
|
312
|
+
const { accountId, userId, force = false } = params;
|
|
313
|
+
if (!accountId || !userId) {
|
|
314
|
+
throw new Error("voice-distil-profile: accountId and userId required");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Single-format path.
|
|
318
|
+
if (params.format !== undefined) {
|
|
319
|
+
return distilForFormat({ ...params, format: params.format, force });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Multi-format path: enumerate distinct format values present in the corpus.
|
|
323
|
+
const session = getSession();
|
|
324
|
+
let formats: VoiceFormat[] = [];
|
|
325
|
+
try {
|
|
326
|
+
const enumResult = await session.run(
|
|
327
|
+
`MATCH (n)
|
|
328
|
+
WHERE ${VOICE_CORPUS_WHERE} AND n.format IS NOT NULL
|
|
329
|
+
RETURN DISTINCT n.format AS fmt`,
|
|
330
|
+
{ accountId },
|
|
331
|
+
);
|
|
332
|
+
formats = enumResult.records
|
|
333
|
+
.map((r) => r.get("fmt") as string)
|
|
334
|
+
.filter((f): f is VoiceFormat =>
|
|
335
|
+
(FORMAT_VALUES as readonly string[]).includes(f),
|
|
336
|
+
);
|
|
337
|
+
} finally {
|
|
338
|
+
await session.close();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (formats.length === 0) {
|
|
342
|
+
process.stderr.write(
|
|
343
|
+
`[voice-distil] adminUser=${userId} skip reason=no-format-tagged-content\n`,
|
|
344
|
+
);
|
|
345
|
+
return {
|
|
346
|
+
profileId: null,
|
|
347
|
+
corpusSize: 0,
|
|
348
|
+
generatedAt: null,
|
|
349
|
+
feedbackEntries: 0,
|
|
350
|
+
skipped: true,
|
|
351
|
+
skipReason: "no-format-tagged-content",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Distil each discovered format sequentially.
|
|
356
|
+
const results: VoiceDistilProfileResult[] = [];
|
|
357
|
+
for (const format of formats) {
|
|
358
|
+
const result = await distilForFormat({ ...params, format, force });
|
|
359
|
+
results.push(result);
|
|
360
|
+
}
|
|
361
|
+
return results;
|
|
362
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voice-ingest-session-text — extract operator turns from a Claude Code
|
|
3
|
+
* session JSONL and write them as :Message {format:'text', source:'admin-pty'}
|
|
4
|
+
* corpus nodes.
|
|
5
|
+
*
|
|
6
|
+
* Called by the SessionEnd hook (via the CLI wrapper) after each admin PTY
|
|
7
|
+
* session. Re-running against the same JSONL is a no-op — MERGE on
|
|
8
|
+
* (contentHash, accountId) is the deduplication primitive.
|
|
9
|
+
*
|
|
10
|
+
* After ingest, triggers voice-distil-profile {format:'text'} so the cadence
|
|
11
|
+
* guard decides whether distillation runs.
|
|
12
|
+
*
|
|
13
|
+
* JSONL record shape expected (Claude Code session format):
|
|
14
|
+
* { "type": "user" | "assistant", "message": { "role": "...", "content": ... }, ... }
|
|
15
|
+
*
|
|
16
|
+
* "Real operator text" extraction rules (excludes):
|
|
17
|
+
* - type != "user" or message.role != "user" → not the operator
|
|
18
|
+
* - content is tool_result-only → tool reply, not turn
|
|
19
|
+
* - text starts with "/" → slash command
|
|
20
|
+
* - text contains "<system-reminder>" → harness injection
|
|
21
|
+
* - text.length >= 4096 → likely pasted content
|
|
22
|
+
* - text is empty after trimming → nothing to ingest
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { createReadStream } from "node:fs";
|
|
26
|
+
import { createInterface } from "node:readline";
|
|
27
|
+
import { getSession } from "../lib/neo4j.js";
|
|
28
|
+
import { voiceDistilProfile } from "./voice-distil-profile.js";
|
|
29
|
+
|
|
30
|
+
export interface VoiceIngestSessionTextParams {
|
|
31
|
+
sessionJsonlPath: string;
|
|
32
|
+
accountId: string;
|
|
33
|
+
userId: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VoiceIngestSessionTextResult {
|
|
38
|
+
ingestedCount: number;
|
|
39
|
+
skippedCount: number;
|
|
40
|
+
sessionId: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts text content from a "real operator" user record.
|
|
45
|
+
* Returns null when the record should be skipped.
|
|
46
|
+
*/
|
|
47
|
+
export function extractOperatorText(record: unknown): string | null {
|
|
48
|
+
if (!record || typeof record !== "object") return null;
|
|
49
|
+
const rec = record as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
if (rec["type"] !== "user") return null;
|
|
52
|
+
|
|
53
|
+
const msg = rec["message"];
|
|
54
|
+
if (!msg || typeof msg !== "object") return null;
|
|
55
|
+
const message = msg as Record<string, unknown>;
|
|
56
|
+
if (message["role"] !== "user") return null;
|
|
57
|
+
|
|
58
|
+
const content = message["content"];
|
|
59
|
+
let text = "";
|
|
60
|
+
|
|
61
|
+
if (typeof content === "string") {
|
|
62
|
+
text = content;
|
|
63
|
+
} else if (Array.isArray(content)) {
|
|
64
|
+
// Must have at least one non-tool_result block to be a real turn.
|
|
65
|
+
const nonToolResult = content.filter(
|
|
66
|
+
(b) => typeof b === "object" && b !== null && (b as Record<string, unknown>)["type"] !== "tool_result",
|
|
67
|
+
);
|
|
68
|
+
if (nonToolResult.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
// Collect text from all text-type content blocks.
|
|
71
|
+
const parts: string[] = [];
|
|
72
|
+
for (const block of content) {
|
|
73
|
+
if (typeof block !== "object" || block === null) continue;
|
|
74
|
+
const b = block as Record<string, unknown>;
|
|
75
|
+
if (b["type"] === "text" && typeof b["text"] === "string") {
|
|
76
|
+
parts.push(b["text"]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
text = parts.join("");
|
|
80
|
+
} else {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
text = text.trim();
|
|
85
|
+
if (text.length === 0) return null;
|
|
86
|
+
|
|
87
|
+
// Exclusions.
|
|
88
|
+
if (text.startsWith("/")) return null; // slash command
|
|
89
|
+
if (text.includes("<system-reminder>")) return null; // harness injection
|
|
90
|
+
if (text.length >= 4096) return null; // paste block
|
|
91
|
+
|
|
92
|
+
return text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a session JSONL file and return all real operator turn texts.
|
|
97
|
+
* Exported for testability (no DB required).
|
|
98
|
+
*/
|
|
99
|
+
export async function extractOperatorTurns(jsonlPath: string): Promise<string[]> {
|
|
100
|
+
const turns: string[] = [];
|
|
101
|
+
|
|
102
|
+
const rl = createInterface({
|
|
103
|
+
input: createReadStream(jsonlPath, { encoding: "utf-8" }),
|
|
104
|
+
crlfDelay: Infinity,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for await (const line of rl) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (!trimmed) continue;
|
|
110
|
+
let record: unknown;
|
|
111
|
+
try {
|
|
112
|
+
record = JSON.parse(trimmed);
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const text = extractOperatorText(record);
|
|
117
|
+
if (text !== null) {
|
|
118
|
+
turns.push(text);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return turns;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function voiceIngestSessionText(
|
|
126
|
+
params: VoiceIngestSessionTextParams,
|
|
127
|
+
): Promise<VoiceIngestSessionTextResult> {
|
|
128
|
+
const { sessionJsonlPath, accountId, userId, sessionId = null } = params;
|
|
129
|
+
|
|
130
|
+
if (!accountId || !userId) {
|
|
131
|
+
throw new Error("voice-ingest-session-text: accountId and userId required");
|
|
132
|
+
}
|
|
133
|
+
if (!sessionJsonlPath) {
|
|
134
|
+
throw new Error("voice-ingest-session-text: sessionJsonlPath required");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 1. Extract operator turns from the JSONL.
|
|
138
|
+
let allTurns: string[];
|
|
139
|
+
try {
|
|
140
|
+
allTurns = await extractOperatorTurns(sessionJsonlPath);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`voice-ingest-session-text: failed to read JSONL at '${sessionJsonlPath}': ${
|
|
144
|
+
err instanceof Error ? err.message : String(err)
|
|
145
|
+
}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (allTurns.length === 0) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`[voice-ingest-session-text] sessionId=${sessionId ?? "unknown"} adminUser=${userId} turns=0 bytes=0\n`,
|
|
152
|
+
);
|
|
153
|
+
return { ingestedCount: 0, skippedCount: 0, sessionId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Write each turn as a :Message node. MERGE on contentHash+accountId dedupes.
|
|
157
|
+
const session = getSession();
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
let ingestedCount = 0;
|
|
160
|
+
let skippedCount = 0;
|
|
161
|
+
let totalBytes = 0;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
for (const body of allTurns) {
|
|
165
|
+
const contentHash = createHash("sha256")
|
|
166
|
+
.update(body, "utf-8")
|
|
167
|
+
.digest("hex");
|
|
168
|
+
|
|
169
|
+
const result = await session.run(
|
|
170
|
+
`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
|
|
171
|
+
MERGE (n:Message {contentHash: $contentHash, accountId: $accountId})
|
|
172
|
+
ON CREATE SET
|
|
173
|
+
n.userId = $userId,
|
|
174
|
+
n.body = $body,
|
|
175
|
+
n.occurredAt = $now,
|
|
176
|
+
n.sessionId = $sessionId,
|
|
177
|
+
n.source = 'admin-pty',
|
|
178
|
+
n.authorshipMode = 'human-only',
|
|
179
|
+
n.format = 'text',
|
|
180
|
+
n.createdAt = $now
|
|
181
|
+
MERGE (a)-[:AUTHORED]->(n)
|
|
182
|
+
RETURN n.createdAt = $now AS isNew`,
|
|
183
|
+
{
|
|
184
|
+
accountId,
|
|
185
|
+
userId,
|
|
186
|
+
contentHash,
|
|
187
|
+
body,
|
|
188
|
+
now,
|
|
189
|
+
sessionId,
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (result.records.length === 0) {
|
|
194
|
+
// :AdminUser not found — abort rather than orphan nodes.
|
|
195
|
+
throw new Error(
|
|
196
|
+
`voice-ingest-session-text: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before ingest.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const isNew = result.records[0].get("isNew") === true;
|
|
201
|
+
if (isNew) {
|
|
202
|
+
ingestedCount++;
|
|
203
|
+
totalBytes += body.length;
|
|
204
|
+
} else {
|
|
205
|
+
skippedCount++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
await session.close();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
`[voice-ingest-session-text] sessionId=${sessionId ?? "unknown"} adminUser=${userId} turns=${ingestedCount} bytes=${totalBytes} skipped=${skippedCount}\n`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// 3. Trigger text-format distillation (cadence guard decides whether to run).
|
|
217
|
+
if (ingestedCount > 0) {
|
|
218
|
+
try {
|
|
219
|
+
await voiceDistilProfile({ accountId, userId, format: "text" });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Distil failure must not fail the ingest.
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`[voice-ingest-session-text] distil-after-ingest error: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { ingestedCount, skippedCount, sessionId };
|
|
229
|
+
}
|
|
@@ -7,19 +7,28 @@
|
|
|
7
7
|
* and passes the resulting `intent` string as a required parameter. The
|
|
8
8
|
* tool now does the graph writes only.
|
|
9
9
|
*
|
|
10
|
+
* Task 462: `format` is required. `:VoiceEdit` carries the format; the
|
|
11
|
+
* `FEEDBACK_FOR` edge targets the per-format `:VoiceProfile`.
|
|
12
|
+
*
|
|
10
13
|
* Side effects:
|
|
11
|
-
* 1. Write `(:VoiceEdit)-[:FEEDBACK_FOR]->(:VoiceProfile)` if the
|
|
12
|
-
* exists; otherwise write the :VoiceEdit alone so the
|
|
13
|
-
* back-fills the edge.
|
|
14
|
+
* 1. Write `(:VoiceEdit)-[:FEEDBACK_FOR]->(:VoiceProfile {format})` if the
|
|
15
|
+
* per-format profile exists; otherwise write the :VoiceEdit alone so the
|
|
16
|
+
* next distillation back-fills the edge.
|
|
14
17
|
*/
|
|
15
18
|
import { randomUUID } from "node:crypto";
|
|
16
19
|
import { getSession } from "../lib/neo4j.js";
|
|
20
|
+
import type { VoiceFormat } from "../lib/voice-corpus.js";
|
|
17
21
|
|
|
18
22
|
export interface VoiceRecordFeedbackParams {
|
|
19
23
|
accountId: string;
|
|
20
|
-
|
|
24
|
+
userId: string;
|
|
21
25
|
originalText: string;
|
|
22
26
|
editedText: string;
|
|
27
|
+
/**
|
|
28
|
+
* Writing format of the draft being edited. Routes feedback to the
|
|
29
|
+
* matching per-format :VoiceProfile.
|
|
30
|
+
*/
|
|
31
|
+
format: VoiceFormat;
|
|
23
32
|
/**
|
|
24
33
|
* One sentence describing what changed and what voice preference it reveals.
|
|
25
34
|
* The calling agent summarises this from `originalText` vs `editedText` in
|
|
@@ -44,13 +53,16 @@ export interface VoiceRecordFeedbackResult {
|
|
|
44
53
|
export async function voiceRecordFeedback(
|
|
45
54
|
params: VoiceRecordFeedbackParams,
|
|
46
55
|
): Promise<VoiceRecordFeedbackResult> {
|
|
47
|
-
const { accountId,
|
|
48
|
-
if (!accountId || !
|
|
49
|
-
throw new Error("voice-record-feedback: accountId and
|
|
56
|
+
const { accountId, userId, originalText, editedText, format, intent: rawIntent, context } = params;
|
|
57
|
+
if (!accountId || !userId) {
|
|
58
|
+
throw new Error("voice-record-feedback: accountId and userId required");
|
|
50
59
|
}
|
|
51
60
|
if (!originalText || !editedText) {
|
|
52
61
|
throw new Error("voice-record-feedback: originalText and editedText required");
|
|
53
62
|
}
|
|
63
|
+
if (!format) {
|
|
64
|
+
throw new Error("voice-record-feedback: format is required");
|
|
65
|
+
}
|
|
54
66
|
const intent = (rawIntent ?? "").trim();
|
|
55
67
|
if (!intent) {
|
|
56
68
|
throw new Error(
|
|
@@ -65,11 +77,12 @@ export async function voiceRecordFeedback(
|
|
|
65
77
|
const session = getSession();
|
|
66
78
|
try {
|
|
67
79
|
const result = await session.run(
|
|
68
|
-
`MATCH (a:AdminUser {accountId: $accountId,
|
|
80
|
+
`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
|
|
69
81
|
CREATE (e:VoiceEdit {
|
|
70
82
|
editId: $editId,
|
|
71
83
|
accountId: $accountId,
|
|
72
|
-
|
|
84
|
+
userId: $userId,
|
|
85
|
+
format: $format,
|
|
73
86
|
originalText: $originalText,
|
|
74
87
|
editedText: $editedText,
|
|
75
88
|
intent: $intent,
|
|
@@ -79,14 +92,15 @@ export async function voiceRecordFeedback(
|
|
|
79
92
|
})
|
|
80
93
|
CREATE (a)-[:AUTHORED]->(e)
|
|
81
94
|
WITH e
|
|
82
|
-
OPTIONAL MATCH (:AdminUser {accountId: $accountId,
|
|
95
|
+
OPTIONAL MATCH (:AdminUser {accountId: $accountId, userId: $userId})-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
|
|
83
96
|
FOREACH (_ IN CASE WHEN p IS NULL THEN [] ELSE [1] END |
|
|
84
97
|
MERGE (e)-[:FEEDBACK_FOR]->(p)
|
|
85
98
|
)
|
|
86
99
|
RETURN elementId(e) AS editId, (p IS NOT NULL) AS hadProfile`,
|
|
87
100
|
{
|
|
88
101
|
accountId,
|
|
89
|
-
|
|
102
|
+
userId,
|
|
103
|
+
format,
|
|
90
104
|
editId,
|
|
91
105
|
originalText,
|
|
92
106
|
editedText,
|
|
@@ -98,13 +112,13 @@ export async function voiceRecordFeedback(
|
|
|
98
112
|
);
|
|
99
113
|
if (result.records.length === 0) {
|
|
100
114
|
throw new Error(
|
|
101
|
-
`voice-mirror: :AdminUser {accountId='${accountId}',
|
|
115
|
+
`voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before feedback can be recorded.`,
|
|
102
116
|
);
|
|
103
117
|
}
|
|
104
118
|
const hadProfile = result.records[0].get("hadProfile") === true;
|
|
105
119
|
|
|
106
120
|
process.stderr.write(
|
|
107
|
-
`[voice-record-feedback] adminUser=${
|
|
121
|
+
`[voice-record-feedback] adminUser=${userId} format=${format} intent="${cappedIntent.replace(/"/g, "'")}" diffBytes=${editedText.length - originalText.length}\n`,
|
|
108
122
|
);
|
|
109
123
|
|
|
110
124
|
return { editId, intent: cappedIntent, occurredAt, hadProfile };
|