dictum-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +280 -0
- package/bin/dictum.js +7 -0
- package/package.json +41 -0
- package/src/cli.ts +399 -0
- package/src/config.ts +250 -0
- package/src/core/pipeline.ts +120 -0
- package/src/core/types.ts +102 -0
- package/src/doctor.ts +276 -0
- package/src/mcp/prompts.ts +186 -0
- package/src/mcp/server.ts +363 -0
- package/src/modules.d.ts +7 -0
- package/src/polisher/anthropic.ts +90 -0
- package/src/polisher/claude_cli.ts +128 -0
- package/src/polisher/factory.ts +89 -0
- package/src/polisher/layered.ts +43 -0
- package/src/polisher/openai_compat.ts +76 -0
- package/src/polisher/rules.ts +332 -0
- package/src/polisher/templates/agent-prompt.md +18 -0
- package/src/polisher/templates/commit.md +13 -0
- package/src/polisher/templates/decompose.md +20 -0
- package/src/polisher/templates/note.md +11 -0
- package/src/polisher/templates/spec.md +29 -0
- package/src/polisher/templates.ts +151 -0
- package/src/recorder/file.ts +35 -0
- package/src/recorder/sox.ts +165 -0
- package/src/recorder/vad.ts +100 -0
- package/src/recorder/wav.ts +150 -0
- package/src/sink/clipboard.ts +153 -0
- package/src/sink/factory.ts +31 -0
- package/src/sink/stdout.ts +17 -0
- package/src/stt/factory.ts +89 -0
- package/src/stt/local_http.ts +75 -0
- package/src/stt/openai_compat.ts +83 -0
- package/src/ui/choose.ts +95 -0
- package/src/ui/keys.ts +110 -0
- package/src/ui/status.ts +91 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/server.ts — expose Dictum's engine over MCP.
|
|
3
|
+
*
|
|
4
|
+
* Any MCP client (Claude Code, Cursor, Windsurf, Claude Desktop, Codex) can
|
|
5
|
+
* use Dictum without installing the CLI. Text-only — voice has no place in
|
|
6
|
+
* the MCP protocol (no microphone access).
|
|
7
|
+
*
|
|
8
|
+
* Prompts (the flagship, host-brain: the HOST model rewrites, using the
|
|
9
|
+
* session's project context — see prompts.ts):
|
|
10
|
+
* - polish(draft?) — draft → clear, structured prompt
|
|
11
|
+
* - spec(draft?) — draft → task spec with acceptance criteria
|
|
12
|
+
* - decompose(draft?) — draft → ordered, dependency-tracked subtasks
|
|
13
|
+
*
|
|
14
|
+
* Tools:
|
|
15
|
+
* - polish_brief(text, mode?) — the same host-brain brief as a tool result,
|
|
16
|
+
* for hosts without MCP Prompts (Codex CLI)
|
|
17
|
+
* - polish_prompt(text, mode?) — server-side polish with Dictum's own model
|
|
18
|
+
* - analyze_prompt(text) — deterministic 0–100 score + weak spots (offline)
|
|
19
|
+
* - build_spec(text) — server-side spec via the "spec" template
|
|
20
|
+
*
|
|
21
|
+
* This is an assembly-layer entry point (like cli.ts): it reuses createPolisher
|
|
22
|
+
* + resolveTemplate and never touches recorder/stt/sink. Started via `dictum mcp`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
26
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
27
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
|
28
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|
29
|
+
import { ErrorCode, GetPromptRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"
|
|
30
|
+
import { z } from "zod"
|
|
31
|
+
import pkg from "../../package.json" with { type: "json" }
|
|
32
|
+
import { type Config, loadConfig, templatesDir } from "../config.ts"
|
|
33
|
+
import { createPolisher } from "../polisher/factory.ts"
|
|
34
|
+
import { analyzePrompt } from "../polisher/rules.ts"
|
|
35
|
+
import { availableTemplateNames, resolveTemplate } from "../polisher/templates.ts"
|
|
36
|
+
import {
|
|
37
|
+
HOST_PROMPT_KINDS,
|
|
38
|
+
HOST_PROMPT_META,
|
|
39
|
+
PROVIDER_FAMILIES,
|
|
40
|
+
type ProviderFamily,
|
|
41
|
+
buildHostBrief,
|
|
42
|
+
buildHostPrompt,
|
|
43
|
+
detectProviderFamily,
|
|
44
|
+
isHostPromptKind,
|
|
45
|
+
isProviderFamily,
|
|
46
|
+
} from "./prompts.ts"
|
|
47
|
+
|
|
48
|
+
/** Refines `text` with the given template `mode`; resolves to the polished text. */
|
|
49
|
+
export type PolishFn = (text: string, mode?: string) => Promise<string>
|
|
50
|
+
|
|
51
|
+
export type McpDeps = {
|
|
52
|
+
/** Polishing backend (real one from config, or a stub in tests). */
|
|
53
|
+
polish: PolishFn
|
|
54
|
+
/** Template names advertised in the tool description. */
|
|
55
|
+
modes: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a text CallToolResult. */
|
|
59
|
+
function textResult(text: string, isError = false): CallToolResult {
|
|
60
|
+
return { content: [{ type: "text", text }], isError }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── In-flight request tracking ──────────────────────────────────────────────
|
|
64
|
+
// stdin EOF must not kill responses still being computed (an async tool like
|
|
65
|
+
// polish_prompt awaits a subprocess). Handlers are wrapped in track(); the
|
|
66
|
+
// EOF path awaits waitForIdle() before letting the process exit.
|
|
67
|
+
|
|
68
|
+
let inflight = 0
|
|
69
|
+
|
|
70
|
+
/** Wrap a handler so the in-flight counter reflects it. */
|
|
71
|
+
export function track<A extends unknown[], R>(
|
|
72
|
+
fn: (...args: A) => R | Promise<R>,
|
|
73
|
+
): (...args: A) => Promise<R> {
|
|
74
|
+
return async (...args: A): Promise<R> => {
|
|
75
|
+
inflight++
|
|
76
|
+
try {
|
|
77
|
+
return await fn(...args)
|
|
78
|
+
} finally {
|
|
79
|
+
inflight--
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The SDK hands a parsed request to its handler on a later microtask; on
|
|
85
|
+
// Bun 1.2 stdin 'end' lands inside that gap, when no handler has started yet
|
|
86
|
+
// and inflight is still 0. Idle is therefore also judged at the wire level:
|
|
87
|
+
// every request the transport parsed must get its response before exit.
|
|
88
|
+
const pendingWire = new Set<string | number>()
|
|
89
|
+
|
|
90
|
+
/** Record a client→server wire message; requests (id + method) become pending. */
|
|
91
|
+
export function noteWireMessage(message: unknown): void {
|
|
92
|
+
const m = message as { id?: unknown; method?: unknown } | null
|
|
93
|
+
if (m && typeof m.method === "string" && (typeof m.id === "string" || typeof m.id === "number")) {
|
|
94
|
+
pendingWire.add(m.id)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Record a server→client wire message; responses (id, no method) settle requests. */
|
|
99
|
+
export function noteWireReply(message: unknown): void {
|
|
100
|
+
const m = message as { id?: unknown; method?: unknown } | null
|
|
101
|
+
if (m && m.method === undefined && (typeof m.id === "string" || typeof m.id === "number")) {
|
|
102
|
+
pendingWire.delete(m.id)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wrap a connected transport so pendingWire mirrors the actual traffic.
|
|
108
|
+
* Must run after connect() — that is when the protocol assigns onmessage.
|
|
109
|
+
*/
|
|
110
|
+
export function instrumentWireTracking(transport: Transport): void {
|
|
111
|
+
const dispatch = transport.onmessage
|
|
112
|
+
transport.onmessage = (message, extra) => {
|
|
113
|
+
noteWireMessage(message)
|
|
114
|
+
dispatch?.(message, extra)
|
|
115
|
+
}
|
|
116
|
+
const sendRaw = transport.send.bind(transport)
|
|
117
|
+
transport.send = (message, options) => {
|
|
118
|
+
noteWireReply(message)
|
|
119
|
+
return sendRaw(message, options)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve once no request is in flight or awaiting dispatch (or the drain timeout elapses). */
|
|
124
|
+
export async function waitForIdle(timeoutMs = 15000): Promise<void> {
|
|
125
|
+
const deadline = Date.now() + timeoutMs
|
|
126
|
+
while ((inflight > 0 || pendingWire.size > 0) && Date.now() < deadline) {
|
|
127
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Core polish_prompt logic, decoupled from the transport for direct testing.
|
|
133
|
+
* Validates input and maps provider failures to a human-readable error result
|
|
134
|
+
* (isError) rather than throwing across the protocol boundary.
|
|
135
|
+
*/
|
|
136
|
+
export async function handlePolishPrompt(
|
|
137
|
+
args: { text?: unknown; mode?: unknown },
|
|
138
|
+
deps: McpDeps,
|
|
139
|
+
): Promise<CallToolResult> {
|
|
140
|
+
const text = typeof args.text === "string" ? args.text.trim() : ""
|
|
141
|
+
if (!text) {
|
|
142
|
+
return textResult("Error: 'text' must be a non-empty string.", true)
|
|
143
|
+
}
|
|
144
|
+
const mode = typeof args.mode === "string" && args.mode.length > 0 ? args.mode : undefined
|
|
145
|
+
// Defense in depth on top of resolveTemplate's name validation: MCP callers
|
|
146
|
+
// may only pick from the advertised template list.
|
|
147
|
+
if (mode !== undefined && !deps.modes.includes(mode)) {
|
|
148
|
+
return textResult(`Error: unknown mode '${mode}'. Valid: ${deps.modes.join(", ")}.`, true)
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const polished = await deps.polish(text, mode)
|
|
152
|
+
return textResult(polished)
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
155
|
+
return textResult(`Polishing failed: ${reason}`, true)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* analyze_prompt logic: deterministic offline scoring — no LLM call, so it
|
|
161
|
+
* never fails on provider problems. Returns the analysis as a JSON string
|
|
162
|
+
* ({score, dimensions, issues}) for the client to reason over.
|
|
163
|
+
*/
|
|
164
|
+
export function handleAnalyzePrompt(args: { text?: unknown }): CallToolResult {
|
|
165
|
+
const text = typeof args.text === "string" ? args.text.trim() : ""
|
|
166
|
+
if (!text) {
|
|
167
|
+
return textResult("Error: 'text' must be a non-empty string.", true)
|
|
168
|
+
}
|
|
169
|
+
return textResult(JSON.stringify(analyzePrompt(text)))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** build_spec logic: polish_prompt fixed to the "spec" template. */
|
|
173
|
+
export async function handleBuildSpec(
|
|
174
|
+
args: { text?: unknown },
|
|
175
|
+
deps: McpDeps,
|
|
176
|
+
): Promise<CallToolResult> {
|
|
177
|
+
return handlePolishPrompt({ text: args.text, mode: "spec" }, deps)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* polish_brief logic: return the host-brain brief as a tool result, so hosts
|
|
182
|
+
* without MCP Prompts (Codex CLI) still get the flagship flow — the HOST model
|
|
183
|
+
* executes the brief with its own session context. Offline, no LLM call.
|
|
184
|
+
*/
|
|
185
|
+
export function handlePolishBrief(
|
|
186
|
+
args: { text?: unknown; mode?: unknown; provider?: unknown },
|
|
187
|
+
detected: ProviderFamily = "generic",
|
|
188
|
+
): CallToolResult {
|
|
189
|
+
const text = typeof args.text === "string" ? args.text.trim() : ""
|
|
190
|
+
if (!text) {
|
|
191
|
+
return textResult("Error: 'text' must be a non-empty string.", true)
|
|
192
|
+
}
|
|
193
|
+
const mode = typeof args.mode === "string" && args.mode.length > 0 ? args.mode : "polish"
|
|
194
|
+
if (!isHostPromptKind(mode)) {
|
|
195
|
+
return textResult(
|
|
196
|
+
`Error: unknown brief mode '${mode}'. Valid: ${HOST_PROMPT_KINDS.join(", ")}.`,
|
|
197
|
+
true,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const provider =
|
|
201
|
+
typeof args.provider === "string" && args.provider.length > 0 ? args.provider : detected
|
|
202
|
+
if (!isProviderFamily(provider)) {
|
|
203
|
+
return textResult(
|
|
204
|
+
`Error: unknown provider '${provider}'. Valid: ${PROVIDER_FAMILIES.join(", ")}.`,
|
|
205
|
+
true,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
return textResult(buildHostBrief(mode, text, provider))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build a PolishFn from configuration. Constructs the polisher once and resolves
|
|
213
|
+
* the template per call (so different `mode` values work). Reuses the exact same
|
|
214
|
+
* createPolisher + resolveTemplate the CLI uses — no duplicated logic.
|
|
215
|
+
*/
|
|
216
|
+
export function polishFromConfig(config: Config): PolishFn {
|
|
217
|
+
const polisher = createPolisher(config.polisher)
|
|
218
|
+
return async (text, mode) => {
|
|
219
|
+
const template = await resolveTemplate(mode ?? config.polisher.template, templatesDir())
|
|
220
|
+
return polisher.polish(text, template)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Construct the MCP server: host-brain prompts (polish / spec / decompose,
|
|
226
|
+
* the flagship surface) plus the polish_brief / polish_prompt / analyze_prompt
|
|
227
|
+
* / build_spec tools.
|
|
228
|
+
*/
|
|
229
|
+
export function createMcpServer(deps: McpDeps): McpServer {
|
|
230
|
+
const server = new McpServer({ name: "dictum", version: pkg.version })
|
|
231
|
+
const modeList = deps.modes.join(", ")
|
|
232
|
+
const modeHelp = `Polishing template (one of: ${modeList}). Defaults to the configured template.`
|
|
233
|
+
|
|
234
|
+
// Model family of the connected host (known after `initialize`), so briefs
|
|
235
|
+
// can carry that vendor's own prompting advice — the polished prompt will
|
|
236
|
+
// be executed by the very model the client runs on.
|
|
237
|
+
const clientFamily = () => detectProviderFamily(server.server.getClientVersion()?.name)
|
|
238
|
+
|
|
239
|
+
// Host-brain prompts: the host model executes the brief with its own
|
|
240
|
+
// context. Surfaced as e.g. /mcp__dictum__polish in Claude Code.
|
|
241
|
+
for (const kind of HOST_PROMPT_KINDS) {
|
|
242
|
+
server.registerPrompt(
|
|
243
|
+
kind,
|
|
244
|
+
{
|
|
245
|
+
title: HOST_PROMPT_META[kind].title,
|
|
246
|
+
description: HOST_PROMPT_META[kind].description,
|
|
247
|
+
argsSchema: {
|
|
248
|
+
draft: z
|
|
249
|
+
.string()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe("The rough draft to refine. If omitted, the host asks the user for it."),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
({ draft }) => buildHostPrompt(kind, draft ?? "", clientFamily()),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The MCP spec makes `params.arguments` optional, but the SDK's generated
|
|
259
|
+
// prompts/get handler rejects a request that omits it (-32602) — which
|
|
260
|
+
// would kill our "no draft → ask the user" flow on spec-compliant clients.
|
|
261
|
+
// Override just the get handler; registerPrompt above still owns
|
|
262
|
+
// prompts/list (names, titles, argument advertising).
|
|
263
|
+
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
264
|
+
const { name } = request.params
|
|
265
|
+
if (!isHostPromptKind(name)) {
|
|
266
|
+
throw new McpError(ErrorCode.InvalidParams, `Prompt ${name} not found`)
|
|
267
|
+
}
|
|
268
|
+
const draft = request.params.arguments?.draft
|
|
269
|
+
return buildHostPrompt(name, typeof draft === "string" ? draft : "", clientFamily())
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
server.registerTool(
|
|
273
|
+
"polish_brief",
|
|
274
|
+
{
|
|
275
|
+
title: "Get a polishing brief (host-executed)",
|
|
276
|
+
description:
|
|
277
|
+
"Return Dictum's rewriting brief for YOU to execute: canon rules plus a deterministic pre-analysis of the user's rough draft. Follow the brief yourself, using your session's project context — do not expect polished text back from this tool. Prefer this over polish_prompt when the user wants their draft refined and you have the context. Offline, no LLM call. Modes: polish (default), spec, decompose.",
|
|
278
|
+
inputSchema: {
|
|
279
|
+
text: z.string().describe("The rough draft to build the brief for."),
|
|
280
|
+
mode: z.string().optional().describe("Brief kind: polish (default), spec, or decompose."),
|
|
281
|
+
provider: z
|
|
282
|
+
.string()
|
|
283
|
+
.optional()
|
|
284
|
+
.describe(
|
|
285
|
+
"Model family for vendor prompting tips: anthropic, openai, or generic. Auto-detected from the connected client when omitted.",
|
|
286
|
+
),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
track((args) => handlePolishBrief(args, clientFamily())),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
server.registerTool(
|
|
293
|
+
"polish_prompt",
|
|
294
|
+
{
|
|
295
|
+
title: "Polish a prompt",
|
|
296
|
+
description: `Refine a rough thought or draft into a clear, well-structured prompt using Dictum's OWN model, server-side (it does not see this session's context — prefer polish_brief when you have context). Text in, polished text out (no voice). Available modes: ${modeList}.`,
|
|
297
|
+
inputSchema: {
|
|
298
|
+
text: z.string().describe("The rough thought or draft prompt to refine."),
|
|
299
|
+
mode: z.string().optional().describe(modeHelp),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
track((args) => handlePolishPrompt(args, deps)),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
server.registerTool(
|
|
306
|
+
"analyze_prompt",
|
|
307
|
+
{
|
|
308
|
+
title: "Analyze a prompt",
|
|
309
|
+
description:
|
|
310
|
+
"Score a prompt draft 0–100 across five dimensions (clarity, specificity, structure, actionability, context) and list its weak spots. Deterministic and offline — no LLM call. Returns JSON: {score, dimensions, issues}. Use it to decide whether a draft needs polishing.",
|
|
311
|
+
inputSchema: {
|
|
312
|
+
text: z.string().describe("The prompt draft to analyze."),
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
track((args) => handleAnalyzePrompt(args)),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
server.registerTool(
|
|
319
|
+
"build_spec",
|
|
320
|
+
{
|
|
321
|
+
title: "Build a task spec",
|
|
322
|
+
description:
|
|
323
|
+
"Expand a rough thought into a compact task spec with requirements and acceptance criteria (Dictum's 'spec' template). Text in, Markdown spec out.",
|
|
324
|
+
inputSchema: {
|
|
325
|
+
text: z.string().describe("The rough task description to expand into a spec."),
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
track((args) => handleBuildSpec(args, deps)),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return server
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Load config, wire the real polisher, and serve over stdio. Stays alive until
|
|
336
|
+
* the client disconnects (stdin EOF) or the process is signalled. stdout is the
|
|
337
|
+
* protocol channel, so all logging goes to stderr.
|
|
338
|
+
*/
|
|
339
|
+
export async function startMcpServer(): Promise<void> {
|
|
340
|
+
const config = await loadConfig()
|
|
341
|
+
const modes = await availableTemplateNames(templatesDir())
|
|
342
|
+
const server = createMcpServer({ polish: polishFromConfig(config), modes })
|
|
343
|
+
const transport = new StdioServerTransport()
|
|
344
|
+
await server.connect(transport)
|
|
345
|
+
instrumentWireTracking(transport)
|
|
346
|
+
process.stderr.write("dictum mcp: host-brain prompts + tools ready on stdio\n")
|
|
347
|
+
|
|
348
|
+
await new Promise<void>((resolve) => {
|
|
349
|
+
const done = () => resolve()
|
|
350
|
+
transport.onclose = done
|
|
351
|
+
// The SDK transport does not surface stdin EOF as onclose — watch the
|
|
352
|
+
// stream directly so `printf … | dictum mcp` exits when the pipe closes.
|
|
353
|
+
// Drain in-flight requests first: killing a response mid-computation
|
|
354
|
+
// would return exit 0 with no output.
|
|
355
|
+
const drainThenDone = () => {
|
|
356
|
+
waitForIdle().then(done)
|
|
357
|
+
}
|
|
358
|
+
process.stdin.once("end", drainThenDone)
|
|
359
|
+
process.stdin.once("close", drainThenDone)
|
|
360
|
+
process.once("SIGINT", done)
|
|
361
|
+
process.once("SIGTERM", done)
|
|
362
|
+
})
|
|
363
|
+
}
|
package/src/modules.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polisher/anthropic.ts — Polisher backed by the Claude Messages API.
|
|
3
|
+
*
|
|
4
|
+
* Raw HTTP (fetch) to keep dependencies minimal, mirroring stt/openai_compat.ts.
|
|
5
|
+
* POST {base}/v1/messages
|
|
6
|
+
* headers: x-api-key, anthropic-version: 2023-06-01, content-type
|
|
7
|
+
* body: { model, max_tokens, system, messages: [{role:"user", content}] }
|
|
8
|
+
* response: { content: [{type:"text", text}], stop_reason, ... }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Polisher, Template } from "../core/types.ts"
|
|
12
|
+
|
|
13
|
+
const ANTHROPIC_VERSION = "2023-06-01"
|
|
14
|
+
|
|
15
|
+
export type AnthropicOptions = {
|
|
16
|
+
apiKey: string
|
|
17
|
+
model: string
|
|
18
|
+
baseUrl: string
|
|
19
|
+
/** Max output tokens; polished prompts are short. */
|
|
20
|
+
maxTokens?: number
|
|
21
|
+
/** Request timeout in milliseconds. */
|
|
22
|
+
timeoutMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type AnthropicResponse = {
|
|
26
|
+
content?: Array<{ type?: string; text?: string }>
|
|
27
|
+
stop_reason?: string
|
|
28
|
+
error?: { message?: string }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AnthropicPolisher implements Polisher {
|
|
32
|
+
private readonly apiKey: string
|
|
33
|
+
private readonly model: string
|
|
34
|
+
private readonly baseUrl: string
|
|
35
|
+
private readonly maxTokens: number
|
|
36
|
+
private readonly timeoutMs: number
|
|
37
|
+
|
|
38
|
+
constructor(opts: AnthropicOptions) {
|
|
39
|
+
this.apiKey = opts.apiKey
|
|
40
|
+
this.model = opts.model
|
|
41
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
|
|
42
|
+
this.maxTokens = opts.maxTokens ?? 2048
|
|
43
|
+
this.timeoutMs = opts.timeoutMs ?? 60000
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async polish(text: string, template: Template): Promise<string> {
|
|
47
|
+
if (!this.apiKey) {
|
|
48
|
+
throw new Error("Anthropic polisher needs an API key — set ANTHROPIC_API_KEY")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let res: Response
|
|
52
|
+
try {
|
|
53
|
+
res = await fetch(`${this.baseUrl}/v1/messages`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
"x-api-key": this.apiKey,
|
|
58
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
model: this.model,
|
|
62
|
+
max_tokens: this.maxTokens,
|
|
63
|
+
system: template.instruction,
|
|
64
|
+
messages: [{ role: "user", content: text }],
|
|
65
|
+
}),
|
|
66
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
67
|
+
})
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
70
|
+
throw new Error(`Anthropic request failed: ${reason}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = (await res.json().catch(() => ({}))) as AnthropicResponse
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const detail = body.error?.message ?? (await res.text().catch(() => "")).trim()
|
|
76
|
+
throw new Error(`Anthropic API returned ${res.status}${detail ? `: ${detail}` : ""}`)
|
|
77
|
+
}
|
|
78
|
+
if (body.stop_reason === "refusal") {
|
|
79
|
+
throw new Error("Anthropic declined to polish this transcript (safety refusal)")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = (body.content ?? [])
|
|
83
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
84
|
+
.map((b) => b.text)
|
|
85
|
+
.join("")
|
|
86
|
+
.trim()
|
|
87
|
+
if (!out) throw new Error("Anthropic returned an empty response")
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polisher/claude_cli.ts — Polisher that shells out to the `claude` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Runs `claude -p --output-format text` with the instruction + transcript
|
|
5
|
+
* piped via stdin (never argv: transcripts must not leak into the process
|
|
6
|
+
* list — /proc/<pid>/cmdline is world-readable on the same host). Uses the
|
|
7
|
+
* user's existing Claude Code auth (subscription), so no API key is needed.
|
|
8
|
+
* The instruction comes from the selected Template.
|
|
9
|
+
*
|
|
10
|
+
* Least privilege: the polisher is a pure text-in/text-out call, so the CLI
|
|
11
|
+
* runs with --safe-mode (no CLAUDE.md, hooks, plugins, MCP servers, skills),
|
|
12
|
+
* --no-session-persistence (no transcript saved to disk) and all standard
|
|
13
|
+
* tools denied. Project-aware polishing is the host-brain MCP path's job,
|
|
14
|
+
* not this subprocess's.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Polisher, Template } from "../core/types.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Oldest Claude Code that understands every safety flag below (--safe-mode
|
|
21
|
+
* shipped in 2.1.169). doctor checks the installed CLI against this.
|
|
22
|
+
*/
|
|
23
|
+
export const CLAUDE_CLI_MIN_VERSION = "2.1.169"
|
|
24
|
+
|
|
25
|
+
/** Compare dotted numeric versions; true when `a >= b`. Pure, for testing. */
|
|
26
|
+
export function versionAtLeast(a: string, b: string): boolean {
|
|
27
|
+
const pa = a.split(".").map(Number)
|
|
28
|
+
const pb = b.split(".").map(Number)
|
|
29
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
30
|
+
const x = pa[i] ?? 0
|
|
31
|
+
const y = pb[i] ?? 0
|
|
32
|
+
if (x !== y) return x > y
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hardening flags every polisher invocation carries (pinned by tests).
|
|
39
|
+
* `--tools ""` disables ALL tools (future-proof); the denylist stays as a
|
|
40
|
+
* second belt for CLI versions where the empty allowlist is advisory.
|
|
41
|
+
*/
|
|
42
|
+
export const CLAUDE_CLI_SAFETY_ARGS = [
|
|
43
|
+
"--safe-mode",
|
|
44
|
+
"--no-session-persistence",
|
|
45
|
+
"--tools",
|
|
46
|
+
"",
|
|
47
|
+
"--disallowedTools",
|
|
48
|
+
"Bash,Edit,Write,Read,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit",
|
|
49
|
+
] as const
|
|
50
|
+
|
|
51
|
+
export type ClaudeCliOptions = {
|
|
52
|
+
/** Binary name/path; defaults to "claude". */
|
|
53
|
+
bin?: string
|
|
54
|
+
/** Optional model override (empty = CLI default). */
|
|
55
|
+
model?: string
|
|
56
|
+
/** Timeout in milliseconds. */
|
|
57
|
+
timeoutMs?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ClaudeCliPolisher implements Polisher {
|
|
61
|
+
private readonly bin: string
|
|
62
|
+
private readonly model: string
|
|
63
|
+
private readonly timeoutMs: number
|
|
64
|
+
|
|
65
|
+
constructor(opts: ClaudeCliOptions = {}) {
|
|
66
|
+
this.bin = opts.bin ?? "claude"
|
|
67
|
+
this.model = opts.model ?? ""
|
|
68
|
+
this.timeoutMs = opts.timeoutMs ?? 60000
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async polish(text: string, template: Template): Promise<string> {
|
|
72
|
+
const prompt = `${template.instruction}\n${text}`
|
|
73
|
+
const args = ["-p", "--output-format", "text", ...CLAUDE_CLI_SAFETY_ARGS]
|
|
74
|
+
if (this.model) args.push("--model", this.model)
|
|
75
|
+
|
|
76
|
+
const spawn = () => {
|
|
77
|
+
try {
|
|
78
|
+
return Bun.spawn([this.bin, ...args], {
|
|
79
|
+
stdin: new TextEncoder().encode(prompt),
|
|
80
|
+
stdout: "pipe",
|
|
81
|
+
stderr: "pipe",
|
|
82
|
+
})
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Cannot launch '${this.bin}': ${reason}. Is the Claude CLI installed and on PATH?`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const proc = spawn()
|
|
91
|
+
|
|
92
|
+
// Manual timeout: SIGTERM at the deadline, SIGKILL after a grace, so a
|
|
93
|
+
// child that holds the stdout/stderr pipes open can't make us hang. We
|
|
94
|
+
// never block on pipe EOF past the process exit (bounded reads below).
|
|
95
|
+
let timedOut = false
|
|
96
|
+
const sigtermTimer = setTimeout(() => {
|
|
97
|
+
timedOut = true
|
|
98
|
+
proc.kill("SIGTERM")
|
|
99
|
+
}, this.timeoutMs)
|
|
100
|
+
const sigkillTimer = setTimeout(() => proc.kill("SIGKILL"), this.timeoutMs + 2000)
|
|
101
|
+
|
|
102
|
+
const stdoutText = new Response(proc.stdout).text().catch(() => "")
|
|
103
|
+
const stderrText = new Response(proc.stderr).text().catch(() => "")
|
|
104
|
+
|
|
105
|
+
const code = await proc.exited
|
|
106
|
+
clearTimeout(sigtermTimer)
|
|
107
|
+
clearTimeout(sigkillTimer)
|
|
108
|
+
|
|
109
|
+
// Don't let a lingering grandchild's open pipe hang us: cap the reads.
|
|
110
|
+
const bounded = (p: Promise<string>): Promise<string> =>
|
|
111
|
+
Promise.race([p, new Promise<string>((resolve) => setTimeout(() => resolve(""), 1000))])
|
|
112
|
+
const stdout = await bounded(stdoutText)
|
|
113
|
+
const stderr = await bounded(stderrText)
|
|
114
|
+
|
|
115
|
+
if (timedOut) {
|
|
116
|
+
throw new Error(`claude polishing timed out after ${this.timeoutMs / 1000}s`)
|
|
117
|
+
}
|
|
118
|
+
if (code !== 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`claude exited with code ${code}: ${stderr.trim() || stdout.trim() || "no output"}`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = stdout.trim()
|
|
125
|
+
if (!result) throw new Error("claude returned an empty response")
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polisher/factory.ts — build a Polisher from configuration.
|
|
3
|
+
*
|
|
4
|
+
* Validates the provider name (human-readable error on a bad config/env value)
|
|
5
|
+
* and constructs the matching backend. Imports only core/types.ts and sibling
|
|
6
|
+
* polisher files (own module).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Polisher } from "../core/types.ts"
|
|
10
|
+
import { AnthropicPolisher } from "./anthropic.ts"
|
|
11
|
+
import { ClaudeCliPolisher } from "./claude_cli.ts"
|
|
12
|
+
import { LayeredPolisher, RulesPolisher } from "./layered.ts"
|
|
13
|
+
import { OpenAiCompatPolisher } from "./openai_compat.ts"
|
|
14
|
+
|
|
15
|
+
export const VALID_POLISHERS = ["claude_cli", "anthropic", "openai_compat"] as const
|
|
16
|
+
export type PolisherName = (typeof VALID_POLISHERS)[number]
|
|
17
|
+
|
|
18
|
+
export const VALID_POLISHER_MODES = ["llm", "rules", "layered"] as const
|
|
19
|
+
export type PolisherMode = (typeof VALID_POLISHER_MODES)[number]
|
|
20
|
+
|
|
21
|
+
/** Layered mode: skip the LLM when the draft scores ≥ this (0–100). */
|
|
22
|
+
const DEFAULT_SCORE_THRESHOLD = 80
|
|
23
|
+
|
|
24
|
+
/** Structural config the factory needs (mirrors Config["polisher"], no import). */
|
|
25
|
+
export type PolisherFactoryConfig = {
|
|
26
|
+
provider: string
|
|
27
|
+
/** Polishing mode; optional for back-compat, defaults to "llm". */
|
|
28
|
+
mode?: string
|
|
29
|
+
/** Layered-mode gate (0–100); defaults to 80. */
|
|
30
|
+
scoreThreshold?: number
|
|
31
|
+
claude_cli: { bin: string; model: string; timeout: number }
|
|
32
|
+
anthropic: { apiKey: string; model: string; baseUrl: string }
|
|
33
|
+
openai_compat: { apiKey: string; model: string; baseUrl: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isValidName(name: string): name is PolisherName {
|
|
37
|
+
return (VALID_POLISHERS as readonly string[]).includes(name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidMode(mode: string): mode is PolisherMode {
|
|
41
|
+
return (VALID_POLISHER_MODES as readonly string[]).includes(mode)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Construct the configured Polisher; throws on an unknown provider or mode.
|
|
46
|
+
* Mode "rules" is fully offline and needs no provider at all; "layered" wraps
|
|
47
|
+
* the configured provider behind the rule-based score gate.
|
|
48
|
+
*/
|
|
49
|
+
export function createPolisher(cfg: PolisherFactoryConfig): Polisher {
|
|
50
|
+
const mode = cfg.mode ?? "llm"
|
|
51
|
+
if (!isValidMode(mode)) {
|
|
52
|
+
throw new Error(`Unknown polisher mode '${mode}'. Valid: ${VALID_POLISHER_MODES.join(", ")}`)
|
|
53
|
+
}
|
|
54
|
+
if (mode === "rules") return new RulesPolisher()
|
|
55
|
+
|
|
56
|
+
if (!isValidName(cfg.provider)) {
|
|
57
|
+
throw new Error(`Unknown polisher '${cfg.provider}'. Valid: ${VALID_POLISHERS.join(", ")}`)
|
|
58
|
+
}
|
|
59
|
+
const base = createProvider(cfg, cfg.provider)
|
|
60
|
+
if (mode === "layered") {
|
|
61
|
+
return new LayeredPolisher(base, {
|
|
62
|
+
scoreThreshold: cfg.scoreThreshold ?? DEFAULT_SCORE_THRESHOLD,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createProvider(cfg: PolisherFactoryConfig, provider: PolisherName): Polisher {
|
|
69
|
+
switch (provider) {
|
|
70
|
+
case "claude_cli":
|
|
71
|
+
return new ClaudeCliPolisher({
|
|
72
|
+
bin: cfg.claude_cli.bin,
|
|
73
|
+
model: cfg.claude_cli.model,
|
|
74
|
+
timeoutMs: cfg.claude_cli.timeout * 1000,
|
|
75
|
+
})
|
|
76
|
+
case "anthropic":
|
|
77
|
+
return new AnthropicPolisher({
|
|
78
|
+
apiKey: cfg.anthropic.apiKey,
|
|
79
|
+
model: cfg.anthropic.model,
|
|
80
|
+
baseUrl: cfg.anthropic.baseUrl,
|
|
81
|
+
})
|
|
82
|
+
case "openai_compat":
|
|
83
|
+
return new OpenAiCompatPolisher({
|
|
84
|
+
apiKey: cfg.openai_compat.apiKey,
|
|
85
|
+
model: cfg.openai_compat.model,
|
|
86
|
+
baseUrl: cfg.openai_compat.baseUrl,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|