@rubytech/create-maxy-code 0.1.179 → 0.1.181

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 (27) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-trash/package.json +7 -0
  3. package/payload/premium-plugins/writer-craft/PLUGIN.md +1 -1
  4. package/payload/premium-plugins/writer-craft/lib/graph-trash/dist/index.d.ts +106 -0
  5. package/payload/premium-plugins/writer-craft/lib/graph-trash/dist/index.d.ts.map +1 -0
  6. package/payload/premium-plugins/writer-craft/lib/graph-trash/dist/index.js +340 -0
  7. package/payload/premium-plugins/writer-craft/lib/graph-trash/dist/index.js.map +1 -0
  8. package/payload/premium-plugins/writer-craft/lib/graph-trash/package.json +7 -0
  9. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  10. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +21 -7
  11. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  12. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +11 -3
  13. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  14. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +13 -2
  15. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  16. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +52 -12
  17. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  18. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +274 -0
  19. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  20. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  21. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +2 -0
  22. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  23. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +25 -7
  24. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +14 -2
  25. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +387 -12
  26. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +2 -0
  27. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +21 -0
@@ -104,7 +104,7 @@ server.tool(
104
104
 
105
105
  server.tool(
106
106
  "voice-distil-profile",
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.",
107
+ "Three-mode tool. mode='sample' (default) walks the operator's full per-format human-only corpus and returns exemplars + recent edit intents for the agent to compose a fresh style card; cadence-guarded (≥20% corpus growth OR ≥30 days). mode='amend' (Task 472) reads only the named documents plus the existing :VoiceProfile.styleCard so the agent can decide whether one or more specific documents move the profile — operator-initiated, no cadence guard. mode='write' persists the YAML style card the agent composed; supply amendedFromNodeIds to attribute the write to specific documents (Task 472, bypasses cadence + corpus walk; existing LEARNED_FROM edges remain via MERGE). Trashed nodes are excluded from every walk. When format is omitted on 'sample', enumerates all formats in the corpus and distils each.",
108
108
  {
109
109
  userId: z
110
110
  .string()
@@ -113,22 +113,34 @@ server.tool(
113
113
  "Operator AdminUser identifier. Defaults to ADMIN_USER_ID env from spawn context.",
114
114
  ),
115
115
  format: zFormat.optional().describe(
116
- "Writing format to distil. When omitted, enumerates corpus formats and distils each that fires the cadence guard.",
116
+ "Writing format to distil. Required for mode='amend' and mode='write'. When omitted on 'sample', enumerates corpus formats and distils each that fires the cadence guard.",
117
117
  ),
118
118
  force: z
119
119
  .boolean()
120
120
  .optional()
121
- .describe("Override cadence guard; rebuild even if recent."),
121
+ .describe("Override cadence guard on full-corpus sample/write; ignored by mode='amend' (always proceeds)."),
122
122
  mode: z
123
- .enum(["sample", "write"])
123
+ .enum(["sample", "amend", "write"])
124
124
  .optional()
125
- .describe("'sample' returns corpus exemplars + feedback for in-turn distillation; 'write' persists the agent-produced styleCardYaml."),
125
+ .describe("'sample' returns full-corpus exemplars + feedback for in-turn distillation; 'amend' returns existing styleCard + bodies of one or more named documents for incremental update; 'write' persists the agent-produced styleCardYaml."),
126
126
  styleCardYaml: z
127
127
  .string()
128
128
  .optional()
129
- .describe("Required for mode='write'. The YAML style card the agent composed from the sample-mode exemplars + feedback intents."),
129
+ .describe("Required for mode='write'. The YAML style card the agent composed (from the sample exemplars + feedback intents, or from the amend bodies + existing card)."),
130
+ nodeIds: z
131
+ .array(z.string())
132
+ .optional()
133
+ .describe(
134
+ "Required for mode='amend'. ElementIds of the documents the operator wants folded into the per-format profile (one or more). Each id is eligibility-checked; ineligible ones come back in the ineligible[] payload.",
135
+ ),
136
+ amendedFromNodeIds: z
137
+ .array(z.string())
138
+ .optional()
139
+ .describe(
140
+ "Optional for mode='write' after an amend sample. ElementIds the agent decided actually moved the profile. Each becomes a LEARNED_FROM edge from the persisted :VoiceProfile. Setting this routes the write through the amend-write path (no corpus walk, cadence bypassed).",
141
+ ),
130
142
  },
131
- async ({ userId, format, force, mode, styleCardYaml }) => {
143
+ async ({ userId, format, force, mode, styleCardYaml, nodeIds, amendedFromNodeIds }) => {
132
144
  if (!accountId) return refuseNoAccount("voice-distil-profile");
133
145
  const resolvedUserId = userId ?? resolveUserId();
134
146
  if (!resolvedUserId) {
@@ -151,11 +163,17 @@ server.tool(
151
163
  force: force === true,
152
164
  mode,
153
165
  styleCardYaml,
166
+ nodeIds,
167
+ amendedFromNodeIds,
154
168
  });
155
169
 
156
170
  // Summarise single-format or multi-format result.
157
171
  const summariseOne = (r: import("./tools/voice-distil-profile.js").VoiceDistilProfileResult, fmt?: string): string => {
158
172
  const tag = fmt ? ` format=${fmt}` : "";
173
+ if (r.eligible !== undefined) {
174
+ // amend sample
175
+ return `voice-distil amend${tag}: eligible=${r.eligible} ineligible=${r.ineligible?.length ?? 0} exemplars=${r.exemplars?.length ?? 0} existingProfile=${r.profileId ?? "none"} feedbackEntries=${r.feedbackEntries}\n${JSON.stringify({ existingStyleCard: r.existingStyleCard, exemplars: r.exemplars, feedbackIntents: r.feedbackIntents, ineligible: r.ineligible }, null, 2)}`;
176
+ }
159
177
  if (r.skipped && r.exemplars) {
160
178
  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
179
  }
@@ -1,3 +1,5 @@
1
+ import { notTrashed } from "../../../../../platform/lib/graph-trash/dist/index.js";
2
+
1
3
  /**
2
4
  * Shared corpus-filter clauses and format helpers for the voice-mirror tools.
3
5
  *
@@ -7,7 +9,7 @@
7
9
  * load-bearing filter — when the corpus rules change, this is the only place
8
10
  * to edit.
9
11
  *
10
- * Two predicates in VOICE_CORPUS_WHERE:
12
+ * Three predicates in VOICE_CORPUS_WHERE:
11
13
  *
12
14
  * 1. `authorshipMode IN ['human-only', 'human-led-agent-assisted']`.
13
15
  * `human-only` is the operator's own writing verbatim;
@@ -23,20 +25,30 @@
23
25
  * land as `:KnowledgeDocument` parents carrying `conversationIdentity`
24
26
  * with plain `:Section` children — no `:Conversation` secondary label.
25
27
  * Conversation-document sections are identified by their parent's
26
- * `conversationIdentity` property via an EXISTS sub-pattern.
28
+ * `conversationIdentity` property via an EXISTS sub-pattern. The
29
+ * conversation parent must itself be live — a trashed parent excludes
30
+ * its child chunks.
27
31
  * Chat captures voice in a way email rarely does — this is the
28
32
  * single highest-leverage corpus addition for voice fidelity.
29
33
  *
34
+ * 3. The node is not trashed — `notTrashed("n")` filters both the
35
+ * `:Trashed` label and the `deletedAt` property (Task 472). This is
36
+ * the read-side equivalent of `graph-trash`'s soft-delete model: a
37
+ * node marked by either signal is excluded from every voice-mirror
38
+ * walk that goes through VOICE_CORPUS_WHERE.
39
+ *
30
40
  * The clause expects `$accountId` to be bound in the calling Cypher
31
41
  * parameters. It does not bind any other parameters.
32
42
  */
33
43
  export const VOICE_CORPUS_WHERE = `n.accountId = $accountId
34
44
  AND n.authorshipMode IN ['human-only', 'human-led-agent-assisted']
45
+ AND ${notTrashed("n")}
35
46
  AND (
36
47
  any(lbl IN labels(n) WHERE lbl IN ['KnowledgeDocument', 'Message', 'SocialPost'])
37
48
  OR ('Section' IN labels(n) AND EXISTS {
38
49
  MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
39
50
  WHERE parent.conversationIdentity IS NOT NULL
51
+ AND ${notTrashed("parent")}
40
52
  })
41
53
  )`;
42
54
 
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import neo4j from "neo4j-driver";
27
27
  import { getSession } from "../lib/neo4j.js";
28
+ import { notTrashed } from "../../../../../platform/lib/graph-trash/dist/index.js";
28
29
  import {
29
30
  VOICE_CORPUS_WHERE,
30
31
  voiceCorpusWhereWithFormat,
@@ -61,21 +62,44 @@ export interface VoiceDistilProfileParams {
61
62
  userId: string;
62
63
  force?: boolean;
63
64
  /**
64
- * Writing format to distil. When omitted, enumerates distinct format
65
- * values in the corpus and distils each that fires the cadence guard.
65
+ * Writing format to distil. When omitted on `sample`, enumerates distinct
66
+ * format values in the corpus and distils each that fires the cadence
67
+ * guard. Required on `amend` and `write`.
66
68
  */
67
69
  format?: VoiceFormat;
68
70
  /**
69
- * Operation mode (Task 424):
70
- * - `'sample'` (default) — read the corpus + feedback intents and return
71
- * them. No write. The calling agent composes the style-card YAML
72
- * in-turn from the returned exemplars.
73
- * - `'write'` — persist the style card the agent produced. Requires
74
- * `styleCardYaml`.
71
+ * Operation mode:
72
+ * - `'sample'` (default, Task 424) — walk the full per-format corpus and
73
+ * return exemplars + feedback intents. No write. The calling agent
74
+ * composes the style-card YAML in-turn.
75
+ * - `'write'` (Task 424) — persist the style card the agent produced.
76
+ * Requires `styleCardYaml`. Optional `amendedFromNodeIds` adds
77
+ * `LEARNED_FROM` edges to the named nodes (additive — MERGE preserves
78
+ * prior edges).
79
+ * - `'amend'` (Task 472) — operator-initiated incremental update from
80
+ * one or more named documents. Reads only those nodes plus the
81
+ * existing `:VoiceProfile.styleCard` so the agent can decide whether
82
+ * the document moves the profile. Format predicate is dropped on the
83
+ * read (the operator picked the docs); `authorshipMode` + trashed +
84
+ * label predicates still apply per-document. Cadence guard does not
85
+ * apply.
75
86
  */
76
- mode?: "sample" | "write";
87
+ mode?: "sample" | "write" | "amend";
77
88
  /** Required when `mode='write'`. The style-card YAML produced by the agent. */
78
89
  styleCardYaml?: string;
90
+ /**
91
+ * Required when `mode='amend'`. ElementIds of the documents to fold into
92
+ * the profile. Each id is eligibility-checked; ineligible ones are
93
+ * reported in the return payload (the agent surfaces them to the operator).
94
+ */
95
+ nodeIds?: string[];
96
+ /**
97
+ * Optional on `mode='write'` after an `amend` sample. ElementIds the
98
+ * agent decided moved the profile. Each becomes a `LEARNED_FROM` edge
99
+ * from the persisted `:VoiceProfile`. Additive — pre-existing edges
100
+ * remain via `MERGE`.
101
+ */
102
+ amendedFromNodeIds?: string[];
79
103
  }
80
104
 
81
105
  export interface VoiceCorpusExemplar {
@@ -95,12 +119,27 @@ export interface VoiceDistilProfileResult {
95
119
  | "recent"
96
120
  | "empty-corpus"
97
121
  | "missing-style-card"
98
- | "no-format-tagged-content";
122
+ | "no-format-tagged-content"
123
+ | "amend-no-eligible-documents";
99
124
  errorReason?: string;
100
- /** Present when `mode='sample'`: corpus exemplars for the agent to read. */
125
+ /** Present when `mode='sample'` or `mode='amend'`: corpus exemplars for the agent to read. */
101
126
  exemplars?: VoiceCorpusExemplar[];
102
- /** Present when `mode='sample'`: operator-edit intents to fold into the style card. */
127
+ /** Present when `mode='sample'` or `mode='amend'`: operator-edit intents to fold into the style card. */
103
128
  feedbackIntents?: string[];
129
+ /**
130
+ * Present when `mode='amend'`: the current `:VoiceProfile.styleCard` (or
131
+ * null if no profile exists yet) so the agent can compare the named
132
+ * documents against the prior card and decide update-or-skip.
133
+ */
134
+ existingStyleCard?: string | null;
135
+ /** Present when `mode='amend'`: count of input nodeIds that passed eligibility. */
136
+ eligible?: number;
137
+ /**
138
+ * Present when `mode='amend'`: per-input-id ineligibility records. Each
139
+ * entry names the nodeId and the first failing predicate so the agent
140
+ * can tell the operator which documents were rejected and why.
141
+ */
142
+ ineligible?: { nodeId: string; reason: string }[];
104
143
  }
105
144
 
106
145
  // The agent produces a YAML style card with at minimum these keys:
@@ -205,8 +244,10 @@ async function distilForFormat(
205
244
  WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
206
245
  OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
207
246
  WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
247
+ AND ${notTrashed("parent")}
208
248
  WITH n, ts, parent
209
249
  OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
250
+ WHERE ${notTrashed("s")}
210
251
  WITH n, ts, parent, s ORDER BY s.position
211
252
  WITH n, ts, parent, collect(s.body) AS sectionBodies
212
253
  WITH n, ts, parent,
@@ -336,6 +377,293 @@ async function distilForFormat(
336
377
  }
337
378
  }
338
379
 
380
+ /**
381
+ * Single-document amend (Task 472).
382
+ *
383
+ * Operator-initiated incremental update: the operator names one or more
384
+ * documents (`nodeIds`), the tool reads only those plus the existing
385
+ * `:VoiceProfile.styleCard` for the format, and the calling agent decides
386
+ * whether the documents move the profile or don't. Same body-resolution
387
+ * and trashed/authorshipMode/label predicates as the full-corpus sample;
388
+ * the corpus-wide format predicate is dropped (the operator picked the
389
+ * docs); the cadence guard does not apply.
390
+ */
391
+ async function amendForFormat(params: {
392
+ accountId: string;
393
+ userId: string;
394
+ format: VoiceFormat;
395
+ nodeIds: string[];
396
+ }): Promise<VoiceDistilProfileResult> {
397
+ const { accountId, userId, format, nodeIds } = params;
398
+ const session = getSession();
399
+
400
+ try {
401
+ // 1. Existing profile (may be absent — first-time amend with no prior card).
402
+ const profileRow = await session.run(
403
+ `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
404
+ OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
405
+ RETURN elementId(p) AS profileId,
406
+ p.styleCard AS styleCard,
407
+ p.corpusSize AS corpusSize,
408
+ p.generatedAt AS generatedAt`,
409
+ { accountId, userId, format },
410
+ );
411
+ const prow = profileRow.records[0];
412
+ const prevProfileId = (prow?.get("profileId") as string | null) ?? null;
413
+ const existingStyleCard = (prow?.get("styleCard") as string | null) ?? null;
414
+ const prevCorpusSize = toJsNumber(prow?.get("corpusSize")) ?? 0;
415
+ const prevGeneratedAt = (prow?.get("generatedAt") as string | null) ?? null;
416
+
417
+ // 2. Per-document eligibility classification. Each input id gets a
418
+ // verdict; the first failing predicate wins. The predicate set mirrors
419
+ // VOICE_CORPUS_WHERE except `format` is dropped (operator picked the
420
+ // docs) and conversation-section sub-pattern is inlined for the
421
+ // per-document case.
422
+ const eligibilityResult = await session.run(
423
+ `UNWIND $nodeIds AS nid
424
+ OPTIONAL MATCH (n) WHERE elementId(n) = nid
425
+ WITH nid, n,
426
+ CASE
427
+ WHEN n IS NULL THEN 'node-not-found'
428
+ WHEN n.accountId <> $accountId THEN 'wrong-account'
429
+ WHEN NOT coalesce(n.authorshipMode, '') IN ['human-only', 'human-led-agent-assisted']
430
+ THEN 'ineligible-authorship-mode'
431
+ WHEN n:Trashed OR n.deletedAt IS NOT NULL THEN 'trashed'
432
+ WHEN NOT (
433
+ any(lbl IN labels(n) WHERE lbl IN ['KnowledgeDocument', 'Message', 'SocialPost'])
434
+ OR ('Section' IN labels(n) AND EXISTS {
435
+ MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
436
+ WHERE parent.conversationIdentity IS NOT NULL
437
+ AND ${notTrashed("parent")}
438
+ })
439
+ ) THEN 'ineligible-label'
440
+ ELSE 'ok'
441
+ END AS verdict
442
+ RETURN nid, verdict`,
443
+ { accountId, nodeIds },
444
+ );
445
+
446
+ const eligibleIds: string[] = [];
447
+ const ineligible: { nodeId: string; reason: string }[] = [];
448
+ for (const r of eligibilityResult.records) {
449
+ const nid = r.get("nid") as string;
450
+ const verdict = r.get("verdict") as string;
451
+ if (verdict === "ok") eligibleIds.push(nid);
452
+ else ineligible.push({ nodeId: nid, reason: verdict });
453
+ }
454
+
455
+ // 3. Feedback intents (same as sample) — agent benefits even on a no-op
456
+ // amend ("you've already corrected this voice tic ten times").
457
+ const feedback = await session.run(
458
+ `MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
459
+ RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`,
460
+ { accountId, userId, format },
461
+ );
462
+ const feedbackIntents = feedback.records
463
+ .map((r) => r.get("intent") as string | null)
464
+ .filter((x): x is string => typeof x === "string" && x.length > 0);
465
+
466
+ if (eligibleIds.length === 0) {
467
+ process.stderr.write(
468
+ `[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
469
+ `existingProfile=${prevProfileId ?? "none"} eligible=0 ineligible=${ineligible.length}\n`,
470
+ );
471
+ return {
472
+ profileId: prevProfileId,
473
+ corpusSize: prevCorpusSize,
474
+ generatedAt: prevGeneratedAt,
475
+ feedbackEntries: feedbackIntents.length,
476
+ skipped: true,
477
+ skipReason: "amend-no-eligible-documents",
478
+ existingStyleCard,
479
+ eligible: 0,
480
+ ineligible,
481
+ exemplars: [],
482
+ feedbackIntents,
483
+ };
484
+ }
485
+
486
+ // 4. Body walk for eligible ids — identical body resolution to the
487
+ // full-corpus sample path: KD-with-Section-children concatenates child
488
+ // bodies in `position` order (trashed sections filtered); other labels
489
+ // fall through to the COALESCE chain.
490
+ const bodyResult = await session.run(
491
+ `UNWIND $eligibleIds AS nid
492
+ MATCH (n) WHERE elementId(n) = nid
493
+ OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
494
+ WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
495
+ AND ${notTrashed("parent")}
496
+ WITH n, parent
497
+ OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
498
+ WHERE ${notTrashed("s")}
499
+ WITH n, parent, s ORDER BY s.position
500
+ WITH n, parent, collect(s.body) AS sectionBodies
501
+ WITH n, parent,
502
+ 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0 AS usedSectionWalk,
503
+ CASE
504
+ WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
505
+ THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
506
+ ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
507
+ END AS body
508
+ RETURN elementId(n) AS id,
509
+ labels(n) AS labels,
510
+ body,
511
+ usedSectionWalk,
512
+ parent.conversationIdentity IS NOT NULL AS isConversationChunk`,
513
+ { eligibleIds },
514
+ );
515
+
516
+ const exemplars: VoiceCorpusExemplar[] = [];
517
+ let charBudget = SAMPLE_CHAR_CAP;
518
+ let sectionWalkCount = 0;
519
+ let nBodyCount = 0;
520
+ for (const r of bodyResult.records) {
521
+ const body = (r.get("body") as string | null) ?? "";
522
+ if (body.length === 0) continue;
523
+ const labels = (r.get("labels") as string[]) ?? [];
524
+ const isConversationChunk = Boolean(r.get("isConversationChunk"));
525
+ const usedSectionWalk = Boolean(r.get("usedSectionWalk"));
526
+ const label = isConversationChunk
527
+ ? "Conversation"
528
+ : (labels.find((l) =>
529
+ ["KnowledgeDocument", "Message", "SocialPost"].includes(l),
530
+ ) ?? labels[0] ?? "Unknown");
531
+ const trimmed = body.length > charBudget ? body.slice(0, charBudget) : body;
532
+ exemplars.push({ id: r.get("id") as string, label, body: trimmed });
533
+ if (usedSectionWalk) sectionWalkCount += 1;
534
+ else nBodyCount += 1;
535
+ charBudget -= trimmed.length;
536
+ if (charBudget <= 0) break;
537
+ }
538
+
539
+ process.stderr.write(
540
+ `[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
541
+ `existingProfile=${prevProfileId ?? "none"} eligible=${eligibleIds.length} ineligible=${ineligible.length} ` +
542
+ `exemplars=${exemplars.length} sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`,
543
+ );
544
+
545
+ // `skipped: false` because the function DID deliver value — exemplars
546
+ // + existingStyleCard for the agent to act on. The agent decides
547
+ // whether to compose new YAML (calls `mode='write'`) or replies "no
548
+ // material delta"; either is a valid amend outcome, neither is a
549
+ // "skip" in the cadence-guard sense.
550
+ return {
551
+ profileId: prevProfileId,
552
+ corpusSize: prevCorpusSize,
553
+ generatedAt: prevGeneratedAt,
554
+ feedbackEntries: feedbackIntents.length,
555
+ skipped: false,
556
+ existingStyleCard,
557
+ eligible: eligibleIds.length,
558
+ ineligible,
559
+ exemplars,
560
+ feedbackIntents,
561
+ };
562
+ } finally {
563
+ await session.close();
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Amend write (Task 472). Persists the agent-composed style card after an
569
+ * `amend` sample. Bypasses the cadence guard and the full corpus walk; the
570
+ * only `LEARNED_FROM` edges added are to `amendedFromNodeIds`. Pre-existing
571
+ * `LEARNED_FROM` edges remain via `MERGE`. `corpusSize` on the profile is
572
+ * untouched — the amend doesn't change the operator's qualifying corpus,
573
+ * only the styleCard's interpretation of it.
574
+ */
575
+ async function amendWriteForFormat(params: {
576
+ accountId: string;
577
+ userId: string;
578
+ format: VoiceFormat;
579
+ styleCardYaml: string;
580
+ amendedFromNodeIds: string[];
581
+ }): Promise<VoiceDistilProfileResult> {
582
+ const { accountId, userId, format, styleCardYaml, amendedFromNodeIds } = params;
583
+ const session = getSession();
584
+ const nowIso = new Date().toISOString();
585
+ try {
586
+ // 1. Pre-resolve the amend ids and refuse if none survive. The write
587
+ // Cypher SETs `p` before the UNWIND/MATCH on amend ids, so if every
588
+ // id is stale the profile would be mutated then the function would
589
+ // throw — leaving a silently advanced styleCard the operator never
590
+ // confirmed. Resolving here means the SET never runs on an empty
591
+ // target list, and stale ids surface as a structured error before
592
+ // any write commits. Code-review pass-1 fix.
593
+ const resolve = await session.run(
594
+ `UNWIND $amendIds AS sid
595
+ MATCH (s) WHERE elementId(s) = sid
596
+ RETURN elementId(s) AS id`,
597
+ { amendIds: amendedFromNodeIds },
598
+ );
599
+ const resolvedAmendIds = resolve.records.map(
600
+ (r) => r.get("id") as string,
601
+ );
602
+ if (resolvedAmendIds.length === 0) {
603
+ throw new Error(
604
+ `voice-mirror: none of amendedFromNodeIds=${JSON.stringify(amendedFromNodeIds)} ` +
605
+ `resolved to live nodes. Confirm the named docs exist and are accessible.`,
606
+ );
607
+ }
608
+
609
+ const feedback = await session.run(
610
+ `MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
611
+ RETURN count(e) AS c`,
612
+ { accountId, userId, format },
613
+ );
614
+ const feedbackEntries = toJsNumber(feedback.records[0]?.get("c")) ?? 0;
615
+
616
+ const result = await session.run(
617
+ `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
618
+ MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
619
+ ON CREATE SET p.createdAt = $now, p.corpusSize = 0
620
+ SET p.styleCard = $styleCard,
621
+ p.generatedAt = $now,
622
+ p.feedbackEntries = $feedbackEntries,
623
+ p.updatedAt = $now
624
+ WITH p
625
+ UNWIND $amendIds AS sid
626
+ MATCH (s) WHERE elementId(s) = sid
627
+ MERGE (p)-[:LEARNED_FROM]->(s)
628
+ WITH p
629
+ RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize`,
630
+ {
631
+ accountId,
632
+ userId,
633
+ format,
634
+ styleCard: styleCardYaml,
635
+ now: nowIso,
636
+ feedbackEntries,
637
+ amendIds: resolvedAmendIds,
638
+ },
639
+ );
640
+ if (result.records.length === 0) {
641
+ throw new Error(
642
+ `voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. ` +
643
+ `Onboarding must promote the operator to :AdminUser before distillation.`,
644
+ );
645
+ }
646
+ const profileId = result.records[0].get("profileId") as string;
647
+ const corpusSize = toJsNumber(result.records[0].get("corpusSize")) ?? 0;
648
+
649
+ process.stderr.write(
650
+ `[voice-distil] mode=write userId=${userId} format=${format} amended=true ` +
651
+ `amendedFromNodeIds=${amendedFromNodeIds.length} corpusSize=${corpusSize} ` +
652
+ `generatedAt=${nowIso} feedbackEntries=${feedbackEntries}\n`,
653
+ );
654
+
655
+ return {
656
+ profileId,
657
+ corpusSize,
658
+ generatedAt: nowIso,
659
+ feedbackEntries,
660
+ skipped: false,
661
+ };
662
+ } finally {
663
+ await session.close();
664
+ }
665
+ }
666
+
339
667
  export async function voiceDistilProfile(
340
668
  params: VoiceDistilProfileParams,
341
669
  ): Promise<VoiceDistilProfileResult | VoiceDistilProfileResult[]> {
@@ -344,6 +672,53 @@ export async function voiceDistilProfile(
344
672
  throw new Error("voice-distil-profile: accountId and userId required");
345
673
  }
346
674
 
675
+ // Amend sample (Task 472): operator-initiated single-document update.
676
+ if (params.mode === "amend") {
677
+ if (!params.format) {
678
+ throw new Error("voice-distil-profile: mode='amend' requires format");
679
+ }
680
+ const nodeIds = params.nodeIds ?? [];
681
+ if (nodeIds.length === 0) {
682
+ throw new Error(
683
+ "voice-distil-profile: mode='amend' requires nodeIds (at least one)",
684
+ );
685
+ }
686
+ return amendForFormat({
687
+ accountId,
688
+ userId,
689
+ format: params.format,
690
+ nodeIds,
691
+ });
692
+ }
693
+
694
+ // Amend write (Task 472): persist after the agent composed from an amend
695
+ // sample. Triggered by `mode='write'` + non-empty `amendedFromNodeIds`.
696
+ // Distinct from the full-corpus write — bypasses cadence + corpus walk.
697
+ if (
698
+ params.mode === "write" &&
699
+ params.amendedFromNodeIds &&
700
+ params.amendedFromNodeIds.length > 0
701
+ ) {
702
+ if (!params.format) {
703
+ throw new Error(
704
+ "voice-distil-profile: mode='write' with amendedFromNodeIds requires format",
705
+ );
706
+ }
707
+ const styleCardYaml = (params.styleCardYaml ?? "").trim();
708
+ if (!styleCardYaml) {
709
+ throw new Error(
710
+ "voice-distil-profile: mode='write' requires styleCardYaml (the YAML you composed from the amend-sample response).",
711
+ );
712
+ }
713
+ return amendWriteForFormat({
714
+ accountId,
715
+ userId,
716
+ format: params.format,
717
+ styleCardYaml,
718
+ amendedFromNodeIds: params.amendedFromNodeIds,
719
+ });
720
+ }
721
+
347
722
  // Single-format path.
348
723
  if (params.format !== undefined) {
349
724
  return distilForFormat({ ...params, format: params.format, force });
@@ -25,6 +25,7 @@
25
25
  * calling drafting skill falls back to its existing default register.
26
26
  */
27
27
  import { getSession } from "../lib/neo4j.js";
28
+ import { notTrashed } from "../../../../../platform/lib/graph-trash/dist/index.js";
28
29
  import {
29
30
  VOICE_CORPUS_WHERE,
30
31
  voiceCorpusWhereWithFormat,
@@ -112,6 +113,7 @@ export async function voiceRetrieveConditioning(
112
113
 
113
114
  const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
114
115
  OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
116
+ WHERE ${notTrashed("s")}
115
117
  WITH n, ts, s ORDER BY s.position
116
118
  WITH n, ts, collect(s.body) AS sectionBodies
117
119
  WITH n, ts, CASE
@@ -153,6 +153,25 @@ A re-run within the 30-day window with <20% growth is a no-op — the tool retur
153
153
 
154
154
  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".
155
155
 
156
+ ### Amend mode (single-document update)
157
+
158
+ Activate when the operator names one or more specific documents and wants them folded into their voice profile right now — e.g. **"I just published this new article, take that into account"**, **"that thread to Sarah is a good example of how I write — fold it in"**, or **"add this essay to my voice profile"**. The full-corpus distil cadence is wrong for "this specific document, now" — the operator's signal is the document choice, not the cadence guard.
159
+
160
+ Call `voice-distil-profile` with:
161
+
162
+ - `mode: 'amend'`
163
+ - `format: <VoiceFormat>` — required; the operator's per-format profile
164
+ - `nodeIds: [<elementId>, ...]` — the documents the operator picked (one or more)
165
+
166
+ The tool returns `{existingStyleCard, exemplars[], feedbackIntents[], eligible, ineligible}`. Compare the named exemplars against the prior card and the recent edit intents, then choose:
167
+
168
+ - **Material delta** — compose an updated YAML and call `mode: 'write'` with `styleCardYaml` and `amendedFromNodeIds: [<elementId>, ...]` listing the documents that actually moved the card. The write bypasses the cadence guard and adds `LEARNED_FROM` edges to those documents (pre-existing edges remain).
169
+ - **No material delta** — reply to the operator in-turn ("nothing in that article shifts your voice card; the prior profile still applies"). Do not call `mode: 'write'`. `generatedAt` only advances on a write.
170
+
171
+ When `eligible=0` (every named document failed the predicate) surface the `ineligible[]` payload to the operator naming the per-id reason (`node-not-found`, `wrong-account`, `ineligible-authorship-mode`, `trashed`, `ineligible-label`) rather than silently no-op. Most commonly this means the documents are tagged as `agent-only` and need re-tagging via `voice-tag-content` before they can amend a profile.
172
+
173
+ Amend mode does not apply to the SessionEnd auto-ingest path. Session-text ingest stays on the full-corpus path; amend is operator-initiated only.
174
+
156
175
  ## Per-operator scope
157
176
 
158
177
  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.
@@ -164,6 +183,8 @@ One `:VoiceProfile` per `(accountId, userId, format)`. Multi-author accounts (an
164
183
  | `[voice-tag] mode=<mode> format=<format> nodeId=<id> count=<n>` | Every tag write. |
165
184
  | `[voice-distil] userId=<id> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
166
185
  | `[voice-distil] skip reason=<below-threshold\|recent> format=<format>` | When the cadence guard fires. |
186
+ | `[voice-distil] mode=amend userId=<id> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
187
+ | `[voice-distil] mode=write userId=<id> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
167
188
  | `[voice-retrieve] task=<topic> format=<format> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. |
168
189
  | `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
169
190
  | `[voice-session-end-hook] trigger sessionId=<id>` | Hook fires for an eligible session. |