@rubytech/create-maxy-code 0.1.179 → 0.1.182
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.
- package/package.json +1 -1
- package/payload/platform/lib/graph-trash/package.json +7 -0
- package/payload/premium-plugins/writer-craft/PLUGIN.md +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +21 -7
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +24 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +27 -2
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +52 -12
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +274 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +2 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +25 -7
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +28 -2
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +387 -12
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +2 -0
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +21 -0
|
@@ -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 "../lib/voice-corpus.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
|
|
65
|
-
* values in the corpus and distils each that fires the cadence
|
|
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
|
|
70
|
-
* - `'sample'` (default) —
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* - `'write'` — persist the style card the agent produced.
|
|
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 "../lib/voice-corpus.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. |
|