@rubytech/create-maxy-code 0.1.174 → 0.1.175
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/neo4j/schema.cypher +49 -10
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +18 -16
- package/payload/platform/scripts/setup-account.sh +8 -0
- package/payload/platform/scripts/verify-skill-tool-surface.sh +27 -8
- package/payload/premium-plugins/writer-craft/PLUGIN.md +11 -7
- package/payload/premium-plugins/writer-craft/hooks/__tests__/voice-session-end-text.test.sh +284 -0
- package/payload/premium-plugins/writer-craft/hooks/voice-session-end-text.sh +75 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts +3 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js +58 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +95 -18
- 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 +47 -6
- 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 +69 -6
- 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 +8 -2
- 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 +82 -53
- 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-ingest-session-text.d.ts +23 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +191 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +6 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -6
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +12 -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 +23 -12
- 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 +3 -0
- 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 +33 -5
- 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 +289 -28
- package/payload/premium-plugins/writer-craft/mcp/src/cli/ingest-session-text.ts +65 -0
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +108 -18
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +78 -6
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +115 -56
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +229 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +20 -6
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +38 -13
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +42 -5
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +65 -31
package/package.json
CHANGED
|
@@ -1279,13 +1279,17 @@ CREATE INDEX postal_address_postcode IF NOT EXISTS
|
|
|
1279
1279
|
FOR (a:PostalAddress) ON (a.postalCode);
|
|
1280
1280
|
|
|
1281
1281
|
// ----------------------------------------------------------
|
|
1282
|
-
// Voice Mirror (Task 356) — authorial-voice capture + application
|
|
1282
|
+
// Voice Mirror (Task 356 + Task 462) — authorial-voice capture + application
|
|
1283
1283
|
// ----------------------------------------------------------
|
|
1284
|
-
// :VoiceProfile holds the distilled style card for one
|
|
1285
|
-
//
|
|
1284
|
+
// :VoiceProfile holds the distilled style card for one (AdminUser, format).
|
|
1285
|
+
// Task 462: one profile per (accountId, userId, format); format ∈
|
|
1286
|
+
// {text, email, social-post, article, marketing-copy, note}.
|
|
1287
|
+
// The old (accountId, adminUserId) unique constraint is dropped and replaced.
|
|
1288
|
+
//
|
|
1286
1289
|
// :VoiceEdit captures operator edits on agent drafts as feedback,
|
|
1287
1290
|
// stamped with a Haiku-summarised intent; many edits feed back into
|
|
1288
|
-
// the next distillation.
|
|
1291
|
+
// the next distillation. VoiceEdit now carries `format` to route
|
|
1292
|
+
// FEEDBACK_FOR edges to the matching per-format :VoiceProfile.
|
|
1289
1293
|
//
|
|
1290
1294
|
// `authorshipMode` is an enum property on content-bearing labels
|
|
1291
1295
|
// (`:KnowledgeDocument`, `:ConversationArchive` (Task 397 — every chat
|
|
@@ -1293,30 +1297,62 @@ FOR (a:PostalAddress) ON (a.postalCode);
|
|
|
1293
1297
|
// `:Message`, `:SocialPost`) with
|
|
1294
1298
|
// values `human-only | human-led-agent-assisted | agent-led-human-reviewed
|
|
1295
1299
|
// | agent-only | unknown`. Legacy nodes default to `unknown` until
|
|
1296
|
-
// reviewed via the voice-mirror backfill flow. The
|
|
1297
|
-
// distillation walk filter
|
|
1300
|
+
// reviewed via the voice-mirror backfill flow. The per-label authorship
|
|
1301
|
+
// index lets the distillation walk filter cheaply.
|
|
1302
|
+
//
|
|
1303
|
+
// `format` is an enum property on corpus nodes (same label set) and on
|
|
1304
|
+
// :VoiceProfile nodes. The per-label format index lets per-format corpus
|
|
1305
|
+
// walks and profile reads avoid full-label scans.
|
|
1306
|
+
|
|
1307
|
+
// Task 462 — drop the old (accountId, adminUserId) constraint before
|
|
1308
|
+
// creating the new (accountId, userId, format) three-key constraint.
|
|
1309
|
+
// `IF EXISTS` makes the DROP idempotent on fresh installs.
|
|
1310
|
+
DROP CONSTRAINT voice_profile_admin_unique IF EXISTS;
|
|
1298
1311
|
|
|
1299
|
-
CREATE CONSTRAINT
|
|
1300
|
-
FOR (v:VoiceProfile) REQUIRE (v.accountId, v.
|
|
1312
|
+
CREATE CONSTRAINT voice_profile_format_unique IF NOT EXISTS
|
|
1313
|
+
FOR (v:VoiceProfile) REQUIRE (v.accountId, v.userId, v.format) IS UNIQUE;
|
|
1301
1314
|
|
|
1302
1315
|
CREATE INDEX voice_profile_account IF NOT EXISTS
|
|
1303
1316
|
FOR (v:VoiceProfile) ON (v.accountId);
|
|
1304
1317
|
|
|
1305
|
-
CREATE INDEX
|
|
1306
|
-
FOR (
|
|
1318
|
+
CREATE INDEX voice_profile_format IF NOT EXISTS
|
|
1319
|
+
FOR (v:VoiceProfile) ON (v.format);
|
|
1320
|
+
|
|
1321
|
+
CREATE INDEX voice_edit_user IF NOT EXISTS
|
|
1322
|
+
FOR (e:VoiceEdit) ON (e.userId);
|
|
1307
1323
|
|
|
1308
1324
|
CREATE INDEX knowledge_document_authorship IF NOT EXISTS
|
|
1309
1325
|
FOR (k:KnowledgeDocument) ON (k.authorshipMode);
|
|
1310
1326
|
|
|
1327
|
+
CREATE INDEX knowledge_document_format IF NOT EXISTS
|
|
1328
|
+
FOR (k:KnowledgeDocument) ON (k.format);
|
|
1329
|
+
|
|
1311
1330
|
CREATE INDEX conversation_archive_authorship IF NOT EXISTS
|
|
1312
1331
|
FOR (c:ConversationArchive) ON (c.authorshipMode);
|
|
1313
1332
|
|
|
1333
|
+
CREATE INDEX conversation_archive_format IF NOT EXISTS
|
|
1334
|
+
FOR (c:ConversationArchive) ON (c.format);
|
|
1335
|
+
|
|
1314
1336
|
CREATE INDEX message_authorship IF NOT EXISTS
|
|
1315
1337
|
FOR (m:Message) ON (m.authorshipMode);
|
|
1316
1338
|
|
|
1339
|
+
CREATE INDEX message_format IF NOT EXISTS
|
|
1340
|
+
FOR (m:Message) ON (m.format);
|
|
1341
|
+
|
|
1342
|
+
// contentHash index — deduplication MERGE in voice-ingest-session-text uses
|
|
1343
|
+
// MERGE (n:Message {contentHash: $hash, accountId: $accountId}). Without this
|
|
1344
|
+
// index, each MERGE scans all :Message nodes for the account. The index on
|
|
1345
|
+
// contentHash alone lets neo4j narrow the lookup before applying the
|
|
1346
|
+
// accountId predicate.
|
|
1347
|
+
CREATE INDEX message_content_hash IF NOT EXISTS
|
|
1348
|
+
FOR (m:Message) ON (m.contentHash);
|
|
1349
|
+
|
|
1317
1350
|
CREATE INDEX social_post_authorship IF NOT EXISTS
|
|
1318
1351
|
FOR (s:SocialPost) ON (s.authorshipMode);
|
|
1319
1352
|
|
|
1353
|
+
CREATE INDEX social_post_format IF NOT EXISTS
|
|
1354
|
+
FOR (s:SocialPost) ON (s.format);
|
|
1355
|
+
|
|
1320
1356
|
// :Section authorshipMode — the corpus walk includes conversation-archive
|
|
1321
1357
|
// :Section chunks (parent :ConversationArchive) alongside ordinary
|
|
1322
1358
|
// document sections under :KnowledgeDocument. Index on the :Section label
|
|
@@ -1325,6 +1361,9 @@ FOR (s:SocialPost) ON (s.authorshipMode);
|
|
|
1325
1361
|
CREATE INDEX section_authorship IF NOT EXISTS
|
|
1326
1362
|
FOR (s:Section) ON (s.authorshipMode);
|
|
1327
1363
|
|
|
1364
|
+
CREATE INDEX section_format IF NOT EXISTS
|
|
1365
|
+
FOR (s:Section) ON (s.format);
|
|
1366
|
+
|
|
1328
1367
|
CREATE INDEX social_post_account IF NOT EXISTS
|
|
1329
1368
|
FOR (s:SocialPost) ON (s.accountId);
|
|
1330
1369
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What It Does
|
|
4
4
|
|
|
5
|
-
{{productName}} reads emails, posts, and
|
|
5
|
+
{{productName}} reads emails, posts, documents, and your own chat messages, and uses them to make sure agent-drafted copy reads like you wrote it — not like generic AI prose.
|
|
6
6
|
|
|
7
7
|
Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model two things alongside every drafting task:
|
|
8
8
|
|
|
@@ -11,32 +11,34 @@ Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model t
|
|
|
11
11
|
|
|
12
12
|
The model conditions on both and produces copy that reads as yours.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Voice mirror maintains a separate profile for each type of content you write: plain text, email, social posts, articles, notes, and marketing copy. The right profile is applied automatically based on what you're drafting.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## How Your Voice Is Captured
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### Automatically — from chat
|
|
19
|
+
|
|
20
|
+
Every admin chat session feeds your writing into the `text` profile automatically. When the session ends, {{productName}} reads the transcript, filters out slash commands, system messages, and large paste blocks, and adds each genuine turn as a corpus entry. This happens in the background with no action needed from you.
|
|
21
|
+
|
|
22
|
+
### Via backfill — from historical content
|
|
23
|
+
|
|
24
|
+
Use the backfill flow to teach {{productName}} your writing from emails, documents, and social posts you've already written.
|
|
19
25
|
|
|
20
26
|
In the admin chat, ask: **"Start the voice-mirror backfill."**
|
|
21
27
|
|
|
22
28
|
{{productName}} asks which stream to backfill first — discrete documents (emails, posts, PDFs) or chat archives (WhatsApp, Telegram, etc.). **Pick chat archives first if you have any imported.** Chat is where your unguarded voice lives; email is the same voice in dress clothes.
|
|
23
29
|
|
|
24
30
|
- **Chat archives** — tag a whole conversation in one click. {{productName}} doesn't ask about individual messages because chunks within a conversation almost always share the same voice. Options per conversation: yours, mixed (multiple authors on your side — rare), or not yours (e.g. a Slack channel where you only forwarded other people's messages).
|
|
25
|
-
- **Documents and posts** — paginated 10 at a time. Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, {{productName}} polished).
|
|
31
|
+
- **Documents and posts** — paginated 10 at a time. {{productName}} shows each item with its detected format (email, article, note, social-post). Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, {{productName}} polished). Override the detected format if one is wrong.
|
|
26
32
|
|
|
27
33
|
Skip a batch if none qualify, or stop at any time — the next session resumes where you left off.
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Once you have at least ~20 `human-only` items tagged, ask: **"Build my voice profile."**
|
|
32
|
-
|
|
33
|
-
{{productName}} reads the corpus, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid.
|
|
35
|
+
## Distilling Your Profile
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
Once you have corpus entries tagged (or after automatic PTY ingestion fills the `text` profile), ask: **"Build my voice profile."**
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
{{productName}} reads the corpus for each format, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid — separately for email, articles, social posts, and so on.
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
The profile re-runs automatically when your corpus grows by ≥20% for any format or every 30 days, whichever comes first.
|
|
40
42
|
|
|
41
43
|
## Feedback Loop
|
|
42
44
|
|
|
@@ -50,11 +52,11 @@ Voice mirror is on by default for every drafting skill. To opt out for one, set
|
|
|
50
52
|
|
|
51
53
|
## What It Won't Do
|
|
52
54
|
|
|
53
|
-
- **Blend voices** — one profile per person, no "Joel + Neo combined".
|
|
55
|
+
- **Blend voices** — one profile set per person, no "Joel + Neo combined".
|
|
54
56
|
- **Copy public figures** — voice mirror only learns from your own writing.
|
|
55
57
|
- **Clone audio** — text only, no speech synthesis.
|
|
56
|
-
- **Guess** — content stays `unknown` until you mark it. {{productName}} never auto-classifies your
|
|
58
|
+
- **Guess** — historical content stays `unknown` until you mark it. {{productName}} never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)
|
|
57
59
|
|
|
58
60
|
## Status
|
|
59
61
|
|
|
60
|
-
Voice mirror
|
|
62
|
+
Voice mirror is live end-to-end. Six corpus formats (text, email, social-post, article, note, marketing-copy), five tools (`voice-tag-content`, `voice-distil-profile`, `voice-retrieve-conditioning`, `voice-record-feedback`, `voice-ingest-session-text`), automatic PTY-turn ingestion via SessionEnd hook, and wiring into the three live drafting surfaces (email composition, property brochures, investor data room) are all live. When no voice profile exists on the account for a given format, every drafting skill degrades gracefully — the output matches what it was before voice mirror was installed.
|
|
@@ -58,6 +58,7 @@ mkdir -p "$ACCOUNT_DIR/agents/admin" "$ACCOUNT_DIR/agents/public" "$ACCOUNT_DIR/
|
|
|
58
58
|
# to locate scripts relative to the platform installation, not the account dir.
|
|
59
59
|
ACCOUNT_SETTINGS="$ACCOUNT_DIR/.claude/settings.json"
|
|
60
60
|
HOOKS_PATH="\$PLATFORM_ROOT/plugins/admin/hooks"
|
|
61
|
+
WRITER_CRAFT_HOOKS_PATH="\$PLATFORM_ROOT/../premium-plugins/writer-craft/hooks"
|
|
61
62
|
cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
|
|
62
63
|
{
|
|
63
64
|
"hooks": {
|
|
@@ -146,6 +147,13 @@ cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
|
|
|
146
147
|
{ "type": "command", "command": "bash $HOOKS_PATH/session-end-retrospective.sh" }
|
|
147
148
|
]
|
|
148
149
|
}
|
|
150
|
+
],
|
|
151
|
+
"SessionEnd": [
|
|
152
|
+
{
|
|
153
|
+
"hooks": [
|
|
154
|
+
{ "type": "command", "command": "bash $WRITER_CRAFT_HOOKS_PATH/voice-session-end-text.sh" }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
149
157
|
]
|
|
150
158
|
}
|
|
151
159
|
}
|
|
@@ -326,26 +326,45 @@ def main() -> int:
|
|
|
326
326
|
)
|
|
327
327
|
pairs_checked += 1
|
|
328
328
|
|
|
329
|
-
# Built-in-tool rule + WebFetch rule: sweep all
|
|
330
|
-
#
|
|
331
|
-
#
|
|
329
|
+
# Built-in-tool rule + WebFetch rule: sweep all agent template .md files.
|
|
330
|
+
# Covers platform specialists (SPECIALISTS_DIR) and premium-plugin agent
|
|
331
|
+
# templates (premium-plugins/*/agents/). These rules are orthogonal to the
|
|
332
|
+
# skill-pairing loop above — they check each agent's own prompt body for
|
|
333
|
+
# directives that require built-in CC tools.
|
|
332
334
|
known_skills = collect_skill_names()
|
|
333
335
|
builtin_checked = 0
|
|
336
|
+
|
|
337
|
+
agent_template_dirs: list[str] = []
|
|
334
338
|
if os.path.isdir(SPECIALISTS_DIR):
|
|
335
|
-
|
|
339
|
+
agent_template_dirs.append(SPECIALISTS_DIR)
|
|
340
|
+
pp_root = os.path.join(REPO_ROOT, "premium-plugins")
|
|
341
|
+
if os.path.isdir(pp_root):
|
|
342
|
+
for plugin in sorted(os.listdir(pp_root)):
|
|
343
|
+
adir = os.path.join(pp_root, plugin, "agents")
|
|
344
|
+
if os.path.isdir(adir):
|
|
345
|
+
agent_template_dirs.append(adir)
|
|
346
|
+
|
|
347
|
+
for adir in agent_template_dirs:
|
|
348
|
+
for fname in sorted(os.listdir(adir)):
|
|
336
349
|
if not fname.endswith(".md"):
|
|
337
350
|
continue
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if tools is None:
|
|
351
|
+
spath = os.path.join(adir, fname)
|
|
352
|
+
fm = parse_frontmatter(spath)
|
|
353
|
+
if fm is None:
|
|
342
354
|
continue
|
|
355
|
+
specialist = fm.get("name") or fname[:-3]
|
|
356
|
+
raw = fm.get("tools", "")
|
|
357
|
+
if isinstance(raw, list):
|
|
358
|
+
tools = {t for t in raw if t}
|
|
359
|
+
else:
|
|
360
|
+
tools = {t.strip() for t in str(raw).split(",") if t.strip()}
|
|
343
361
|
body = specialist_body(spath)
|
|
344
362
|
for err in check_builtin_tool_rule(specialist, body, tools, known_skills):
|
|
345
363
|
errors.append(err)
|
|
346
364
|
for err in check_webfetch_rule(specialist, body, tools):
|
|
347
365
|
errors.append(err)
|
|
348
366
|
builtin_checked += 1
|
|
367
|
+
|
|
349
368
|
summary.append(
|
|
350
369
|
f"[verify] builtin-tool-check specialists_checked={builtin_checked}"
|
|
351
370
|
)
|
|
@@ -16,6 +16,9 @@ tools:
|
|
|
16
16
|
- name: voice-record-feedback
|
|
17
17
|
publicAllowlist: false
|
|
18
18
|
adminAllowlist: true
|
|
19
|
+
- name: voice-ingest-session-text
|
|
20
|
+
publicAllowlist: false
|
|
21
|
+
adminAllowlist: true
|
|
19
22
|
always: false
|
|
20
23
|
embed: false
|
|
21
24
|
mcp:
|
|
@@ -87,7 +90,7 @@ The user is:
|
|
|
87
90
|
|
|
88
91
|
| Skill | Purpose |
|
|
89
92
|
|-------|---------|
|
|
90
|
-
| `voice-mirror` | Capture the operator's voice from their own writing (`human-only` corpus) and condition agent drafts on it.
|
|
93
|
+
| `voice-mirror` | Capture the operator's voice from their own writing (`human-only` corpus) and condition agent drafts on it. Five tools: tag historical content (with auto-derived format), distil a per-format style card, retrieve conditioning for a drafting brief (requires `brief.format`), record operator edits as feedback, and auto-ingest PTY turns at SessionEnd. Other drafting skills (email composition, Postiz, property-brochure, prospectus) call it transparently — opt-out via `voiceMirror: false` in the calling skill's frontmatter. |
|
|
91
94
|
|
|
92
95
|
## Specialist Agent
|
|
93
96
|
|
|
@@ -99,14 +102,15 @@ The manuscript-reviewer agent operates in its own context window with file tools
|
|
|
99
102
|
|
|
100
103
|
## Tools
|
|
101
104
|
|
|
102
|
-
Voice-mirror introduces
|
|
105
|
+
Voice-mirror introduces five deterministic plugin tools. They route writes through `memory-write` so the graph-write gate stamps provenance on every node.
|
|
103
106
|
|
|
104
107
|
| Tool | Purpose |
|
|
105
108
|
|------|---------|
|
|
106
|
-
| `voice-tag-content` | Stamp `authorshipMode ∈ {human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown}` on one or many `:KnowledgeDocument | :Message | :SocialPost` nodes (email threads live as `:KnowledgeDocument {source:'email'}` since Task 321). Bulk-mode for backfill batches. |
|
|
107
|
-
| `voice-distil-profile` | Walk the operator's `human-only` corpus and persist a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries}` linked to the `:AdminUser`. Cadence-guarded: re-runs on ≥20% corpus growth or ≥30 days, otherwise no-op. |
|
|
108
|
-
| `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. K=5 short-form, K=15 long-form. Token-budget bounded. Graceful degradation: empty profile → `{styleCard: null, exemplars: []}`. |
|
|
109
|
-
| `voice-record-feedback` | Capture an operator edit on an agent draft as a `:VoiceEdit` with a Haiku-summarised `intent`, linked back to the operator's `:VoiceProfile` via `:FEEDBACK_FOR`. |
|
|
109
|
+
| `voice-tag-content` | Stamp `authorshipMode ∈ {human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown}` and derived `format ∈ {text, email, social-post, article, note, marketing-copy}` on one or many `:KnowledgeDocument | :Message | :SocialPost` nodes (email threads live as `:KnowledgeDocument {source:'email'}` since Task 321). Bulk-mode for backfill batches. Pass `format` explicitly to override auto-derivation. |
|
|
110
|
+
| `voice-distil-profile` | Walk the operator's `human-only` corpus for the given `format` and persist a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format}` linked to the `:AdminUser`. Omit `format` to enumerate all formats in the corpus and distil each. Cadence-guarded per format: re-runs on ≥20% corpus growth or ≥30 days, otherwise no-op. |
|
|
111
|
+
| `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (one of the six corpus formats). K=5 short-form (`brief.length:'short'`), K=15 long-form (`brief.length:'long'`). Token-budget bounded. Graceful degradation: empty profile → `{styleCard: null, exemplars: []}`. |
|
|
112
|
+
| `voice-record-feedback` | Capture an operator edit on an agent draft as a `:VoiceEdit {format}` with a Haiku-summarised `intent`, linked back to the operator's per-format `:VoiceProfile` via `:FEEDBACK_FOR`. Requires `format`. |
|
|
113
|
+
| `voice-ingest-session-text` | Ingest operator PTY turns from a JSONL transcript as `:Message {format:'text', authorshipMode:'human-only'}` corpus nodes. Deduplicates via SHA256 `contentHash`. Called by `voice-session-end-text.sh` at SessionEnd; not intended for direct agent invocation. |
|
|
110
114
|
|
|
111
115
|
The non-voice skills continue to operate via existing platform tools (`memory-search` for knowledge retrieval). Structured choices are still presented as numbered lists in plain chat — the platform has no inline-UI component primitive on the admin / native-CC surface.
|
|
112
116
|
|
|
@@ -127,4 +131,4 @@ Domain knowledge files loaded on demand by each skill. See individual skill file
|
|
|
127
131
|
| `review-manuscript` | `review-manuscript-checklist.md` |
|
|
128
132
|
| `review-scene` | `scene-analysis-framework.md` |
|
|
129
133
|
| `review-prose` | `prose-review-checklist.md` |
|
|
130
|
-
| `voice-mirror` | No external references; the skill body defines the
|
|
134
|
+
| `voice-mirror` | No external references; the skill body defines the five tool contracts. Developer notes in `maxy-code/.docs/voice-mirror.md`. |
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# voice-session-end-text.sh hook test suite.
|
|
3
|
+
#
|
|
4
|
+
# Pattern: a local Python HTTP server stands in for /api/admin/log-ingest
|
|
5
|
+
# and records every POST so the assertions can inspect every log line the
|
|
6
|
+
# hook emitted. The CLI is stubbed with a mock node script so tests run
|
|
7
|
+
# without a live Neo4j connection.
|
|
8
|
+
#
|
|
9
|
+
# Contract under test:
|
|
10
|
+
# - Hook skips with reason=role-not-admin when MAXY_SESSION_ROLE != admin.
|
|
11
|
+
# - Hook skips with reason=is-specialist when MAXY_SPECIALIST is set.
|
|
12
|
+
# - Hook skips with reason=empty-stdin when no stdin is piped.
|
|
13
|
+
# - Hook skips with reason=missing-transcript when the JSONL path in the
|
|
14
|
+
# stdin envelope does not exist.
|
|
15
|
+
# - Hook skips with reason=cli-not-found when the compiled CLI is absent.
|
|
16
|
+
# - Happy path: hook emits trigger + result=ok when all guards pass and the
|
|
17
|
+
# stub CLI exits 0.
|
|
18
|
+
# - Dedupe path: stub CLI returns turns=0 skipped=3 → result=ok with those
|
|
19
|
+
# counts (verifying the hook forwards the CLI stdout to the log-ingest).
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/voice-session-end-text.sh"
|
|
24
|
+
if [[ ! -f "$HOOK" ]]; then
|
|
25
|
+
echo "FAIL: hook not found at $HOOK" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
PASS=0
|
|
30
|
+
FAIL=0
|
|
31
|
+
TMPFILES=()
|
|
32
|
+
LISTENER_PIDS=()
|
|
33
|
+
|
|
34
|
+
cleanup() {
|
|
35
|
+
for pid in "${LISTENER_PIDS[@]:-}"; do
|
|
36
|
+
[[ -n "$pid" ]] && { kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; }
|
|
37
|
+
done
|
|
38
|
+
for f in "${TMPFILES[@]:-}"; do
|
|
39
|
+
[[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
|
|
40
|
+
done
|
|
41
|
+
}
|
|
42
|
+
trap cleanup EXIT
|
|
43
|
+
|
|
44
|
+
# ---- helpers ---------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
make_tmp_file() {
|
|
47
|
+
local tmp
|
|
48
|
+
tmp=$(mktemp)
|
|
49
|
+
TMPFILES+=("$tmp")
|
|
50
|
+
echo "$tmp"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Start a listener on a random port; capture every POST body.
|
|
54
|
+
# Sets LISTENER_PORT and LISTENER_LOG globals.
|
|
55
|
+
start_listener() {
|
|
56
|
+
LISTENER_LOG=$(make_tmp_file)
|
|
57
|
+
LISTENER_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
|
|
58
|
+
python3 - "$LISTENER_PORT" "$LISTENER_LOG" <<'PY' &
|
|
59
|
+
import sys, http.server, json
|
|
60
|
+
|
|
61
|
+
port = int(sys.argv[1])
|
|
62
|
+
log = sys.argv[2]
|
|
63
|
+
|
|
64
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
65
|
+
def do_POST(self):
|
|
66
|
+
n = int(self.headers.get("Content-Length", 0))
|
|
67
|
+
body = self.rfile.read(n).decode()
|
|
68
|
+
with open(log, "a") as f:
|
|
69
|
+
f.write(body + "\n")
|
|
70
|
+
self.send_response(200)
|
|
71
|
+
self.end_headers()
|
|
72
|
+
def log_message(self, *a): pass
|
|
73
|
+
|
|
74
|
+
http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
|
|
75
|
+
PY
|
|
76
|
+
local pid=$!
|
|
77
|
+
LISTENER_PIDS+=("$pid")
|
|
78
|
+
sleep 0.3 # let the server start
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Run the hook with given env overrides and optional stdin.
|
|
82
|
+
# Returns: HOOK_EXIT, LISTENER_LINES (array of log-ingest line bodies).
|
|
83
|
+
run_hook() {
|
|
84
|
+
local env_args=("$@")
|
|
85
|
+
local stdin_data="${HOOK_STDIN:-}"
|
|
86
|
+
local hook_exit=0
|
|
87
|
+
if [[ -n "$stdin_data" ]]; then
|
|
88
|
+
printf '%s' "$stdin_data" | env \
|
|
89
|
+
MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
|
|
90
|
+
MAXY_SESSION_ROLE="${MAXY_SESSION_ROLE:-admin}" \
|
|
91
|
+
MAXY_SPECIALIST="${MAXY_SPECIALIST:-}" \
|
|
92
|
+
PLATFORM_ROOT="${FAKE_PLATFORM_ROOT:-}" \
|
|
93
|
+
ACCOUNT_ID="${ACCOUNT_ID:-acct-test}" \
|
|
94
|
+
USER_ID="${USER_ID:-user-test}" \
|
|
95
|
+
SESSION_ID="" \
|
|
96
|
+
NEO4J_URI="bolt://localhost:7688" \
|
|
97
|
+
NEO4J_USER="neo4j" \
|
|
98
|
+
NEO4J_PASSWORD="test" \
|
|
99
|
+
${env_args[@]+"${env_args[@]}"} \
|
|
100
|
+
bash "$HOOK" >/dev/null 2>/dev/null || hook_exit=$?
|
|
101
|
+
else
|
|
102
|
+
env \
|
|
103
|
+
MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
|
|
104
|
+
MAXY_SESSION_ROLE="${MAXY_SESSION_ROLE:-admin}" \
|
|
105
|
+
MAXY_SPECIALIST="${MAXY_SPECIALIST:-}" \
|
|
106
|
+
PLATFORM_ROOT="${FAKE_PLATFORM_ROOT:-}" \
|
|
107
|
+
ACCOUNT_ID="${ACCOUNT_ID:-acct-test}" \
|
|
108
|
+
USER_ID="${USER_ID:-user-test}" \
|
|
109
|
+
SESSION_ID="" \
|
|
110
|
+
NEO4J_URI="bolt://localhost:7688" \
|
|
111
|
+
NEO4J_USER="neo4j" \
|
|
112
|
+
NEO4J_PASSWORD="test" \
|
|
113
|
+
${env_args[@]+"${env_args[@]}"} \
|
|
114
|
+
bash "$HOOK" </dev/null >/dev/null 2>/dev/null || hook_exit=$?
|
|
115
|
+
fi
|
|
116
|
+
HOOK_EXIT=$hook_exit
|
|
117
|
+
# Read all log-ingest POSTs.
|
|
118
|
+
LISTENER_LINES=()
|
|
119
|
+
while IFS= read -r raw; do
|
|
120
|
+
[[ -z "$raw" ]] && continue
|
|
121
|
+
LISTENER_LINES+=("$raw")
|
|
122
|
+
done < "$LISTENER_LOG"
|
|
123
|
+
# Reset log for next run.
|
|
124
|
+
> "$LISTENER_LOG"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
assert_contains_line() {
|
|
128
|
+
local test_name="$1"
|
|
129
|
+
local fragment="$2"
|
|
130
|
+
for line in "${LISTENER_LINES[@]:-}"; do
|
|
131
|
+
if [[ "$line" == *"$fragment"* ]]; then
|
|
132
|
+
echo " PASS: $test_name"
|
|
133
|
+
PASS=$((PASS + 1))
|
|
134
|
+
return 0
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
echo " FAIL: $test_name (expected fragment: '$fragment' not found in log lines)"
|
|
138
|
+
printf ' lines:\n'
|
|
139
|
+
for line in "${LISTENER_LINES[@]:-}"; do printf ' %s\n' "$line"; done
|
|
140
|
+
FAIL=$((FAIL + 1))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
assert_exit() {
|
|
144
|
+
local test_name="$1"
|
|
145
|
+
local expected="$2"
|
|
146
|
+
if [[ "$HOOK_EXIT" -eq "$expected" ]]; then
|
|
147
|
+
echo " PASS: $test_name (exit $expected)"
|
|
148
|
+
PASS=$((PASS + 1))
|
|
149
|
+
else
|
|
150
|
+
echo " FAIL: $test_name (expected exit $expected, got $HOOK_EXIT)"
|
|
151
|
+
FAIL=$((FAIL + 1))
|
|
152
|
+
fi
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# ---- setup -----------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
start_listener
|
|
158
|
+
|
|
159
|
+
# Create a fake platform root structure so the lib source works.
|
|
160
|
+
FAKE_PLATFORM_ROOT=$(mktemp -d)
|
|
161
|
+
TMPFILES+=("$FAKE_PLATFORM_ROOT")
|
|
162
|
+
mkdir -p "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib"
|
|
163
|
+
# Copy the real lib so guards work correctly.
|
|
164
|
+
REAL_LIB="$(cd "$(dirname "$0")/../../../../platform/plugins/admin/hooks/lib" && pwd)/admin-graph-pass-common.sh"
|
|
165
|
+
if [[ ! -f "$REAL_LIB" ]]; then
|
|
166
|
+
echo "FAIL: admin-graph-pass-common.sh not found at $REAL_LIB" >&2
|
|
167
|
+
exit 1
|
|
168
|
+
fi
|
|
169
|
+
cp "$REAL_LIB" "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib/admin-graph-pass-common.sh"
|
|
170
|
+
|
|
171
|
+
# Create a minimal fake JSONL transcript.
|
|
172
|
+
FAKE_TRANSCRIPT=$(make_tmp_file)
|
|
173
|
+
printf '{"type":"user","message":{"role":"user","content":"Hello world"}}\n' > "$FAKE_TRANSCRIPT"
|
|
174
|
+
|
|
175
|
+
# Create a stub CLI that exits 0 with a canned result.
|
|
176
|
+
FAKE_CLI_DIR=$(mktemp -d)
|
|
177
|
+
TMPFILES+=("$FAKE_CLI_DIR")
|
|
178
|
+
STUB_CLI="$FAKE_CLI_DIR/ingest-session-text.js"
|
|
179
|
+
cat > "$STUB_CLI" <<'STUB'
|
|
180
|
+
process.stdout.write("turns=3 skipped=0 sessionId=test-session\n");
|
|
181
|
+
process.exit(0);
|
|
182
|
+
STUB
|
|
183
|
+
|
|
184
|
+
# Stub CLI for dedupe scenario (turns=0).
|
|
185
|
+
STUB_CLI_DEDUP="$FAKE_CLI_DIR/ingest-dedup.js"
|
|
186
|
+
cat > "$STUB_CLI_DEDUP" <<'STUB'
|
|
187
|
+
process.stdout.write("turns=0 skipped=3 sessionId=test-session\n");
|
|
188
|
+
process.exit(0);
|
|
189
|
+
STUB
|
|
190
|
+
|
|
191
|
+
# Stub CLI that exits 1 (error).
|
|
192
|
+
STUB_CLI_ERR="$FAKE_CLI_DIR/ingest-error.js"
|
|
193
|
+
cat > "$STUB_CLI_ERR" <<'STUB'
|
|
194
|
+
process.stderr.write("simulated error\n");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
STUB
|
|
197
|
+
|
|
198
|
+
# Valid stdin envelope for the SessionEnd hook.
|
|
199
|
+
valid_envelope() {
|
|
200
|
+
local transcript="${1:-$FAKE_TRANSCRIPT}"
|
|
201
|
+
printf '{"session_id":"test-session","transcript_path":"%s","hook_event_name":"SessionEnd"}' "$transcript"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Override CLI path by symlinking ingest-session-text.js inside a fake platform layout.
|
|
205
|
+
make_platform_with_cli() {
|
|
206
|
+
local stub="$1"
|
|
207
|
+
local proot
|
|
208
|
+
proot=$(mktemp -d)
|
|
209
|
+
TMPFILES+=("$proot")
|
|
210
|
+
cp "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib/admin-graph-pass-common.sh" \
|
|
211
|
+
"$proot/plugins/admin/hooks/lib/" 2>/dev/null || \
|
|
212
|
+
{ mkdir -p "$proot/plugins/admin/hooks/lib"; cp "$REAL_LIB" "$proot/plugins/admin/hooks/lib/admin-graph-pass-common.sh"; }
|
|
213
|
+
mkdir -p "$proot/../premium-plugins/writer-craft/mcp/dist/cli" 2>/dev/null || \
|
|
214
|
+
mkdir -p "$(dirname "$proot")/../premium-plugins/writer-craft/mcp/dist/cli" 2>/dev/null || true
|
|
215
|
+
# Simpler: build the expected path from $PLATFORM_ROOT/../premium-plugins/...
|
|
216
|
+
# The hook computes: ${PLATFORM_ROOT}/../premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js
|
|
217
|
+
# So parent of $proot + /premium-plugins/writer-craft/mcp/dist/cli/
|
|
218
|
+
local parent
|
|
219
|
+
parent=$(dirname "$proot")
|
|
220
|
+
mkdir -p "$parent/premium-plugins/writer-craft/mcp/dist/cli"
|
|
221
|
+
cp "$stub" "$parent/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js"
|
|
222
|
+
echo "$proot"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# ---- tests -----------------------------------------------------------------
|
|
226
|
+
echo "[voice-session-end-text.test] starting"
|
|
227
|
+
|
|
228
|
+
# T1: non-admin role → skip
|
|
229
|
+
MAXY_SESSION_ROLE="public" HOOK_STDIN=$(valid_envelope) run_hook
|
|
230
|
+
assert_exit "T1: non-admin exits 0" 0
|
|
231
|
+
assert_contains_line "T1: non-admin emits trigger-skipped" "trigger-skipped"
|
|
232
|
+
|
|
233
|
+
# T2: specialist → skip
|
|
234
|
+
unset MAXY_SESSION_ROLE 2>/dev/null || true
|
|
235
|
+
MAXY_SESSION_ROLE="admin" MAXY_SPECIALIST="database-operator" HOOK_STDIN=$(valid_envelope) run_hook
|
|
236
|
+
assert_exit "T2: specialist exits 0" 0
|
|
237
|
+
assert_contains_line "T2: specialist emits trigger-skipped" "trigger-skipped"
|
|
238
|
+
|
|
239
|
+
# T3: empty stdin → skip
|
|
240
|
+
MAXY_SPECIALIST="" MAXY_SESSION_ROLE="admin"
|
|
241
|
+
HOOK_STDIN="" run_hook # no stdin piped
|
|
242
|
+
assert_exit "T3: empty-stdin exits 0" 0
|
|
243
|
+
assert_contains_line "T3: empty-stdin emits trigger-skipped" "trigger-skipped"
|
|
244
|
+
|
|
245
|
+
# T4: missing transcript → skip
|
|
246
|
+
FAKE_MISSING=$(make_tmp_file)
|
|
247
|
+
rm -f "$FAKE_MISSING"
|
|
248
|
+
HOOK_STDIN=$(valid_envelope "$FAKE_MISSING") run_hook
|
|
249
|
+
assert_exit "T4: missing-transcript exits 0" 0
|
|
250
|
+
assert_contains_line "T4: missing-transcript emits trigger-skipped" "trigger-skipped"
|
|
251
|
+
|
|
252
|
+
# T5: CLI not found → skip
|
|
253
|
+
FAKE_PLATFORM_ROOT="$FAKE_PLATFORM_ROOT" HOOK_STDIN=$(valid_envelope) run_hook
|
|
254
|
+
assert_exit "T5: cli-not-found exits 0" 0
|
|
255
|
+
assert_contains_line "T5: cli-not-found emits trigger-skipped" "cli-not-found"
|
|
256
|
+
|
|
257
|
+
# T6: happy path — stub CLI exits 0
|
|
258
|
+
HAPPY_PROOT=$(make_platform_with_cli "$STUB_CLI")
|
|
259
|
+
FAKE_PLATFORM_ROOT="$HAPPY_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
|
|
260
|
+
assert_exit "T6: happy path exits 0" 0
|
|
261
|
+
assert_contains_line "T6: happy path emits trigger" "trigger"
|
|
262
|
+
assert_contains_line "T6: happy path emits result=ok" "result=ok"
|
|
263
|
+
|
|
264
|
+
# T7: dedupe — stub returns turns=0 skipped=3
|
|
265
|
+
DEDUP_PROOT=$(make_platform_with_cli "$STUB_CLI_DEDUP")
|
|
266
|
+
FAKE_PLATFORM_ROOT="$DEDUP_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
|
|
267
|
+
assert_exit "T7: dedupe exits 0" 0
|
|
268
|
+
assert_contains_line "T7: dedupe emits result=ok" "result=ok"
|
|
269
|
+
|
|
270
|
+
# T8: CLI error — stub exits 1
|
|
271
|
+
ERR_PROOT=$(make_platform_with_cli "$STUB_CLI_ERR")
|
|
272
|
+
FAKE_PLATFORM_ROOT="$ERR_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
|
|
273
|
+
assert_exit "T8: cli-error exits 0" 0
|
|
274
|
+
assert_contains_line "T8: cli-error emits result=err" "result=err"
|
|
275
|
+
|
|
276
|
+
# ---- summary ---------------------------------------------------------------
|
|
277
|
+
echo ""
|
|
278
|
+
if [[ "$FAIL" -eq 0 ]]; then
|
|
279
|
+
echo "[voice-session-end-text.test] all $PASS tests passed"
|
|
280
|
+
exit 0
|
|
281
|
+
else
|
|
282
|
+
echo "[voice-session-end-text.test] FAILED — $FAIL/$((PASS + FAIL)) tests failed"
|
|
283
|
+
exit 1
|
|
284
|
+
fi
|