@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
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI wrapper for voice-ingest-session-text.
4
+ *
5
+ * Invoked by the SessionEnd hook after each admin PTY session:
6
+ * node dist/cli/ingest-session-text.js <transcript_path>
7
+ *
8
+ * All other parameters are read from environment variables inherited from
9
+ * the PTY session (which inherits from the session manager's systemd unit):
10
+ *
11
+ * ACCOUNT_ID — operator's account identifier
12
+ * USER_ID — operator's userId (= adminUserId)
13
+ * SESSION_ID — session identifier (parsed by hook from stdin)
14
+ * NEO4J_URI — neo4j bolt URI
15
+ * NEO4J_USER — neo4j username
16
+ * NEO4J_PASSWORD — neo4j password
17
+ *
18
+ * Exit codes:
19
+ * 0 — success (includes no-new-turns case)
20
+ * 1 — error (missing required env, JSONL not found, DB error)
21
+ *
22
+ * stdout: one-line result summary ("turns=N skipped=M") for the hook to
23
+ * capture and forward to the log-ingest endpoint.
24
+ * stderr: detailed diagnostics (tool log lines).
25
+ */
26
+ import { voiceIngestSessionText } from "../tools/voice-ingest-session-text.js";
27
+ import { closeDriver } from "../lib/neo4j.js";
28
+
29
+ const transcriptPath = process.argv[2];
30
+ if (!transcriptPath) {
31
+ process.stderr.write("[ingest-session-text] error: transcript path required as argv[2]\n");
32
+ process.exit(1);
33
+ }
34
+
35
+ const accountId = process.env.ACCOUNT_ID ?? "";
36
+ const userId = process.env.USER_ID ?? "";
37
+ const sessionId = process.env.SESSION_ID ?? undefined;
38
+
39
+ if (!accountId || !userId) {
40
+ process.stderr.write(
41
+ `[ingest-session-text] error: ACCOUNT_ID and USER_ID must be set in env (accountId=${accountId || "empty"} userId=${userId || "empty"})\n`,
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ try {
47
+ const result = await voiceIngestSessionText({
48
+ sessionJsonlPath: transcriptPath,
49
+ accountId,
50
+ userId,
51
+ sessionId,
52
+ });
53
+ // Emit one-line summary to stdout for the hook to capture.
54
+ process.stdout.write(
55
+ `turns=${result.ingestedCount} skipped=${result.skippedCount} sessionId=${result.sessionId ?? "unknown"}\n`,
56
+ );
57
+ process.exit(0);
58
+ } catch (err) {
59
+ process.stderr.write(
60
+ `[ingest-session-text] error: ${err instanceof Error ? err.message : String(err)}\n`,
61
+ );
62
+ process.exit(1);
63
+ } finally {
64
+ await closeDriver();
65
+ }
@@ -8,6 +8,8 @@ import { voiceTagContent, AUTHORSHIP_MODES, type AuthorshipMode } from "./tools/
8
8
  import { voiceDistilProfile } from "./tools/voice-distil-profile.js";
9
9
  import { voiceRetrieveConditioning } from "./tools/voice-retrieve-conditioning.js";
10
10
  import { voiceRecordFeedback } from "./tools/voice-record-feedback.js";
11
+ import { voiceIngestSessionText } from "./tools/voice-ingest-session-text.js";
12
+ import { FORMAT_VALUES } from "./lib/voice-corpus.js";
11
13
  import { closeDriver } from "./lib/neo4j.js";
12
14
 
13
15
  const server = new McpServer({
@@ -46,9 +48,14 @@ function resolveUserId(): string | null {
46
48
  return userIdEnv;
47
49
  }
48
50
 
51
+ // Format enum schema — shared across tools that accept VoiceFormat.
52
+ const zFormat = z.enum([...FORMAT_VALUES] as [string, ...string[]]).describe(
53
+ "Writing format: text | email | social-post | article | marketing-copy | note. One :VoiceProfile per (accountId, userId, format).",
54
+ );
55
+
49
56
  server.tool(
50
57
  "voice-tag-content",
51
- "Stamp authorshipMode on one or many content nodes (:KnowledgeDocument | :Message | :SocialPost) the operator owns. Email threads live as :KnowledgeDocument {source:'email'} since Task 321. Valid modes: human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown.",
58
+ "Stamp authorshipMode (and optionally format) on one or many content nodes (:KnowledgeDocument | :Message | :SocialPost) the operator owns. Email threads live as :KnowledgeDocument {source:'email'} since Task 321. Valid modes: human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown. Valid formats: text, email, social-post, article, marketing-copy, note. When format is omitted, it is derived per-node from labels and source property.",
52
59
  {
53
60
  nodeIds: z.array(z.string()).min(1).describe("Element IDs of nodes to tag"),
54
61
  mode: z
@@ -60,20 +67,24 @@ server.tool(
60
67
  "unknown",
61
68
  ])
62
69
  .describe("Authorship mode to apply"),
70
+ format: zFormat.optional().describe(
71
+ "Writing format. When omitted, derived from each node's labels and source property.",
72
+ ),
63
73
  },
64
- async ({ nodeIds, mode }) => {
74
+ async ({ nodeIds, mode, format }) => {
65
75
  if (!accountId) return refuseNoAccount("voice-tag-content");
66
76
  try {
67
77
  const result = await voiceTagContent({
68
78
  nodeIds,
69
79
  mode: mode as AuthorshipMode,
70
80
  accountId,
81
+ format: format as import("./lib/voice-corpus.js").VoiceFormat | undefined,
71
82
  });
72
83
  return {
73
84
  content: [
74
85
  {
75
86
  type: "text" as const,
76
- text: `voice-tag: mode=${mode} updated=${result.updated} skipped=${result.skipped}`,
87
+ text: `voice-tag: mode=${mode} format=${format ?? "derived"} updated=${result.updated} skipped=${result.skipped}`,
77
88
  },
78
89
  ],
79
90
  };
@@ -93,7 +104,7 @@ server.tool(
93
104
 
94
105
  server.tool(
95
106
  "voice-distil-profile",
96
- "Two-mode tool (Task 424). mode='sample' (default) walks the operator's human-only corpus, returns the exemplars + recent operator-edit intents for the agent to read. mode='write' persists the YAML style card the agent composed from the sample-mode response. Cadence-guarded: returns skip in 'write' mode without writing if corpus didn't grow ≥20% AND <30 days passed (pass force=true to override).",
107
+ "Two-mode tool (Task 424). mode='sample' (default) walks the operator's per-format human-only corpus, returns exemplars + recent edit intents for the agent to read. mode='write' persists the YAML style card the agent composed. Cadence-guarded: skips in 'write' mode without writing if corpus didn't grow ≥20% AND <30 days passed (pass force=true to override). When format is omitted, enumerates all formats in the corpus and distils each.",
97
108
  {
98
109
  userId: z
99
110
  .string()
@@ -101,6 +112,9 @@ server.tool(
101
112
  .describe(
102
113
  "Operator AdminUser identifier. Defaults to ADMIN_USER_ID env from spawn context.",
103
114
  ),
115
+ format: zFormat.optional().describe(
116
+ "Writing format to distil. When omitted, enumerates corpus formats and distils each that fires the cadence guard.",
117
+ ),
104
118
  force: z
105
119
  .boolean()
106
120
  .optional()
@@ -114,7 +128,7 @@ server.tool(
114
128
  .optional()
115
129
  .describe("Required for mode='write'. The YAML style card the agent composed from the sample-mode exemplars + feedback intents."),
116
130
  },
117
- async ({ userId, force, mode, styleCardYaml }) => {
131
+ async ({ userId, format, force, mode, styleCardYaml }) => {
118
132
  if (!accountId) return refuseNoAccount("voice-distil-profile");
119
133
  const resolvedUserId = userId ?? resolveUserId();
120
134
  if (!resolvedUserId) {
@@ -133,15 +147,31 @@ server.tool(
133
147
  const result = await voiceDistilProfile({
134
148
  accountId,
135
149
  userId: resolvedUserId,
150
+ format: format as import("./lib/voice-corpus.js").VoiceFormat | undefined,
136
151
  force: force === true,
137
152
  mode,
138
153
  styleCardYaml,
139
154
  });
140
- const summary = result.skipped
141
- ? (result.exemplars
142
- ? `voice-distil sample: corpusSize=${result.corpusSize} exemplars=${result.exemplars.length} feedbackEntries=${result.feedbackEntries}\n${JSON.stringify({ exemplars: result.exemplars, feedbackIntents: result.feedbackIntents }, null, 2)}`
143
- : `voice-distil: skipped reason=${result.skipReason} corpusSize=${result.corpusSize}`)
144
- : `voice-distil: ok profileId=${result.profileId} corpusSize=${result.corpusSize} feedbackEntries=${result.feedbackEntries} generatedAt=${result.generatedAt}`;
155
+
156
+ // Summarise single-format or multi-format result.
157
+ const summariseOne = (r: import("./tools/voice-distil-profile.js").VoiceDistilProfileResult, fmt?: string): string => {
158
+ const tag = fmt ? ` format=${fmt}` : "";
159
+ if (r.skipped && r.exemplars) {
160
+ return `voice-distil sample${tag}: corpusSize=${r.corpusSize} exemplars=${r.exemplars.length} feedbackEntries=${r.feedbackEntries}\n${JSON.stringify({ exemplars: r.exemplars, feedbackIntents: r.feedbackIntents }, null, 2)}`;
161
+ }
162
+ if (r.skipped) {
163
+ return `voice-distil${tag}: skipped reason=${r.skipReason} corpusSize=${r.corpusSize}`;
164
+ }
165
+ return `voice-distil${tag}: ok profileId=${r.profileId} corpusSize=${r.corpusSize} feedbackEntries=${r.feedbackEntries} generatedAt=${r.generatedAt}`;
166
+ };
167
+
168
+ let summary: string;
169
+ if (Array.isArray(result)) {
170
+ summary = result.map((r, i) => summariseOne(r)).join("\n---\n");
171
+ } else {
172
+ summary = summariseOne(result, format);
173
+ }
174
+
145
175
  return { content: [{ type: "text" as const, text: summary }] };
146
176
  } catch (err) {
147
177
  return {
@@ -159,23 +189,25 @@ server.tool(
159
189
 
160
190
  server.tool(
161
191
  "voice-retrieve-conditioning",
162
- "Return the operator's style card and K voice-matched exemplars for a drafting brief. K=5 short-form, K=15 long-form. Token-budget bounded. Returns empty {styleCard:null,exemplars:[]} when no profile exists.",
192
+ "Return the operator's per-format style card and K voice-matched exemplars for a drafting brief. K=5 short-form (length='short'), K=15 long-form (length='long'). Token-budget bounded. format is required (the writing type: text|email|social-post|article|marketing-copy|note). Returns empty {styleCard:null,exemplars:[]} when no profile exists.",
163
193
  {
164
194
  userId: z
165
195
  .string()
166
196
  .optional()
167
197
  .describe("Operator AdminUser identifier. Defaults to ADMIN_USER_ID env."),
198
+ format: zFormat.describe("Writing format for corpus and profile scoping."),
168
199
  topic: z
169
200
  .string()
170
201
  .optional()
171
202
  .describe("Drafting topic keyword (filters exemplars to relevant ones)"),
172
- format: z
203
+ length: z
173
204
  .enum(["short", "long"])
174
- .describe("Short = email/post (K=5, 16k tokens); long = brochure/prospectus (K=15, 96k tokens)"),
205
+ .optional()
206
+ .describe("K-selector: short = K=5 (16k tokens, email/post); long = K=15 (96k tokens, brochure/prospectus). Defaults to short."),
175
207
  register: z.string().optional().describe("Optional register hint"),
176
208
  tokenBudget: z.number().int().positive().optional(),
177
209
  },
178
- async ({ userId, topic, format, register, tokenBudget }) => {
210
+ async ({ userId, format, topic, length, register, tokenBudget }) => {
179
211
  if (!accountId) return refuseNoAccount("voice-retrieve-conditioning");
180
212
  const resolvedUserId = userId ?? resolveUserId();
181
213
  if (!resolvedUserId) {
@@ -192,7 +224,12 @@ server.tool(
192
224
  const result = await voiceRetrieveConditioning({
193
225
  accountId,
194
226
  userId: resolvedUserId,
195
- brief: { topic, format, register },
227
+ brief: {
228
+ format: format as import("./lib/voice-corpus.js").VoiceFormat,
229
+ topic,
230
+ length: length as "short" | "long" | undefined,
231
+ register,
232
+ },
196
233
  tokenBudget,
197
234
  });
198
235
  return {
@@ -220,9 +257,10 @@ server.tool(
220
257
 
221
258
  server.tool(
222
259
  "voice-record-feedback",
223
- "Capture an operator's edit on an agent draft. Writes :VoiceEdit linked to :VoiceProfile via :FEEDBACK_FOR. The calling agent supplies `intent` (one sentence describing what the operator changed and what voice preference it reveals); Task 424 moved the diff-intent summarisation into the agent's turn.",
260
+ "Capture an operator's edit on an agent draft. Writes :VoiceEdit linked to the per-format :VoiceProfile via :FEEDBACK_FOR. The calling agent supplies `intent` (one sentence describing what changed and what voice preference it reveals Task 424) and `format` (the writing format of the draft).",
224
261
  {
225
262
  userId: z.string().optional(),
263
+ format: zFormat.describe("Writing format of the draft being edited."),
226
264
  originalText: z.string().min(1),
227
265
  editedText: z.string().min(1),
228
266
  intent: z
@@ -232,7 +270,7 @@ server.tool(
232
270
  skill: z.string().optional().describe("Calling skill name, for provenance"),
233
271
  brief: z.string().optional().describe("Drafting brief that produced the original"),
234
272
  },
235
- async ({ userId, originalText, editedText, intent, skill, brief }) => {
273
+ async ({ userId, format, originalText, editedText, intent, skill, brief }) => {
236
274
  if (!accountId) return refuseNoAccount("voice-record-feedback");
237
275
  const resolvedUserId = userId ?? resolveUserId();
238
276
  if (!resolvedUserId) {
@@ -250,6 +288,7 @@ server.tool(
250
288
  const result = await voiceRecordFeedback({
251
289
  accountId,
252
290
  userId: resolvedUserId,
291
+ format: format as import("./lib/voice-corpus.js").VoiceFormat,
253
292
  originalText,
254
293
  editedText,
255
294
  intent,
@@ -259,7 +298,7 @@ server.tool(
259
298
  content: [
260
299
  {
261
300
  type: "text" as const,
262
- text: `voice-record-feedback: editId=${result.editId} intent="${result.intent}" hadProfile=${result.hadProfile}`,
301
+ text: `voice-record-feedback: editId=${result.editId} format=${format} intent="${result.intent}" hadProfile=${result.hadProfile}`,
263
302
  },
264
303
  ],
265
304
  };
@@ -277,6 +316,57 @@ server.tool(
277
316
  },
278
317
  );
279
318
 
319
+ server.tool(
320
+ "voice-ingest-session-text",
321
+ "Ingest operator PTY turns from a Claude Code session JSONL as :Message {format:'text', source:'admin-pty', authorshipMode:'human-only'} corpus nodes. Deduplication via SHA256 contentHash — re-running against the same JSONL is a no-op. Triggers voice-distil-profile {format:'text'} after ingest (cadence guard decides whether distillation runs). Called automatically by the SessionEnd hook; can also be invoked manually.",
322
+ {
323
+ sessionJsonlPath: z.string().min(1).describe("Absolute path to the Claude Code session JSONL file"),
324
+ sessionId: z.string().optional().describe("Session ID for provenance; read from the SessionEnd envelope"),
325
+ userId: z.string().optional().describe("Operator AdminUser identifier. Defaults to ADMIN_USER_ID env."),
326
+ },
327
+ async ({ sessionJsonlPath, sessionId, userId }) => {
328
+ if (!accountId) return refuseNoAccount("voice-ingest-session-text");
329
+ const resolvedUserId = userId ?? resolveUserId();
330
+ if (!resolvedUserId) {
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text" as const,
335
+ text: "voice-ingest-session-text: userId required (no ADMIN_USER_ID in spawn env).",
336
+ },
337
+ ],
338
+ isError: true,
339
+ };
340
+ }
341
+ try {
342
+ const result = await voiceIngestSessionText({
343
+ sessionJsonlPath,
344
+ accountId,
345
+ userId: resolvedUserId,
346
+ sessionId,
347
+ });
348
+ return {
349
+ content: [
350
+ {
351
+ type: "text" as const,
352
+ text: `voice-ingest-session-text: ingestedCount=${result.ingestedCount} skippedCount=${result.skippedCount} sessionId=${result.sessionId ?? "unknown"}`,
353
+ },
354
+ ],
355
+ };
356
+ } catch (err) {
357
+ return {
358
+ content: [
359
+ {
360
+ type: "text" as const,
361
+ text: `voice-ingest-session-text failed: ${err instanceof Error ? err.message : String(err)}`,
362
+ },
363
+ ],
364
+ isError: true,
365
+ };
366
+ }
367
+ },
368
+ );
369
+
280
370
  const transport = new StdioServerTransport();
281
371
  await server.connect(transport);
282
372
 
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Shared corpus-filter clause for the voice-mirror tools.
2
+ * Shared corpus-filter clauses and format helpers for the voice-mirror tools.
3
3
  *
4
- * Imported by `voice-distil-profile` (count + sample walks) and
5
- * `voice-retrieve-conditioning` (exemplar walk). The single load-bearing
6
- * filter — when the corpus rules change, this is the only place to edit.
4
+ * Imported by `voice-distil-profile` (count + sample walks),
5
+ * `voice-retrieve-conditioning` (exemplar walk), `voice-tag-content` (tag
6
+ * writes), and `voice-ingest-session-text` (SessionEnd ingest). The single
7
+ * load-bearing filter — when the corpus rules change, this is the only place
8
+ * to edit.
7
9
  *
8
- * Two predicates:
10
+ * Two predicates in VOICE_CORPUS_WHERE:
9
11
  *
10
12
  * 1. `authorshipMode IN ['human-only', 'human-led-agent-assisted']`.
11
13
  * `human-only` is the operator's own writing verbatim;
@@ -25,7 +27,7 @@
25
27
  * Chat captures voice in a way email rarely does — this is the
26
28
  * single highest-leverage corpus addition for voice fidelity.
27
29
  *
28
- * The clause expects `$accountId` to be bound in the calling cypher
30
+ * The clause expects `$accountId` to be bound in the calling Cypher
29
31
  * parameters. It does not bind any other parameters.
30
32
  */
31
33
  export const VOICE_CORPUS_WHERE = `n.accountId = $accountId
@@ -52,3 +54,73 @@ export const TAGGABLE_PRIMARY_LABELS = [
52
54
  "Message",
53
55
  "SocialPost",
54
56
  ] as const;
57
+
58
+ /**
59
+ * Closed enum of writing-format values. One `:VoiceProfile` exists per
60
+ * `(accountId, userId, format)`. Adding a new value is a separate task.
61
+ *
62
+ * `marketing-copy` has no auto-derivation path — operator-tagged only.
63
+ */
64
+ export const FORMAT_VALUES = [
65
+ "text",
66
+ "email",
67
+ "social-post",
68
+ "article",
69
+ "marketing-copy",
70
+ "note",
71
+ ] as const;
72
+
73
+ export type VoiceFormat = (typeof FORMAT_VALUES)[number];
74
+
75
+ /**
76
+ * Returns VOICE_CORPUS_WHERE with an additional `n.format = $format`
77
+ * predicate appended. Requires `$format` to be bound alongside `$accountId`
78
+ * in the calling Cypher parameters.
79
+ *
80
+ * Format-discovery queries (which enumerate distinct formats from the
81
+ * corpus) use the base `VOICE_CORPUS_WHERE` directly; per-format corpus
82
+ * walks use this function.
83
+ */
84
+ export function voiceCorpusWhereWithFormat(format: VoiceFormat): string {
85
+ return `${VOICE_CORPUS_WHERE}\n AND n.format = $format`;
86
+ }
87
+
88
+ /**
89
+ * Derives the default writing format for a corpus node from its labels
90
+ * and properties. Used by ingest writers and `voice-tag-content` when the
91
+ * operator does not specify a format explicitly.
92
+ *
93
+ * Derivation rules (first match wins):
94
+ *
95
+ * | Node shape | format |
96
+ * |-------------------------------------------------------------|---------------|
97
+ * | :Message | 'text' |
98
+ * | :SocialPost | 'social-post' |
99
+ * | :KnowledgeDocument {source:'email'} | 'email' |
100
+ * | :ConversationArchive {source:'email'} | 'email' |
101
+ * | :KnowledgeDocument (non-email), body.length > 1500 | 'article' |
102
+ * | :KnowledgeDocument (non-email), body.length ≤ 1500 | 'note' |
103
+ * | :Section (conversation-document chunk) | 'text' |
104
+ * | fallback | 'text' |
105
+ *
106
+ * `marketing-copy` has no auto-derivation path.
107
+ */
108
+ export function deriveFormat(
109
+ labels: string[],
110
+ source?: string | null,
111
+ body?: string | null,
112
+ ): VoiceFormat {
113
+ if (labels.includes("Message")) return "text";
114
+ if (labels.includes("SocialPost")) return "social-post";
115
+ if (
116
+ (labels.includes("KnowledgeDocument") || labels.includes("ConversationArchive")) &&
117
+ source === "email"
118
+ ) {
119
+ return "email";
120
+ }
121
+ if (labels.includes("KnowledgeDocument")) {
122
+ return (body?.length ?? 0) > 1500 ? "article" : "note";
123
+ }
124
+ // :Section (conversation-document chunk) and any other label → text.
125
+ return "text";
126
+ }