@rubytech/create-maxy-code 0.1.175 → 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.
- package/dist/index.js +13 -4
- package/package.json +1 -1
- package/payload/platform/plugins/email/skills/email-composition/SKILL.md +1 -1
- package/payload/platform/plugins/memory/bin/conversation-archive-ingest.mjs +38 -3
- package/payload/platform/plugins/memory/mcp/dist/index.js +17 -7
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.d.ts +8 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.js +27 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/conversation-pipeline/turn-text-cache-path.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts +15 -2
- package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js +95 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.js +124 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-chat-body-server-sliced.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +104 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts +12 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js +38 -13
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +23 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +234 -20
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
- package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +7 -3
- package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +12 -3
- package/payload/platform/plugins/substack-import/skills/substack-import/SKILL.md +1 -0
- package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
- package/payload/premium-plugins/writer-craft/PLUGIN.md +2 -2
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +3 -3
- 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 +4 -22
- 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 +4 -36
- 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.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +34 -2
- 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 +24 -5
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +2 -2
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +14 -28
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +21 -41
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +5 -5
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +4 -41
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +32 -2
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +25 -5
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +16 -31
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +16 -19
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* 5. TAGGABLE_PRIMARY_LABELS covers the three content labels.
|
|
12
12
|
* 6. FORMAT_VALUES covers the six writing-format values (Task 462).
|
|
13
13
|
* 7. voiceCorpusWhereWithFormat() appends the format predicate.
|
|
14
|
-
* 8.
|
|
15
|
-
* 9. voice-retrieve-conditioning returns the graceful-degradation shape
|
|
14
|
+
* * 8. voice-retrieve-conditioning returns the graceful-degradation shape
|
|
16
15
|
* {styleCard: null, exemplars: []} on empty accountId/userId.
|
|
17
|
-
*
|
|
18
|
-
* on empty nodeIds.
|
|
16
|
+
* 9. voice-tag-content validates mode and format enums, requires format,
|
|
17
|
+
* and returns zero counts on empty nodeIds (with format supplied).
|
|
19
18
|
* 11. voice-ingest-session-text: extractOperatorTurns() extracts real turns
|
|
20
19
|
* and excludes slash commands, paste blocks, and system reminders.
|
|
21
20
|
*
|
|
@@ -164,44 +163,11 @@ await check("voiceCorpusWhereWithFormat appends format predicate", () => {
|
|
|
164
163
|
});
|
|
165
164
|
|
|
166
165
|
// ---------------------------------------------------------------------------
|
|
167
|
-
// 4. deriveFormat()
|
|
166
|
+
// 4. deriveFormat is gone (Task 471) — format is operator-supplied editorial.
|
|
168
167
|
// ---------------------------------------------------------------------------
|
|
169
|
-
console.log("\n--
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await check("deriveFormat: :Message → text", () => {
|
|
173
|
-
assert.equal(deriveFormat(["Message"]), "text");
|
|
174
|
-
assert.equal(deriveFormat(["Message", "SomeOther"]), "text");
|
|
175
|
-
});
|
|
176
|
-
await check("deriveFormat: :SocialPost → social-post", () => {
|
|
177
|
-
assert.equal(deriveFormat(["SocialPost"]), "social-post");
|
|
178
|
-
});
|
|
179
|
-
await check("deriveFormat: :KnowledgeDocument {source:'email'} → email", () => {
|
|
180
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], "email"), "email");
|
|
181
|
-
});
|
|
182
|
-
await check("deriveFormat: :ConversationArchive {source:'email'} → email", () => {
|
|
183
|
-
assert.equal(deriveFormat(["ConversationArchive"], "email"), "email");
|
|
184
|
-
});
|
|
185
|
-
await check("deriveFormat: :KnowledgeDocument body > 1500 → article", () => {
|
|
186
|
-
const longBody = "x".repeat(1501);
|
|
187
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], null, longBody), "article");
|
|
188
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], "other", longBody), "article");
|
|
189
|
-
});
|
|
190
|
-
await check("deriveFormat: :KnowledgeDocument body = 1500 → note", () => {
|
|
191
|
-
const borderBody = "x".repeat(1500);
|
|
192
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], null, borderBody), "note");
|
|
193
|
-
});
|
|
194
|
-
await check("deriveFormat: :KnowledgeDocument body ≤ 1500 → note", () => {
|
|
195
|
-
const shortBody = "x".repeat(100);
|
|
196
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], null, shortBody), "note");
|
|
197
|
-
assert.equal(deriveFormat(["KnowledgeDocument"], undefined, undefined), "note");
|
|
198
|
-
});
|
|
199
|
-
await check("deriveFormat: :Section (conversation chunk) → text", () => {
|
|
200
|
-
assert.equal(deriveFormat(["Section"]), "text");
|
|
201
|
-
});
|
|
202
|
-
await check("deriveFormat: unknown label → text (fallback)", () => {
|
|
203
|
-
assert.equal(deriveFormat(["Unknown"]), "text");
|
|
204
|
-
assert.equal(deriveFormat([]), "text");
|
|
168
|
+
console.log("\n-- format is operator-supplied --");
|
|
169
|
+
await check("deriveFormat export is removed from voice-corpus", () => {
|
|
170
|
+
assert.equal(corpusMod.deriveFormat, undefined);
|
|
205
171
|
});
|
|
206
172
|
|
|
207
173
|
// ---------------------------------------------------------------------------
|
|
@@ -242,6 +208,7 @@ await check("voice-tag-content rejects an invalid mode", async () => {
|
|
|
242
208
|
nodeIds: ["x"],
|
|
243
209
|
mode: "not-a-mode",
|
|
244
210
|
accountId: "acc",
|
|
211
|
+
format: "text",
|
|
245
212
|
}),
|
|
246
213
|
/invalid authorshipMode/,
|
|
247
214
|
);
|
|
@@ -265,15 +232,28 @@ await check("voice-tag-content rejects empty accountId", async () => {
|
|
|
265
232
|
nodeIds: ["x"],
|
|
266
233
|
mode: "human-only",
|
|
267
234
|
accountId: "",
|
|
235
|
+
format: "text",
|
|
268
236
|
}),
|
|
269
237
|
/accountId is required/,
|
|
270
238
|
);
|
|
271
239
|
});
|
|
240
|
+
await check("voice-tag-content rejects missing format", async () => {
|
|
241
|
+
await assert.rejects(
|
|
242
|
+
() =>
|
|
243
|
+
tagMod.voiceTagContent({
|
|
244
|
+
nodeIds: ["x"],
|
|
245
|
+
mode: "human-only",
|
|
246
|
+
accountId: "acc",
|
|
247
|
+
}),
|
|
248
|
+
/format is required/,
|
|
249
|
+
);
|
|
250
|
+
});
|
|
272
251
|
await check("voice-tag-content returns zero counts on empty nodeIds (no DB hit)", async () => {
|
|
273
252
|
const result = await tagMod.voiceTagContent({
|
|
274
253
|
nodeIds: [],
|
|
275
254
|
mode: "human-only",
|
|
276
255
|
accountId: "acc",
|
|
256
|
+
format: "text",
|
|
277
257
|
});
|
|
278
258
|
assert.deepEqual(result, { updated: 0, skipped: 0, skippedReasons: {} });
|
|
279
259
|
});
|
|
@@ -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
|
|
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.
|
|
71
|
-
"Writing format.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
107
|
-
|
|
126
|
+
${bodyComputeBlock}
|
|
127
|
+
WHERE toLower(body) CONTAINS toLower($topic)
|
|
108
128
|
RETURN elementId(n) AS id,
|
|
109
129
|
labels(n) AS labels,
|
|
110
|
-
|
|
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
|
-
|
|
137
|
+
${bodyComputeBlock}
|
|
118
138
|
RETURN elementId(n) AS id,
|
|
119
139
|
labels(n) AS labels,
|
|
120
|
-
|
|
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`
|
|
3
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
44
|
-
format
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
41
|
-
|
|
42
|
-
| `text` | `:Message` nodes
|
|
43
|
-
| `email` | `:KnowledgeDocument {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`
|
|
46
|
-
| `note` | `:KnowledgeDocument`
|
|
47
|
-
| `marketing-copy` |
|
|
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
|
|
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).
|
|
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
|
|
69
|
-
- `1
|
|
70
|
-
- `
|
|
71
|
-
- `
|
|
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
|
-
|
|
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
|
|