@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,10 +2,17 @@
2
2
  * voice-retrieve-conditioning — return the operator's style card plus K
3
3
  * voice-matched exemplars for a drafting brief.
4
4
  *
5
+ * Task 462 changes:
6
+ * - `brief.format` (K-selector: 'short'|'long') renamed to `brief.length`
7
+ * to free the name for the new `brief.format: VoiceFormat` axis.
8
+ * - `brief.format: VoiceFormat` (required) — corpus exemplars are filtered
9
+ * to this writing format; the `:VoiceProfile` is the per-format profile.
10
+ * - All callers updated in the same commit.
11
+ *
5
12
  * Retrieval order (best to worst):
6
- * 1. If brief.topic is provided AND keyword search on the human-only corpus
13
+ * 1. If brief.topic is provided AND keyword search on the per-format corpus
7
14
  * returns ≥K matches → use those (ordered by recency).
8
- * 2. Otherwise, return the K most-recent human-only nodes.
15
+ * 2. Otherwise, return the K most-recent per-format corpus nodes.
9
16
  *
10
17
  * Embeddings are not used in v1: the corpus is small (single operator's
11
18
  * personal writing — typically <500 docs), and recency-with-keyword-filter
@@ -18,7 +25,11 @@
18
25
  * calling drafting skill falls back to its existing default register.
19
26
  */
20
27
  import { getSession } from "../lib/neo4j.js";
21
- import { VOICE_CORPUS_WHERE } from "../lib/voice-corpus.js";
28
+ import {
29
+ VOICE_CORPUS_WHERE,
30
+ voiceCorpusWhereWithFormat,
31
+ type VoiceFormat,
32
+ } from "../lib/voice-corpus.js";
22
33
 
23
34
  const DEFAULT_TOKEN_BUDGET_SHORT = 16_000;
24
35
  const DEFAULT_TOKEN_BUDGET_LONG = 96_000;
@@ -29,7 +40,17 @@ export interface VoiceRetrieveConditioningParams {
29
40
  userId: string;
30
41
  brief: {
31
42
  topic?: string;
32
- format: "short" | "long";
43
+ /**
44
+ * Writing format for corpus and profile scoping (required, Task 462).
45
+ * One :VoiceProfile per (accountId, userId, format).
46
+ */
47
+ format: VoiceFormat;
48
+ /**
49
+ * K-selector: 'short' = K=5 (16k token budget, email/post);
50
+ * 'long' = K=15 (96k token budget, brochure/prospectus).
51
+ * Renamed from `format` in Task 462 to free the name for VoiceFormat.
52
+ */
53
+ length?: "short" | "long";
33
54
  register?: string;
34
55
  };
35
56
  tokenBudget?: number;
@@ -55,30 +76,33 @@ export async function voiceRetrieveConditioning(
55
76
  const { accountId, userId, brief } = params;
56
77
  if (!accountId || !userId) return EMPTY;
57
78
 
58
- const k = brief.format === "long" ? 15 : 5;
79
+ const length = brief.length ?? "short";
80
+ const k = length === "long" ? 15 : 5;
59
81
  const tokenBudget =
60
82
  params.tokenBudget ??
61
- (brief.format === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
83
+ (length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
62
84
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
85
+ const format = brief.format;
86
+ const corpusWhere = voiceCorpusWhereWithFormat(format);
63
87
 
64
88
  const session = getSession();
65
89
  try {
66
- // 1. Style card.
90
+ // 1. Style card — per-format profile.
67
91
  const profile = await session.run(
68
92
  `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
69
- OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile)
93
+ OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
70
94
  RETURN p.styleCard AS styleCard`,
71
- { accountId, userId },
95
+ { accountId, userId, format },
72
96
  );
73
97
  const styleCard = (profile.records[0]?.get("styleCard") as string | null) ?? null;
74
98
 
75
- // 2. Exemplars. Keyword-aware filter when brief.topic is set.
99
+ // 2. Exemplars — filtered to brief.format. Keyword-aware when topic is set.
76
100
  const topic = (brief.topic ?? "").trim();
77
101
  const usesTopic = topic.length > 0;
78
102
 
79
103
  const cypher = usesTopic
80
104
  ? `MATCH (n)
81
- WHERE ${VOICE_CORPUS_WHERE}
105
+ WHERE ${corpusWhere}
82
106
  AND toLower(coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')) CONTAINS toLower($topic)
83
107
  WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
84
108
  RETURN elementId(n) AS id,
@@ -89,7 +113,7 @@ export async function voiceRetrieveConditioning(
89
113
  ORDER BY ts IS NULL, ts DESC
90
114
  LIMIT $k`
91
115
  : `MATCH (n)
92
- WHERE ${VOICE_CORPUS_WHERE}
116
+ WHERE ${corpusWhere}
93
117
  WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
94
118
  RETURN elementId(n) AS id,
95
119
  labels(n) AS labels,
@@ -101,6 +125,7 @@ export async function voiceRetrieveConditioning(
101
125
 
102
126
  const result = await session.run(cypher, {
103
127
  accountId,
128
+ format,
104
129
  topic,
105
130
  k,
106
131
  });
@@ -128,7 +153,7 @@ export async function voiceRetrieveConditioning(
128
153
  }
129
154
 
130
155
  process.stderr.write(
131
- `[voice-retrieve] task=${brief.topic ?? brief.format} styleCardBytes=${
156
+ `[voice-retrieve] task=${brief.topic ?? length} format=${format} styleCardBytes=${
132
157
  styleCard?.length ?? 0
133
158
  } exemplarCount=${exemplars.length} tokenBudget=${tokenBudget}\n`,
134
159
  );
@@ -1,13 +1,25 @@
1
1
  /**
2
- * voice-tag-content — set `authorshipMode` on one or many content nodes.
2
+ * voice-tag-content — set `authorshipMode` (and optionally `format`) on one
3
+ * or many content nodes.
3
4
  *
4
5
  * The tool routes through neo4j directly with an account-scoped MERGE so
5
6
  * the operator can only mutate their own nodes. We do not go through
6
7
  * `memory-update` because that tool re-embeds and re-validates on every
7
8
  * call — overkill for a single-property authorship stamp.
9
+ *
10
+ * Format stamping (Task 462):
11
+ * - When `format` is provided: validated against FORMAT_VALUES and written
12
+ * to `n.format` alongside `authorshipMode`.
13
+ * - When `format` is omitted: a Cypher CASE expression derives format from
14
+ * each node's labels and `source` property, matching the `deriveFormat()`
15
+ * rule table in voice-corpus.ts. This keeps tag and ingest in lockstep.
8
16
  */
9
17
  import { getSession } from "../lib/neo4j.js";
10
- import { TAGGABLE_PRIMARY_LABELS } from "../lib/voice-corpus.js";
18
+ import {
19
+ TAGGABLE_PRIMARY_LABELS,
20
+ FORMAT_VALUES,
21
+ type VoiceFormat,
22
+ } from "../lib/voice-corpus.js";
11
23
 
12
24
  export type AuthorshipMode =
13
25
  | "human-only"
@@ -28,6 +40,8 @@ export interface VoiceTagContentParams {
28
40
  nodeIds: string[];
29
41
  mode: AuthorshipMode;
30
42
  accountId: string;
43
+ /** Optional format. When omitted, derived from each node's labels. */
44
+ format?: VoiceFormat;
31
45
  }
32
46
 
33
47
  export interface VoiceTagContentResult {
@@ -36,10 +50,22 @@ export interface VoiceTagContentResult {
36
50
  skippedReasons: Record<string, number>;
37
51
  }
38
52
 
53
+ // Cypher CASE expression that mirrors deriveFormat() for in-query derivation.
54
+ // Used when the caller does not supply an explicit format.
55
+ const FORMAT_DERIVE_CASE = `CASE
56
+ WHEN 'Message' IN labels(n) THEN 'text'
57
+ WHEN 'SocialPost' IN labels(n) THEN 'social-post'
58
+ WHEN ('KnowledgeDocument' IN labels(n) OR 'ConversationArchive' IN labels(n))
59
+ AND n.source = 'email' THEN 'email'
60
+ WHEN 'KnowledgeDocument' IN labels(n) AND size(coalesce(n.body, '')) > 1500 THEN 'article'
61
+ WHEN 'KnowledgeDocument' IN labels(n) THEN 'note'
62
+ ELSE 'text'
63
+ END`;
64
+
39
65
  export async function voiceTagContent(
40
66
  params: VoiceTagContentParams,
41
67
  ): Promise<VoiceTagContentResult> {
42
- const { nodeIds, mode, accountId } = params;
68
+ const { nodeIds, mode, accountId, format } = params;
43
69
 
44
70
  if (!AUTHORSHIP_MODES.has(mode)) {
45
71
  throw new Error(
@@ -51,6 +77,11 @@ export async function voiceTagContent(
51
77
  if (!accountId) {
52
78
  throw new Error("voice-tag-content: accountId is required");
53
79
  }
80
+ if (format !== undefined && !(FORMAT_VALUES as readonly string[]).includes(format)) {
81
+ throw new Error(
82
+ `voice-tag-content: invalid format '${format}'. Valid values: ${FORMAT_VALUES.join(", ")}`,
83
+ );
84
+ }
54
85
  if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
55
86
  return { updated: 0, skipped: 0, skippedReasons: {} };
56
87
  }
@@ -72,6 +103,10 @@ export async function voiceTagContent(
72
103
  // accidentally tagging document-path :Section rows AND parent
73
104
  // `:Conversation` / `:AdminConversation` nodes (which are not
74
105
  // body-bearing content).
106
+ //
107
+ // Format is either the caller-supplied constant ($format is non-null)
108
+ // or derived per-node via the CASE expression that mirrors
109
+ // deriveFormat() in voice-corpus.ts.
75
110
  `UNWIND $ids AS id
76
111
  MATCH (n) WHERE elementId(n) = id AND n.accountId = $accountId
77
112
  WITH n
@@ -81,13 +116,15 @@ export async function voiceTagContent(
81
116
  WHERE parent.conversationIdentity IS NOT NULL
82
117
  })
83
118
  SET n.authorshipMode = $mode,
84
- n.authorshipTaggedAt = $now
119
+ n.authorshipTaggedAt = $now,
120
+ n.format = CASE WHEN $format IS NOT NULL THEN $format ELSE ${FORMAT_DERIVE_CASE} END
85
121
  RETURN elementId(n) AS id`,
86
122
  {
87
123
  ids: batch,
88
124
  accountId,
89
125
  mode,
90
126
  now,
127
+ format: format ?? null,
91
128
  primaryLabels: [...TAGGABLE_PRIMARY_LABELS],
92
129
  },
93
130
  );
@@ -104,7 +141,7 @@ export async function voiceTagContent(
104
141
  }
105
142
 
106
143
  process.stderr.write(
107
- `[voice-tag] mode=${mode} accountId=${accountId} updated=${updated} skipped=${
144
+ `[voice-tag] mode=${mode} format=${format ?? "derived"} accountId=${accountId} updated=${updated} skipped=${
108
145
  nodeIds.length - updated
109
146
  }\n`,
110
147
  );
@@ -7,14 +7,15 @@ description: "Capture the operator's authorial voice from their own writing and
7
7
 
8
8
  Distil the operator's own voice from the content they have actually written, and condition agent drafts on it so generated copy reads as theirs. Anthropic models cannot be fine-tuned; voice mimicry comes from in-context conditioning — a style card plus a handful of real exemplars. The 1M context window makes this affordable.
9
9
 
10
- The skill is built on four deterministic MCP tools served by the `writer-craft` plugin's MCP server (`premium-plugins/writer-craft/mcp/`):
10
+ The skill is built on five deterministic MCP tools served by the `writer-craft` plugin's MCP server (`premium-plugins/writer-craft/mcp/`):
11
11
 
12
12
  | Tool | Purpose |
13
13
  |------|---------|
14
- | `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode`. Bulk-tags arrays of node ids. |
15
- | `voice-distil-profile` | Walk the operator's `human-only` corpus and produce a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries}` node. |
16
- | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. K=5 for short-form, K=15 for long-form. Token-budget bounded. |
17
- | `voice-record-feedback` | When the operator edits an agent draft, write a `:VoiceEdit {originalText, editedText, intent, occurredAt}-[:FEEDBACK_FOR]->(:VoiceProfile)` node. The `intent` field is a Haiku-summarised diff. |
14
+ | `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode` and derived `format`. Bulk-tags arrays of node ids. |
15
+ | `voice-distil-profile` | Walk the operator's `human-only` corpus for a given `format` and produce a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format}` node. Without a `format`, enumerates all formats present in the corpus and distils each. |
16
+ | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (one of the six corpus formats). K=5 for short-form, K=15 for long-form. Token-budget bounded. |
17
+ | `voice-record-feedback` | When the operator edits an agent draft, write a `:VoiceEdit {originalText, editedText, intent, occurredAt, format}-[:FEEDBACK_FOR]->(:VoiceProfile {format})` node. The `intent` field is a Haiku-summarised diff. |
18
+ | `voice-ingest-session-text` | (Called by the SessionEnd hook, not directly.) Ingest operator PTY turns from the session transcript as `:Message {format:'text', authorshipMode:'human-only'}` corpus nodes. Deduplicates via `contentHash`. |
18
19
 
19
20
  ## When to activate
20
21
 
@@ -32,6 +33,21 @@ Do **not** activate for:
32
33
  - Public-figure voice mimicry. Out of scope — see the `## Out of scope` section.
33
34
  - Audio voice cloning. Different domain entirely.
34
35
 
36
+ ## Corpus formats
37
+
38
+ Voice profiles are segmented by content format. The closed enum is:
39
+
40
+ | Format | Derived from |
41
+ |--------|-------------|
42
+ | `text` | `:Message` nodes, operator PTY turns (auto-ingested via SessionEnd hook) |
43
+ | `email` | `:KnowledgeDocument {source:'email'}`, `:ConversationArchive {source:'email'}` |
44
+ | `social-post` | `:SocialPost` nodes |
45
+ | `article` | `:KnowledgeDocument` with body > 1,500 chars |
46
+ | `note` | `:KnowledgeDocument` with body ≤ 1,500 chars |
47
+ | `marketing-copy` | No automatic derivation path — tag explicitly via `voice-tag-content` |
48
+
49
+ Format is derived automatically when tagging via `voice-tag-content`. Pass `format` explicitly to override the derivation.
50
+
35
51
  ## Flow — backfill review (one-shot session)
36
52
 
37
53
  Run when the operator first installs voice-mirror, then whenever they want to refresh the corpus.
@@ -49,14 +65,17 @@ Ask the operator which stream to backfill first. Default to chat archives when t
49
65
 
50
66
  Query for `:KnowledgeDocument | :SocialPost | :Message` nodes belonging to this operator's account whose `authorshipMode` is `unknown`. Order by `dateSent | datePublished | dateCreated` descending — most recent first. For each batch, surface a numbered list with: source label, date, recipient or topic, and a one-line preview (first ~120 chars of body). Offer bulk shortcuts:
51
67
 
52
- - `1-10 human-only` — tag every item in the batch as `human-only`.
68
+ - `1-10 human-only` — tag every item in the batch as `human-only` with auto-derived format.
69
+ - `1-10 human-only article` — tag the whole batch as `human-only` and force format to `article`.
53
70
  - `1,3,7 human-only` — tag a subset.
54
71
  - `2 human-led-agent-assisted` — single-item mode override.
55
- - `5 agent-led-human-reviewed` — single-item mode override.
72
+ - `5 agent-led-human-reviewed marketing-copy` — single-item mode and format override.
56
73
  - `skip` — leave the batch as `unknown` and advance.
57
74
  - `stop` — exit the backfill; the next session resumes from where the operator left off.
58
75
 
59
- Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> count=<n>` per write.
76
+ The tool derives `format` automatically from node labels and properties. Show the derived format alongside each item's preview so the operator can spot misclassifications. Append a format token to any batch or single-item command to override the derivation.
77
+
78
+ Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> count=<n>` per write.
60
79
 
61
80
  ### 2b. Chat archives — paginate per conversation-document parent
62
81
 
@@ -67,17 +86,30 @@ For each conversation-document `:KnowledgeDocument` (with `conversationIdentity`
67
86
  - `not me` — tag every chunk as `agent-only` (e.g. a Slack channel where the operator only forwarded other people's messages).
68
87
  - `skip` / `stop` — same semantics as 2a.
69
88
 
70
- Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains. The skill emits `[voice-tag] mode=<mode> count=<chunk-count>` once per conversation.
89
+ Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains. The skill emits `[voice-tag] mode=<mode> format=<format> count=<chunk-count>` once per conversation.
71
90
 
72
91
  Authorship at conversation granularity is a deliberate UX trade-off: chunks within a conversation almost always share the same operator's voice, and per-chunk tagging at archive scale would burn the operator's attention. The `mixed` opt-in covers the rare case where it matters.
73
92
 
74
93
  ### 3. Distil when the corpus crosses threshold
75
94
 
76
- After each backfill batch, check whether the qualifying corpus (`authorshipMode IN ['human-only', 'human-led-agent-assisted']`) has grown ≥20% since the last `:VoiceProfile.corpusSize` (or ≥1 entry if no profile exists). If yes, offer to run `voice-distil-profile` immediately. The operator can defer; the next scheduled distillation will pick it up.
95
+ After each backfill batch, check whether the qualifying corpus (`authorshipMode IN ['human-only', 'human-led-agent-assisted']`) for any format has grown ≥20% since the last `:VoiceProfile.corpusSize` for that format (or ≥1 entry if no profile exists for that format). If yes, offer to run `voice-distil-profile` immediately. The operator can defer; the next scheduled distillation will pick it up.
96
+
97
+ ## Automatic text-format capture (SessionEnd hook)
98
+
99
+ Operator PTY turns are automatically ingested into the `text` corpus at the end of every session — no operator action required. The `voice-session-end-text.sh` hook fires when Claude Code terminates the PTY session. It reads the JSONL transcript, filters to genuine operator turns (excludes slash commands, system reminders, and large paste blocks), deduplicates via SHA256 content hash, and writes each new turn as a `:Message {format:'text', authorshipMode:'human-only'}` node.
100
+
101
+ The hook only runs for admin sessions (role=admin, no specialist flag). Accounts without writer-craft installed skip silently — the hook guards on CLI existence.
102
+
103
+ After ingestion the hook triggers an incremental distillation for the `text` format if the corpus grew ≥20% since the last profile.
104
+
105
+ Observe via:
106
+ ```
107
+ journalctl -u maxy-code | grep '\[voice-session-end-hook\]'
108
+ ```
77
109
 
78
110
  ## Flow — drafting-time conditioning
79
111
 
80
- Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, register}` — and returns:
112
+ Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register}` — where `format` is the target corpus format and returns:
81
113
 
82
114
  ```yaml
83
115
  styleCard: |
@@ -97,58 +129,61 @@ exemplars:
97
129
 
98
130
  Drafting skills inject both blocks into their prompt. Opt-out is per-skill: declare `voiceMirror: false` in the skill's frontmatter and the calling site skips the fetch. Default is on.
99
131
 
100
- If the `:VoiceProfile` does not yet exist (corpus too small, or distillation never run), the tool returns `{styleCard: null, exemplars: []}` and the calling skill falls back to its existing default register. No error, no draft refusal.
132
+ If the `:VoiceProfile` for the requested format does not yet exist (corpus too small, or distillation never run), the tool returns `{styleCard: null, exemplars: []}` and the calling skill falls back to its existing default register. No error, no draft refusal.
101
133
 
102
- Emit `[voice-retrieve] task=<brief> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` per call.
134
+ Emit `[voice-retrieve] task=<brief> format=<format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` per call.
103
135
 
104
136
  ## Flow — feedback capture
105
137
 
106
138
  When the operator edits an agent draft (in the email composer's edit loop, or in any drafting skill that supports inline edits), capture the diff:
107
139
 
108
- 1. The calling skill passes `{originalText, editedText}` to `voice-record-feedback`.
140
+ 1. The calling skill passes `{originalText, editedText, format}` to `voice-record-feedback`.
109
141
  2. The tool runs a Haiku diff summarisation to produce an `intent` field — a one-sentence description of what the operator changed and why. Example intents: "shorter and dropped the apology", "switched to first person", "removed sign-off", "added a specific date".
110
- 3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, adminUserId, accountId})-[:FEEDBACK_FOR]->(:VoiceProfile)` for the operator's profile.
142
+ 3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, userId, accountId, format})-[:FEEDBACK_FOR]->(:VoiceProfile {format})` for the operator's profile.
111
143
 
112
144
  Feedback accumulates between distillations. The next `voice-distil-profile` run reads every `:VoiceEdit` linked to the profile and feeds the intents into the style-card refinement.
113
145
 
114
- Emit `[voice-record-feedback] adminUser=<id> intent="<haiku-summary>" diffBytes=<n>`.
146
+ Emit `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>`.
115
147
 
116
148
  ## Distillation cadence
117
149
 
118
- `voice-distil-profile` re-runs when **either**:
150
+ `voice-distil-profile` re-runs per format when **either**:
119
151
 
120
- - the `human-only` corpus has grown ≥20% since the last `:VoiceProfile.corpusSize`, OR
121
- - 30 days have passed since `:VoiceProfile.generatedAt`.
152
+ - the `human-only` corpus for that format has grown ≥20% since the last `:VoiceProfile.corpusSize` for that format, OR
153
+ - 30 days have passed since `:VoiceProfile.generatedAt` for that format.
122
154
 
123
- A re-run within the 30-day window with <20% growth is a no-op — the tool returns the existing profile unchanged and emits `[voice-distil] skip reason=below-threshold`.
155
+ A re-run within the 30-day window with <20% growth is a no-op — the tool returns the existing profile unchanged and emits `[voice-distil] skip reason=below-threshold format=<format>`.
124
156
 
125
157
  The triggering surface is the skill, not a scheduled job. Distillation runs on demand: at the end of a backfill batch, when the email composer notices stale-profile narration, or when the operator says "rebuild my voice profile".
126
158
 
127
159
  ## Per-operator scope
128
160
 
129
- One `:VoiceProfile` per `:AdminUser`. Multi-author accounts (an agency with several operators) get one profile per person. Identity is the `(accountId, adminUserId)` pair, enforced by the schema constraint.
161
+ One `:VoiceProfile` per `(accountId, userId, format)`. Multi-author accounts (an agency with several operators) get one profile per person per format. Identity is the `(accountId, userId, format)` triple, enforced by the schema constraint.
130
162
 
131
163
  ## Observability
132
164
 
133
165
  | Tag | When |
134
166
  |-----|------|
135
- | `[voice-tag] mode=<mode> nodeId=<id> count=<n>` | Every tag write. |
136
- | `[voice-distil] adminUser=<id> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
137
- | `[voice-distil] skip reason=<below-threshold\|recent>` | When the cadence guard fires. |
138
- | `[voice-retrieve] task=<topic\|format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. |
139
- | `[voice-record-feedback] adminUser=<id> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
167
+ | `[voice-tag] mode=<mode> format=<format> nodeId=<id> count=<n>` | Every tag write. |
168
+ | `[voice-distil] userId=<id> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
169
+ | `[voice-distil] skip reason=<below-threshold\|recent> format=<format>` | When the cadence guard fires. |
170
+ | `[voice-retrieve] task=<topic> format=<format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. |
171
+ | `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
172
+ | `[voice-session-end-hook] trigger sessionId=<id>` | Hook fires for an eligible session. |
173
+ | `[voice-session-end-hook] result=ok sessionId=<id> turns=<n> skipped=<m>` | Successful ingest. |
174
+ | `[voice-session-end-hook] trigger-skipped sessionId=<id> reason=<reason>` | Hook guard fired. |
140
175
 
141
- **Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active `:AdminUser`.
176
+ **Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active format per operator. `[voice-session-end-hook] result=ok` appears after each admin PTY session.
142
177
 
143
- **Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead.
178
+ **Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead; `[voice-session-end-hook] result=err` → CLI or Neo4j issue.
144
179
 
145
180
  **Diagnostic path:**
146
181
 
147
182
  ```
148
- journalctl -u maxy-code | grep -E '\[voice-(tag|distil|retrieve|record-feedback)\]'
183
+ journalctl -u maxy-code | grep -E '\[voice-(tag|distil|retrieve|record-feedback|session-end-hook)\]'
149
184
  ```
150
185
 
151
- Filter by `adminUser=<id>` for per-operator drill-down.
186
+ Filter by `userId=<id>` for per-operator drill-down.
152
187
 
153
188
  ## Out of scope
154
189
 
@@ -156,7 +191,6 @@ Filter by `adminUser=<id>` for per-operator drill-down.
156
191
  - Style transfer from public-figure corpora — voice-mirror only learns from the operator's own writing.
157
192
  - Audio voice cloning / speech synthesis — different domain.
158
193
  - Auto-tagging legacy content via heuristics — backfill is operator-driven only; the agent never guesses authorship.
159
- - Register-segmented profiles (one profile per author, not one per formality level).
160
194
  - Admin-UI editing of the `styleCard` — operator edits via `database-operator` for now. Surfacing this in a dedicated UI is a follow-up task.
161
195
 
162
196
  ## Dependencies