@rubytech/create-maxy-code 0.1.177 → 0.1.179

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 (54) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/memory/bin/conversation-archive-ingest.mjs +38 -3
  3. package/payload/platform/plugins/memory/mcp/dist/index.js +17 -7
  4. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  5. package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.d.ts +8 -0
  6. package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.d.ts.map +1 -0
  7. package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.js +27 -0
  8. package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.js.map +1 -0
  9. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts +10 -6
  10. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts.map +1 -1
  11. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js +13 -13
  12. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.d.ts +2 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.d.ts.map +1 -0
  15. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.js +124 -0
  16. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.js.map +1 -0
  17. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.d.ts +2 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +104 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts +12 -0
  22. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js +38 -13
  24. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js.map +1 -1
  25. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +23 -1
  26. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  27. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +175 -44
  28. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  29. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +7 -3
  30. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +9 -1
  31. package/payload/premium-plugins/writer-craft/PLUGIN.md +2 -2
  32. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +3 -3
  33. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  34. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +4 -22
  35. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  36. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +4 -36
  37. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  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 +34 -2
  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-retrieve-conditioning.d.ts.map +1 -1
  42. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +24 -5
  43. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  44. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  45. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  46. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +14 -28
  47. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
  48. package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +21 -41
  49. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +5 -5
  50. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +4 -41
  51. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +32 -2
  52. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +25 -5
  53. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +16 -31
  54. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +16 -19
@@ -55,7 +55,7 @@ const zFormat = z.enum([...FORMAT_VALUES] as [string, ...string[]]).describe(
55
55
 
56
56
  server.tool(
57
57
  "voice-tag-content",
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.",
58
+ "Stamp authorshipMode and 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. format is editorial the operator chooses it per node; the tool does not infer it.",
59
59
  {
60
60
  nodeIds: z.array(z.string()).min(1).describe("Element IDs of nodes to tag"),
61
61
  mode: z
@@ -67,8 +67,8 @@ server.tool(
67
67
  "unknown",
68
68
  ])
69
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.",
70
+ format: zFormat.describe(
71
+ "Writing format. Required the operator's editorial classification of the content.",
72
72
  ),
73
73
  },
74
74
  async ({ nodeIds, mode, format }) => {
@@ -78,13 +78,13 @@ server.tool(
78
78
  nodeIds,
79
79
  mode: mode as AuthorshipMode,
80
80
  accountId,
81
- format: format as import("./lib/voice-corpus.js").VoiceFormat | undefined,
81
+ format: format as import("./lib/voice-corpus.js").VoiceFormat,
82
82
  });
83
83
  return {
84
84
  content: [
85
85
  {
86
86
  type: "text" as const,
87
- text: `voice-tag: mode=${mode} format=${format ?? "derived"} updated=${result.updated} skipped=${result.skipped}`,
87
+ text: `voice-tag: mode=${mode} format=${format} updated=${result.updated} skipped=${result.skipped}`,
88
88
  },
89
89
  ],
90
90
  };
@@ -59,7 +59,10 @@ export const TAGGABLE_PRIMARY_LABELS = [
59
59
  * Closed enum of writing-format values. One `:VoiceProfile` exists per
60
60
  * `(accountId, userId, format)`. Adding a new value is a separate task.
61
61
  *
62
- * `marketing-copy` has no auto-derivation path operator-tagged only.
62
+ * Format is an editorial classification set by the operator at tag time.
63
+ * The only auto-tagged path is `voice-ingest-session-text`, which hard-codes
64
+ * `format='text'` on `:Message` because chat turns are unambiguously text and
65
+ * the operator is not in the loop on session end.
63
66
  */
64
67
  export const FORMAT_VALUES = [
65
68
  "text",
@@ -84,43 +87,3 @@ export type VoiceFormat = (typeof FORMAT_VALUES)[number];
84
87
  export function voiceCorpusWhereWithFormat(format: VoiceFormat): string {
85
88
  return `${VOICE_CORPUS_WHERE}\n AND n.format = $format`;
86
89
  }
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
- }
@@ -187,15 +187,39 @@ async function distilForFormat(
187
187
  }
188
188
 
189
189
  // 4. Sample corpus bodies up to the char cap. Pick recent first.
190
+ //
191
+ // Body resolution (Task 471):
192
+ // - For a :KnowledgeDocument with HAS_SECTION children, concatenate
193
+ // child :Section bodies in `position` order — KD.body itself is
194
+ // null after the Task 465 server-slicing change.
195
+ // - Other labels (:Message, :SocialPost, :Section chat chunks) read
196
+ // n.body directly via the existing COALESCE fallback chain.
197
+ //
198
+ // `collect(s.body)` is null-skipping in Cypher, so KDs with no children
199
+ // produce sectionBodies = [] and fall through to the COALESCE branch
200
+ // correctly. The `usedSectionWalk` flag is returned for per-exemplar
201
+ // observability.
190
202
  const sampled = await session.run(
191
203
  `MATCH (n)
192
204
  WHERE ${corpusWhere}
193
205
  WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
194
206
  OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
195
207
  WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
208
+ WITH n, ts, parent
209
+ OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
210
+ WITH n, ts, parent, s ORDER BY s.position
211
+ WITH n, ts, parent, collect(s.body) AS sectionBodies
212
+ WITH n, ts, parent,
213
+ 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0 AS usedSectionWalk,
214
+ CASE
215
+ WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
216
+ THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
217
+ ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
218
+ END AS body
196
219
  RETURN elementId(n) AS id,
197
220
  labels(n) AS labels,
198
- coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '') AS body,
221
+ body,
222
+ usedSectionWalk,
199
223
  parent.conversationIdentity IS NOT NULL AS isConversationChunk,
200
224
  ts
201
225
  ORDER BY ts IS NULL, ts DESC
@@ -203,11 +227,14 @@ async function distilForFormat(
203
227
  { accountId, format },
204
228
  );
205
229
  const samples: { id: string; label: string; body: string }[] = [];
230
+ let sectionWalkCount = 0;
231
+ let nBodyCount = 0;
206
232
  let charBudget = SAMPLE_CHAR_CAP;
207
233
  for (const r of sampled.records) {
208
234
  const body = (r.get("body") as string | null) ?? "";
209
235
  const labels = (r.get("labels") as string[]) ?? [];
210
236
  const isConversationChunk = Boolean(r.get("isConversationChunk"));
237
+ const usedSectionWalk = Boolean(r.get("usedSectionWalk"));
211
238
  const label = isConversationChunk
212
239
  ? "Conversation"
213
240
  : (labels.find((l) =>
@@ -216,6 +243,8 @@ async function distilForFormat(
216
243
  if (body.length === 0) continue;
217
244
  const trimmed = body.length > charBudget ? body.slice(0, charBudget) : body;
218
245
  samples.push({ id: r.get("id") as string, label, body: trimmed });
246
+ if (usedSectionWalk) sectionWalkCount += 1;
247
+ else nBodyCount += 1;
219
248
  charBudget -= trimmed.length;
220
249
  if (charBudget <= 0) break;
221
250
  }
@@ -235,7 +264,8 @@ async function distilForFormat(
235
264
  if (mode === "sample") {
236
265
  process.stderr.write(
237
266
  `[voice-distil] adminUser=${userId} format=${format} mode=sample corpusSize=${corpusSize} ` +
238
- `exemplars=${samples.length} feedbackEntries=${feedbackIntents.length}\n`,
267
+ `exemplars=${samples.length} feedbackEntries=${feedbackIntents.length} ` +
268
+ `sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`,
239
269
  );
240
270
  return {
241
271
  profileId: prevProfileId,
@@ -97,27 +97,47 @@ export async function voiceRetrieveConditioning(
97
97
  const styleCard = (profile.records[0]?.get("styleCard") as string | null) ?? null;
98
98
 
99
99
  // 2. Exemplars — filtered to brief.format. Keyword-aware when topic is set.
100
+ //
101
+ // Body resolution (Task 471):
102
+ // - For :KnowledgeDocument with HAS_SECTION children, concatenate
103
+ // child :Section bodies in `position` order. KD.body is null after
104
+ // Task 465 server-slicing.
105
+ // - Other labels read n.body directly via the COALESCE fallback chain.
106
+ //
107
+ // The topic filter runs against the computed body, so a topic that
108
+ // appears only in n.summary (LLM abstract) no longer matches — by
109
+ // design: the corpus surfaces verbatim prose, not the abstract.
100
110
  const topic = (brief.topic ?? "").trim();
101
111
  const usesTopic = topic.length > 0;
102
112
 
113
+ const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
114
+ OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
115
+ WITH n, ts, s ORDER BY s.position
116
+ WITH n, ts, collect(s.body) AS sectionBodies
117
+ WITH n, ts, CASE
118
+ WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
119
+ THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
120
+ ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
121
+ END AS body`;
122
+
103
123
  const cypher = usesTopic
104
124
  ? `MATCH (n)
105
125
  WHERE ${corpusWhere}
106
- AND toLower(coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')) CONTAINS toLower($topic)
107
- WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
126
+ ${bodyComputeBlock}
127
+ WHERE toLower(body) CONTAINS toLower($topic)
108
128
  RETURN elementId(n) AS id,
109
129
  labels(n) AS labels,
110
- coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '') AS body,
130
+ body,
111
131
  coalesce(n.subject, n.title, n.name, '') AS source,
112
132
  ts
113
133
  ORDER BY ts IS NULL, ts DESC
114
134
  LIMIT $k`
115
135
  : `MATCH (n)
116
136
  WHERE ${corpusWhere}
117
- WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
137
+ ${bodyComputeBlock}
118
138
  RETURN elementId(n) AS id,
119
139
  labels(n) AS labels,
120
- coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '') AS body,
140
+ body,
121
141
  coalesce(n.subject, n.title, n.name, '') AS source,
122
142
  ts
123
143
  ORDER BY ts IS NULL, ts DESC
@@ -1,18 +1,16 @@
1
1
  /**
2
- * voice-tag-content — set `authorshipMode` (and optionally `format`) on one
3
- * or many content nodes.
2
+ * voice-tag-content — set `authorshipMode` and `format` on one or many
3
+ * content nodes.
4
4
  *
5
5
  * The tool routes through neo4j directly with an account-scoped MERGE so
6
6
  * the operator can only mutate their own nodes. We do not go through
7
7
  * `memory-update` because that tool re-embeds and re-validates on every
8
8
  * call — overkill for a single-property authorship stamp.
9
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.
10
+ * `format` is an editorial classification only the operator can set; it is
11
+ * therefore required. The sole hard-coded exception lives in
12
+ * `voice-ingest-session-text`, which stamps `format='text'` on auto-ingested
13
+ * `:Message` rows where the operator is not in the loop (Task 471).
16
14
  */
17
15
  import { getSession } from "../lib/neo4j.js";
18
16
  import {
@@ -40,8 +38,8 @@ export interface VoiceTagContentParams {
40
38
  nodeIds: string[];
41
39
  mode: AuthorshipMode;
42
40
  accountId: string;
43
- /** Optional format. When omitted, derived from each node's labels. */
44
- format?: VoiceFormat;
41
+ /** Writing format (required). Editorial classification operator-supplied. */
42
+ format: VoiceFormat;
45
43
  }
46
44
 
47
45
  export interface VoiceTagContentResult {
@@ -50,18 +48,6 @@ export interface VoiceTagContentResult {
50
48
  skippedReasons: Record<string, number>;
51
49
  }
52
50
 
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
-
65
51
  export async function voiceTagContent(
66
52
  params: VoiceTagContentParams,
67
53
  ): Promise<VoiceTagContentResult> {
@@ -77,7 +63,10 @@ export async function voiceTagContent(
77
63
  if (!accountId) {
78
64
  throw new Error("voice-tag-content: accountId is required");
79
65
  }
80
- if (format !== undefined && !(FORMAT_VALUES as readonly string[]).includes(format)) {
66
+ if (format === undefined || format === null) {
67
+ throw new Error("voice-tag-content: format is required");
68
+ }
69
+ if (!(FORMAT_VALUES as readonly string[]).includes(format)) {
81
70
  throw new Error(
82
71
  `voice-tag-content: invalid format '${format}'. Valid values: ${FORMAT_VALUES.join(", ")}`,
83
72
  );
@@ -97,16 +86,12 @@ export async function voiceTagContent(
97
86
  for (let i = 0; i < nodeIds.length; i += BATCH) {
98
87
  const batch = nodeIds.slice(i, i + BATCH);
99
88
  const result = await session.run(
100
- // Tag any of the four primary content labels, or a conversation-
89
+ // Tag any of the three primary content labels, or a conversation-
101
90
  // document :Section chunk (parent :KnowledgeDocument carries
102
91
  // `conversationIdentity`). The EXISTS sub-pattern guards against
103
92
  // accidentally tagging document-path :Section rows AND parent
104
93
  // `:Conversation` / `:AdminConversation` nodes (which are not
105
94
  // 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.
110
95
  `UNWIND $ids AS id
111
96
  MATCH (n) WHERE elementId(n) = id AND n.accountId = $accountId
112
97
  WITH n
@@ -117,14 +102,14 @@ export async function voiceTagContent(
117
102
  })
118
103
  SET n.authorshipMode = $mode,
119
104
  n.authorshipTaggedAt = $now,
120
- n.format = CASE WHEN $format IS NOT NULL THEN $format ELSE ${FORMAT_DERIVE_CASE} END
105
+ n.format = $format
121
106
  RETURN elementId(n) AS id`,
122
107
  {
123
108
  ids: batch,
124
109
  accountId,
125
110
  mode,
126
111
  now,
127
- format: format ?? null,
112
+ format,
128
113
  primaryLabels: [...TAGGABLE_PRIMARY_LABELS],
129
114
  },
130
115
  );
@@ -141,7 +126,7 @@ export async function voiceTagContent(
141
126
  }
142
127
 
143
128
  process.stderr.write(
144
- `[voice-tag] mode=${mode} format=${format ?? "derived"} accountId=${accountId} updated=${updated} skipped=${
129
+ `[voice-tag] mode=${mode} format=${format} accountId=${accountId} updated=${updated} skipped=${
145
130
  nodeIds.length - updated
146
131
  }\n`,
147
132
  );
@@ -11,7 +11,7 @@ The skill is built on five deterministic MCP tools served by the `writer-craft`
11
11
 
12
12
  | Tool | Purpose |
13
13
  |------|---------|
14
- | `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode` and derived `format`. Bulk-tags arrays of node ids. |
14
+ | `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode` and operator-supplied `format`. Bulk-tags arrays of node ids. |
15
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
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
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. |
@@ -37,16 +37,16 @@ Do **not** activate for:
37
37
 
38
38
  Voice profiles are segmented by content format. The closed enum is:
39
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'}` |
40
+ | Format | Operator picks for |
41
+ |--------|--------------------|
42
+ | `text` | `:Message` nodes; the SessionEnd hook stamps this automatically on operator PTY turns (the sole hard-coded path chat turns are unambiguously text and the operator is not in the loop on session end) |
43
+ | `email` | Email threads stored as `:KnowledgeDocument {source:'email'}` or `:ConversationArchive {source:'email'}` |
44
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` |
45
+ | `article` | Long-form `:KnowledgeDocument` the operator considers an article |
46
+ | `note` | Short-form `:KnowledgeDocument` the operator considers a note |
47
+ | `marketing-copy` | Any node the operator considers marketing copy |
48
48
 
49
- Format is derived automatically when tagging via `voice-tag-content`. Pass `format` explicitly to override the derivation.
49
+ Format is editorial the operator picks it per node when calling `voice-tag-content`. The tool does not infer it.
50
50
 
51
51
  ## Flow — backfill review (one-shot session)
52
52
 
@@ -63,19 +63,16 @@ Ask the operator which stream to backfill first. Default to chat archives when t
63
63
 
64
64
  ### 2a. Discrete documents — paginate in batches of 10
65
65
 
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:
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). Operator commands name both mode and format:
67
67
 
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`.
70
- - `1,3,7 human-only` — tag a subset.
71
- - `2 human-led-agent-assisted` — single-item mode override.
72
- - `5 agent-led-human-reviewed marketing-copy` — single-item mode and format override.
68
+ - `1-10 human-only article` — tag every item in the batch as `human-only` with format `article`.
69
+ - `1,3,7 human-only email` — tag a subset with mode + format.
70
+ - `2 human-led-agent-assisted note` — single-item.
71
+ - `5 agent-led-human-reviewed marketing-copy` — single-item.
73
72
  - `skip` — leave the batch as `unknown` and advance.
74
73
  - `stop` — exit the backfill; the next session resumes from where the operator left off.
75
74
 
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.
75
+ `format` is required on every command; the tool will not infer it. If the operator omits the format, ask before tagging. Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> count=<n>` per write.
79
76
 
80
77
  ### 2b. Chat archives — paginate per conversation-document parent
81
78
 
@@ -86,7 +83,7 @@ For each conversation-document `:KnowledgeDocument` (with `conversationIdentity`
86
83
  - `not me` — tag every chunk as `agent-only` (e.g. a Slack channel where the operator only forwarded other people's messages).
87
84
  - `skip` / `stop` — same semantics as 2a.
88
85
 
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.
86
+ Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains, passing `format='text'` (chat archives are unambiguously text-format). The skill emits `[voice-tag] mode=<mode> format=<format> count=<chunk-count>` once per conversation.
90
87
 
91
88
  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.
92
89