@rubytech/create-maxy-code 0.1.174 → 0.1.177

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 (66) hide show
  1. package/dist/index.js +13 -4
  2. package/package.json +1 -1
  3. package/payload/platform/neo4j/schema.cypher +49 -10
  4. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +18 -16
  5. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +1 -1
  6. package/payload/platform/plugins/memory/mcp/dist/index.js +1 -1
  7. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts +11 -2
  9. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts.map +1 -1
  10. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.d.ts +2 -0
  11. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js +95 -0
  13. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js.map +1 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +1 -0
  15. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  16. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +98 -15
  17. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  18. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +3 -2
  19. package/payload/platform/plugins/substack-import/skills/substack-import/SKILL.md +1 -0
  20. package/payload/platform/scripts/setup-account.sh +8 -0
  21. package/payload/platform/scripts/verify-skill-tool-surface.sh +27 -8
  22. package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
  23. package/payload/premium-plugins/writer-craft/PLUGIN.md +11 -7
  24. package/payload/premium-plugins/writer-craft/hooks/__tests__/voice-session-end-text.test.sh +284 -0
  25. package/payload/premium-plugins/writer-craft/hooks/voice-session-end-text.sh +75 -0
  26. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts +3 -0
  27. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts.map +1 -0
  28. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js +58 -0
  29. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js.map +1 -0
  30. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  31. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +95 -18
  32. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  33. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +47 -6
  34. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  35. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +69 -6
  36. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  37. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +8 -2
  38. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  39. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +82 -53
  40. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  41. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts +23 -0
  42. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -0
  43. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +191 -0
  44. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -0
  45. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +6 -0
  46. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  47. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -6
  48. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  49. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +12 -1
  50. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  51. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +23 -12
  52. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  53. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +3 -0
  54. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  55. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +33 -5
  56. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
  57. package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +289 -28
  58. package/payload/premium-plugins/writer-craft/mcp/src/cli/ingest-session-text.ts +65 -0
  59. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +108 -18
  60. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +78 -6
  61. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +115 -56
  62. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +229 -0
  63. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +20 -6
  64. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +38 -13
  65. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +42 -5
  66. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +65 -31
@@ -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 { VOICE_CORPUS_WHERE } from "../lib/voice-corpus.js";
15
-
16
- // Task 424 — the Haiku style-card composition moved into the calling agent's
17
- // turn. The agent reads the corpus exemplars and feedback intents (this tool
18
- // returns them when called with `mode='sample'`), composes the style-card YAML,
19
- // then calls this tool again with `mode='write'` + `styleCardYaml` set.
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.
@@ -46,6 +60,11 @@ 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?: "below-threshold" | "recent" | "empty-corpus" | "missing-style-card";
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
- export async function voiceDistilProfile(
88
- params: VoiceDistilProfileParams,
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, userId, force = false } = params;
91
- if (!accountId || !userId) {
92
- throw new Error("voice-distil-profile: accountId and userId 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
128
  `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
102
- OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile)
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, userId },
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. Filter matches human-only + human-led-agent-assisted
113
- // across the four primary content labels plus conversation-document
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 ${VOICE_CORPUS_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=${userId} skip reason=empty-corpus\n`,
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=${userId} skip reason=${reason} corpusSize=${corpusSize} prevCorpus=${prevCorpusSize} ageDays=${ageDays.toFixed(1)}\n`,
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 ${VOICE_CORPUS_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 operator.
223
+ // 5. Pull feedback intents from existing :VoiceEdit nodes for this format.
209
224
  const feedback = await session.run(
210
- `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
211
- MATCH (e:VoiceEdit {userId: $userId, 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, userId },
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=${userId} mode=sample corpusSize=${corpusSize} ` +
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 + LEARNED_FROM edges to every sampled 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
- // userId, 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
262
  `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
258
- MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {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,
@@ -270,6 +275,7 @@ export async function voiceDistilProfile(
270
275
  {
271
276
  accountId,
272
277
  userId,
278
+ format,
273
279
  styleCard: styleCardYaml,
274
280
  now: nowIso,
275
281
  corpusSize,
@@ -277,8 +283,6 @@ 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
288
  `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before distillation.`,
@@ -287,7 +291,7 @@ export async function voiceDistilProfile(
287
291
  const profileId = writeResult.records[0].get("profileId") as string;
288
292
 
289
293
  process.stderr.write(
290
- `[voice-distil] adminUser=${userId} mode=write corpusSize=${corpusSize} generatedAt=${nowIso} feedbackEntries=${feedbackIntents.length}\n`,
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 profile
12
- * exists; otherwise write the :VoiceEdit alone so the next distillation
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, userId, originalText, editedText, intent: rawIntent, context } = params;
56
+ const { accountId, userId, originalText, editedText, format, intent: rawIntent, context } = params;
48
57
  if (!accountId || !userId) {
49
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(
@@ -70,6 +82,7 @@ export async function voiceRecordFeedback(
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,7 +92,7 @@ export async function voiceRecordFeedback(
79
92
  })
80
93
  CREATE (a)-[:AUTHORED]->(e)
81
94
  WITH e
82
- OPTIONAL MATCH (:AdminUser {accountId: $accountId, userId: $userId})-[:HAS_VOICE_PROFILE]->(p:VoiceProfile)
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
  )
@@ -87,6 +100,7 @@ export async function voiceRecordFeedback(
87
100
  {
88
101
  accountId,
89
102
  userId,
103
+ format,
90
104
  editId,
91
105
  originalText,
92
106
  editedText,
@@ -104,7 +118,7 @@ export async function voiceRecordFeedback(
104
118
  const hadProfile = result.records[0].get("hadProfile") === true;
105
119
 
106
120
  process.stderr.write(
107
- `[voice-record-feedback] adminUser=${userId} intent="${cappedIntent.replace(/"/g, "'")}" diffBytes=${editedText.length - originalText.length}\n`,
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 };