dsh-persona-forge 0.2.1 → 0.3.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/lib/index.js CHANGED
@@ -40,8 +40,18 @@ export const inject = []
40
40
  const DEFAULT_CONFIG = {
41
41
  /** What the composer does after a rewrite settles. */
42
42
  sendMode: 'review',
43
- /** Run the technical-fact preservation check after each rewrite. */
44
- factCheck: true,
43
+ /**
44
+ * Run the SECOND, model-based preservation check after each rewrite.
45
+ *
46
+ * OFF by default. It is the only thing that can see a requirement reworded
47
+ * weaker, or a behavioural constraint added in prose — but it doubles the
48
+ * latency and the token cost of every single send, and the free local check
49
+ * (lib/preserve.js) already proves the common and most damaging failure: a
50
+ * dropped identifier, path, version or number. Paying a model call on every
51
+ * send to re-check what a string comparison answers for nothing is a bad
52
+ * default; turn it on when the drafts are technical enough to warrant it.
53
+ */
54
+ factCheck: false,
45
55
  /** Watch the card directory and reload the catalog on change. */
46
56
  watchCards: true,
47
57
  }
@@ -51,7 +61,10 @@ function resolveConfig(raw) {
51
61
  const source = raw !== null && typeof raw === 'object' ? raw : {}
52
62
  return {
53
63
  sendMode: source.sendMode === 'direct' ? 'direct' : 'review',
54
- factCheck: source.factCheck !== false,
64
+ // Only an explicit `true` enables it. Anything else — absent, null, a typo —
65
+ // leaves the cheap default, because the cost of this check is paid on every
66
+ // send and the local check already covers the common failure.
67
+ factCheck: source.factCheck === true,
55
68
  watchCards: source.watchCards !== false,
56
69
  }
57
70
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The deterministic half of the preservation check.
3
+ *
4
+ * The plugin has two ways to notice that a rewrite changed the request rather
5
+ * than its voice:
6
+ *
7
+ * 1. This module — free, instant, provable. It compares the technical tokens
8
+ * of the draft against the rewrite: identifiers, file paths, versions,
9
+ * numbers, flags, and named technologies.
10
+ * 2. An optional second model call — costs tokens and latency, but is the only
11
+ * thing that can see a *semantic* change (a requirement quietly weakened,
12
+ * a constraint invented in prose).
13
+ *
14
+ * They are complementary, and the split is deliberate: the most common and most
15
+ * damaging failure is a dropped identifier, path or number, and that is exactly
16
+ * what a token comparison proves for free. Paying a second model call on every
17
+ * send to catch it would be wasteful, so this check always runs and the model
18
+ * call is opt-in.
19
+ *
20
+ * WHAT THIS CANNOT SEE, and must never be presented as covering:
21
+ * - a requirement reworded into something weaker while keeping every token;
22
+ * - a behavioural constraint added in ordinary prose;
23
+ * - a quantity expressed in Chinese words (「两次」) rather than digits;
24
+ * - a bare lowercase English word that is not a path, flag or identifier.
25
+ * The optional model check exists for exactly those.
26
+ *
27
+ * @module dsh-persona-forge/preserve
28
+ */
29
+
30
+ /** Words that carry no technical meaning, so their absence proves nothing. */
31
+ const STOP_WORDS = new Set([
32
+ 'the', 'a', 'an', 'and', 'or', 'to', 'of', 'for', 'is', 'are', 'was', 'were',
33
+ 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'in',
34
+ 'on', 'at', 'by', 'with', 'from', 'as', 'if', 'then', 'than', 'so', 'not',
35
+ 'no', 'do', 'does', 'did', 'can', 'could', 'should', 'would', 'will',
36
+ 'shall', 'may', 'might', 'must', 'i', 'you', 'we', 'they', 'he', 'she', 'my',
37
+ 'your', 'our', 'their', 'me', 'us', 'them', 'here', 'there', 'when', 'where',
38
+ 'which', 'who', 'what', 'how', 'why', 'all', 'any', 'some', 'each', 'every',
39
+ 'but', 'also', 'just', 'please', 'thanks', 'thank', 'need', 'want', 'make',
40
+ 'use', 'get', 'set', 'let', 'say', 'write', 'read', 'run', 'fix', 'add',
41
+ 'new', 'old', 'one', 'two', 'first', 'last', 'next', 'then', 'now',
42
+ ])
43
+
44
+ /**
45
+ * One ordered alternation, so a token is matched exactly once and no fact is
46
+ * double-counted. Order is specificity: a code span before a version, a version
47
+ * before a bare number.
48
+ *
49
+ * A capitalised word is included because named technologies (`Rust`, `Python`,
50
+ * `Docker`) are technical facts; matching is case-insensitive downstream, so
51
+ * sentence-initial prose (`Write …`) cannot be reported as lost when the
52
+ * rewrite lowercases it.
53
+ * @type {RegExp}
54
+ */
55
+ const TOKEN = new RegExp([
56
+ '`[^`\\n]{1,80}`', // an inline code span, spaces and all
57
+ 'v?\\d+(?:\\.\\d+)+(?:[-+][A-Za-z0-9.]+)?', // a version: v1.2.3, 1.2.3-beta
58
+ '[A-Za-z_][A-Za-z0-9_-]*(?:[./][A-Za-z0-9_-]+)+', // a path or dotted name: a.js, src/main.rs
59
+ '[A-Za-z_][A-Za-z0-9_]*_[A-Za-z0-9_]+', // snake_case
60
+ '[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*', // camelCase
61
+ '[A-Z][A-Z0-9]{1,}', // an acronym: API, HTTP, README
62
+ '--[A-Za-z][A-Za-z0-9-]*', // a long flag: --watch
63
+ '[A-Z][a-z]{2,}', // a capitalised word: Rust, Docker
64
+ '\\d+(?:\\.\\d+)*', // a bare number
65
+ ].join('|'), 'g')
66
+
67
+ /**
68
+ * Collect the technical facts a text asserts, as a case-insensitive multiset.
69
+ *
70
+ * A multiset, not a set: a draft mentioning `8080` twice and a rewrite
71
+ * mentioning it once has lost a fact, and counting occurrences is what catches
72
+ * that.
73
+ * @param {string} text - the draft or the rewrite.
74
+ * @returns {Map<string, number>} lowercased token to occurrence count.
75
+ */
76
+ export function factsIn(text) {
77
+ const counts = new Map()
78
+ for (const match of String(text ?? '').matchAll(TOKEN)) {
79
+ // A code span keeps its inner text; the backticks are punctuation.
80
+ const token = match[0].replace(/^`|`$/g, '').trim().toLowerCase()
81
+ if (token === '') continue
82
+ if (STOP_WORDS.has(token)) continue
83
+ // A single letter or digit carries no meaning worth comparing.
84
+ if (token.replace(/[^a-z0-9]/g, '').length < 2) continue
85
+ counts.set(token, (counts.get(token) ?? 0) + 1)
86
+ }
87
+ return counts
88
+ }
89
+
90
+ /**
91
+ * Whether a token is structurally unmistakable — a path, version, number, flag
92
+ * or code span — as opposed to a bare word.
93
+ *
94
+ * Only these may be reported as INVENTED. A bear word can legitimately appear
95
+ * in a rewrite as ceremony: the persona's own name ("Omnissiah"), a form of
96
+ * address, a flourish. Reporting those as "you added a fact" would fire on
97
+ * every successful zealot rewrite, which is precisely the check's job to allow.
98
+ * @param {string} token - a lowercased token.
99
+ * @returns whether it is structural.
100
+ */
101
+ function isStructural(token) {
102
+ if (/[0-9]/.test(token)) return true
103
+ if (/[./_@-]/.test(token)) return true
104
+ return false
105
+ }
106
+
107
+ /**
108
+ * Compare a rewrite against its draft for lost or invented technical facts.
109
+ *
110
+ * Reports `drift` only for things a token comparison can actually prove, and
111
+ * names them, so the panel can show exactly what went missing instead of a
112
+ * vague warning. Everything it cannot prove is `ok` — never a false alarm.
113
+ * @param {string} original - the user's draft.
114
+ * @param {string} rewritten - the rewrite under review.
115
+ * @returns {{state: 'ok'|'drift', missing: string[], added: string[]}} the verdict.
116
+ */
117
+ export function compareFacts(original, rewritten) {
118
+ const before = factsIn(original)
119
+ const after = factsIn(rewritten)
120
+ const missing = []
121
+ const added = []
122
+ for (const [token, count] of before) {
123
+ // Case-insensitive, already: a rewrite may legitimately recase a word.
124
+ const kept = after.get(token) ?? 0
125
+ if (kept < count) missing.push(token)
126
+ }
127
+ for (const token of after.keys()) {
128
+ if (!before.has(token) && isStructural(token)) added.push(token)
129
+ }
130
+ // Keep the report readable: a long list is noise, and the first few are what
131
+ // the user needs to act on.
132
+ const cap = (list) => list.sort().slice(0, 12)
133
+ return {
134
+ state: missing.length > 0 || added.length > 0 ? 'drift' : 'ok',
135
+ missing: cap(missing),
136
+ added: cap(added),
137
+ }
138
+ }
package/lib/prompts.js CHANGED
@@ -75,12 +75,20 @@ const FIDELITY_RULES = {
75
75
 
76
76
  /**
77
77
  * Build the system prompt of one rewrite call.
78
+ *
79
+ * `fidelity` is a PARAMETER rather than read off the card: the caller may
80
+ * narrow a strategy card to voice-only for a single message, and a prompt built
81
+ * from `card.fidelity` would silently ignore that. The response would report
82
+ * the narrowed fidelity while the model still received the strategy rules —
83
+ * a setting that appears to work and does nothing.
78
84
  * @param card - the normalized persona card.
79
85
  * @param intensity - the effective intensity (the card's, or a caller override).
80
86
  * @param examples - the effective conversion examples for that intensity.
87
+ * @param fidelity - the effective fidelity (the card's, or a narrowed override).
81
88
  * @returns the system prompt text.
82
89
  */
83
- export function buildSystemPrompt(card, intensity, examples) {
90
+ export function buildSystemPrompt(card, intensity, examples, fidelity) {
91
+ const effectiveFidelity = fidelity ?? card.fidelity
84
92
  const parts = [
85
93
  'You rewrite a user\'s message into the voice of a character, for an AI coding assistant.',
86
94
  '',
@@ -92,7 +100,7 @@ export function buildSystemPrompt(card, intensity, examples) {
92
100
  ...card.description !== undefined ? ['## Character summary', card.description, ''] : [],
93
101
  ...INTENSITY_RULES[intensity] ?? INTENSITY_RULES.medium,
94
102
  '',
95
- ...FIDELITY_RULES[card.fidelity] ?? FIDELITY_RULES.style,
103
+ ...FIDELITY_RULES[effectiveFidelity] ?? FIDELITY_RULES.style,
96
104
  '',
97
105
  '## Speaker direction (most common failure — read carefully)',
98
106
  'The character described above is the ASSISTANT\'s identity. The message you are rewriting belongs to the USER.',
@@ -156,6 +164,159 @@ export function renderTemplate(template, text) {
156
164
  return `${template}\n\n${text}`
157
165
  }
158
166
 
167
+ /**
168
+ * System prompt of the character-card generator. The user supplies a rough
169
+ * idea ("a grumpy lighthouse keeper"); this asks the model to turn it into a
170
+ * complete card.
171
+ *
172
+ * The two load-bearing rules of this plugin apply to the GENERATED card too,
173
+ * not only to hand-written ones. A card whose `examples` replace requirements
174
+ * with atmosphere teaches the rewriting model to do exactly that, so the
175
+ * generator is held to the same standard as the bundled cards: every example
176
+ * must carry its technical facts across, and the style must state the speaker
177
+ * direction.
178
+ */
179
+ const CARD_DRAFT_SYSTEM = [
180
+ 'You design a character card for a prompt-rewriting tool.',
181
+ 'The card describes a character voice. A later step will rewrite the user\'s messages into that voice.',
182
+ '',
183
+ 'Answer with ONE JSON object and nothing else. No prose, no code fences, no comments.',
184
+ '',
185
+ 'Fields:',
186
+ '- "id": lowercase letters, digits and hyphens only, 2-40 chars, descriptive of the character.',
187
+ '- "name": the character name, at most 40 chars, in the same language as the idea.',
188
+ '- "icon": exactly one emoji.',
189
+ '- "description": one sentence describing the voice, at most 80 chars, same language as the idea.',
190
+ '- "style": 3-8 sentences of instructions for the rewriting model, in the language of the idea.',
191
+ '- "intensity": one of "light", "medium", "strong", "zealot".',
192
+ '- "examplesByIntensity": an object with ALL FOUR keys "light", "medium", "strong" and "zealot".',
193
+ ' Each key holds an array of 1 to 2 objects, each {"from": "...", "to": "..."}.',
194
+ ' The four levels must be genuinely different: "light" adds only a touch of flavour;',
195
+ ' "medium" frames the request clearly; "strong" gives the voice real weight while keeping',
196
+ ' the request the first thing the reader sees; "zealot" ritualizes throughout without',
197
+ ' ever burying the request.',
198
+ '',
199
+ 'The "style" field MUST state the speaker direction explicitly. The character is the',
200
+ 'ASSISTANT\'s identity, and the message being rewritten belongs to the USER, so the rewrite',
201
+ 'speaks in the user\'s voice and addresses the character. If the character is an authority,',
202
+ 'a deity or a machine-god, the user is the petitioner — never the authority. Say this in',
203
+ 'the style text, and do not write the character\'s own speech.',
204
+ '',
205
+ 'The "examplesByIntensity" entries are the most important part and are graded strictly:',
206
+ 'each "from" is a plain user request, each "to" is that SAME request rewritten in the voice.',
207
+ 'Every technical fact in "from" — every file name, identifier, number, version, path,',
208
+ 'command and constraint — must still be present in "to". Change only the voice.',
209
+ 'Never replace a concrete requirement with atmosphere.',
210
+ 'Write the examples in the same language as the idea.',
211
+ '',
212
+ 'Do not describe a task-checker, a reviewer or a tester as the character. The character is',
213
+ 'a voice for the user\'s own requests, not an agent that performs the request.',
214
+ ].join('\n')
215
+
216
+ /**
217
+ * Build the user message of the card generator.
218
+ * @param brief - the user's rough description of the character.
219
+ * @param takenIds - ids already in use, so a new card does not silently collide.
220
+ * @returns the user message body.
221
+ */
222
+ export function frameCardBrief(brief, takenIds = []) {
223
+ const safe = brief.replace(/<\/?(idea|taken)>/gi, '<\\/$1>')
224
+ const parts = ['Character idea:', '<idea>', safe, '</idea>']
225
+ if (takenIds.length > 0) {
226
+ parts.push(
227
+ '',
228
+ 'These ids are already in use; choose a DIFFERENT id:',
229
+ '<taken>',
230
+ takenIds.join(', '),
231
+ '</taken>',
232
+ )
233
+ }
234
+ return parts.join('\n')
235
+ }
236
+
237
+ /** Coerce one generated example into a `{ from, to }` pair, or undefined. */
238
+ function draftExampleOf(raw) {
239
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
240
+ const from = typeof raw.from === 'string' ? raw.from.trim() : ''
241
+ const to = typeof raw.to === 'string' ? raw.to.trim() : ''
242
+ if (from === '' || to === '') return undefined
243
+ return { from, to }
244
+ }
245
+
246
+ /**
247
+ * Turn the model's raw answer into a card-shaped object, without trusting it.
248
+ *
249
+ * The model is asked for bare JSON but may wrap it in a fence or bracket it
250
+ * with a sentence; the first balanced-looking object is recovered and parsed.
251
+ * Structural problems are reported as an error so the route can answer with a
252
+ * clear message instead of half-filling the form. Field-level problems are
253
+ * left to `normalizeCard`, which is the single validator for both generated
254
+ * and hand-written cards.
255
+ * @param text - the model's raw answer.
256
+ * @returns the candidate card document, or a diagnostic.
257
+ */
258
+ export function parseCardDraft(text) {
259
+ const trimmed = (text ?? '').trim()
260
+ if (trimmed === '') return { error: 'the model returned nothing' }
261
+ // Drop a whole-output code fence, with or without a language tag.
262
+ const fenced = /^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```$/.exec(trimmed)
263
+ const body = (fenced?.[1] ?? trimmed).trim()
264
+ let raw = body
265
+ if (!body.startsWith('{')) {
266
+ // Recover the outermost object from surrounding prose. Slicing to the last
267
+ // brace is what makes a trailing sentence harmless.
268
+ const start = body.indexOf('{')
269
+ const end = body.lastIndexOf('}')
270
+ if (start === -1 || end <= start) return { error: 'the model did not return a JSON object' }
271
+ raw = body.slice(start, end + 1)
272
+ }
273
+ let parsed
274
+ try {
275
+ parsed = JSON.parse(raw)
276
+ } catch (error) {
277
+ return { error: `the model returned invalid JSON: ${error instanceof Error ? error.message : String(error)}` }
278
+ }
279
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
280
+ return { error: 'the model did not return a JSON object' }
281
+ }
282
+ const document = {
283
+ // `fidelity` is deliberately NOT taken from the model. Whether a card may
284
+ // add behavioural constraints is the user's decision, and a generated card
285
+ // should never quietly acquire that power. It defaults to voice-only and
286
+ // the user can raise it deliberately in the form.
287
+ fidelity: 'style',
288
+ mode: 'llm',
289
+ }
290
+ for (const field of ['id', 'name', 'icon', 'description']) {
291
+ if (typeof parsed[field] === 'string' && parsed[field].trim() !== '') document[field] = parsed[field].trim()
292
+ }
293
+ if (typeof parsed.style === 'string' && parsed.style.trim() !== '') document.style = parsed.style.trim()
294
+ // Intensity is copied through as-is: `normalizeCard` is the single validator
295
+ // and clamps an unknown rung to its default, so this module needs no copy of
296
+ // the enum and cannot drift from the store's ladder.
297
+ if (typeof parsed.intensity === 'string' && parsed.intensity.trim() !== '') {
298
+ document.intensity = parsed.intensity.trim()
299
+ }
300
+ if (Array.isArray(parsed.examples)) {
301
+ const examples = parsed.examples.map(draftExampleOf).filter((example) => example !== undefined)
302
+ if (examples.length > 0) document.examples = examples
303
+ }
304
+ // Per-intensity demonstrations are the reason the four-rung ladder does
305
+ // anything at all, so a generated card carries them. A rung with no usable
306
+ // pair is simply absent here; `examplesFor` then falls back to the flat list
307
+ // rather than demonstrating nothing.
308
+ if (parsed.examplesByIntensity !== null && typeof parsed.examplesByIntensity === 'object' && !Array.isArray(parsed.examplesByIntensity)) {
309
+ const byIntensity = {}
310
+ for (const [level, list] of Object.entries(parsed.examplesByIntensity)) {
311
+ if (!Array.isArray(list)) continue
312
+ const examples = list.map(draftExampleOf).filter((example) => example !== undefined)
313
+ if (examples.length > 0) byIntensity[level] = examples
314
+ }
315
+ if (Object.keys(byIntensity).length > 0) document.examplesByIntensity = byIntensity
316
+ }
317
+ return { document }
318
+ }
319
+
159
320
  /** System prompt of the technical-fact preservation check. */
160
321
  const FACT_CHECK_SYSTEM = [
161
322
  'You compare two versions of one user request and report whether the rewrite preserved the technical content.',
@@ -215,4 +376,4 @@ export function parseFactCheck(text) {
215
376
  return { state: 'unavailable', reason: `unparseable verdict: ${trimmed.slice(0, 120)}` }
216
377
  }
217
378
 
218
- export { FACT_CHECK_SYSTEM }
379
+ export { FACT_CHECK_SYSTEM, CARD_DRAFT_SYSTEM }
package/lib/routes.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  CARD_RESET_ENDPOINT,
21
21
  CARD_DIFF_ENDPOINT,
22
22
  REVEAL_ENDPOINT,
23
+ CARD_DRAFT_ENDPOINT,
23
24
  } from './shared/protocol.js'
24
25
  import { isTrustedRequest } from './loopback.js'
25
26
  import { readBoundedJson, writeJson } from './http.js'
@@ -32,8 +33,12 @@ import {
32
33
  parseFactCheck,
33
34
  renderTemplate,
34
35
  FACT_CHECK_SYSTEM,
36
+ CARD_DRAFT_SYSTEM,
37
+ frameCardBrief,
38
+ parseCardDraft,
35
39
  } from './prompts.js'
36
40
  import { rewriteDraft, checkPreservation } from './rewrite.js'
41
+ import { compareFacts } from './preserve.js'
37
42
 
38
43
  /** Largest accepted request body. */
39
44
  const MAX_BODY_BYTES = 512 * 1024
@@ -191,6 +196,9 @@ async function serve(deps, req, res) {
191
196
  case CARD_DIFF_ENDPOINT:
192
197
  await serveCardDiff(deps, res)
193
198
  return
199
+ case CARD_DRAFT_ENDPOINT:
200
+ await serveCardDraft(deps, body, req, res)
201
+ return
194
202
  case REVEAL_ENDPOINT:
195
203
  await serveReveal(deps, res)
196
204
  return
@@ -206,6 +214,11 @@ async function serve(deps, req, res) {
206
214
  async function serveCards(deps, res) {
207
215
  try {
208
216
  const catalog = await deps.store.list()
217
+ // Tell the client which model an AI draft would run on, so the settings
218
+ // page can SHOW it instead of the user having to guess. The settings slot
219
+ // receives no session id from the shell, so this is the harness default —
220
+ // reported honestly rather than described as "the session's model".
221
+ const draftRoute = resolveRoute(deps.ctx, undefined)
209
222
  writeJson(res, 200, {
210
223
  ok: true,
211
224
  value: {
@@ -214,6 +227,7 @@ async function serveCards(deps, res) {
214
227
  directory: catalog.directory,
215
228
  sendMode: deps.readConfig().sendMode,
216
229
  factCheck: deps.readConfig().factCheck,
230
+ draftModel: draftRoute === undefined ? null : { provider: draftRoute.provider, model: draftRoute.model },
217
231
  },
218
232
  })
219
233
  } catch (error) {
@@ -289,6 +303,116 @@ async function serveReveal(deps, res) {
289
303
  }
290
304
  }
291
305
 
306
+ /** Largest accepted character brief, in characters. */
307
+ const MAX_BRIEF_CHARS = 4_000
308
+
309
+ /** Largest output requested when generating a card. */
310
+ const DRAFT_MAX_TOKENS = 2_048
311
+
312
+ /**
313
+ * Turn a rough character idea into a filled-in card on the session's own model.
314
+ *
315
+ * This is a PREFILL, never a write: the candidate card travels back to the
316
+ * browser, which drops it into the card form for the user to review, edit and
317
+ * save through the ordinary save route. Nothing here touches the card
318
+ * directory, so a bad generation cannot damage the catalog.
319
+ *
320
+ * The generated document is put through the SAME `normalizeCard` the save path
321
+ * uses, so a card this route produces can never be one the save path would
322
+ * reject — and the browser receives a fully normalized card rather than raw
323
+ * model output.
324
+ */
325
+ async function serveCardDraft(deps, body, req, res) {
326
+ const brief = typeof body.brief === 'string' ? body.brief.trim() : ''
327
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
328
+ if (brief === '') {
329
+ writeJson(res, 422, { ok: false, error: { code: 'rejected', message: 'the character idea is empty' } })
330
+ return
331
+ }
332
+ if (brief.length > MAX_BRIEF_CHARS) {
333
+ writeJson(res, 422, {
334
+ ok: false,
335
+ error: { code: 'rejected', message: `the character idea exceeds ${MAX_BRIEF_CHARS} characters`, params: { max: MAX_BRIEF_CHARS } },
336
+ })
337
+ return
338
+ }
339
+
340
+ const route = resolveRoute(deps.ctx, sessionId)
341
+ if (route === undefined) {
342
+ writeJson(res, 409, {
343
+ ok: false,
344
+ error: {
345
+ code: 'unconfigured',
346
+ message: 'no model route resolved: send one message in this session first, or set a default model',
347
+ },
348
+ })
349
+ return
350
+ }
351
+ let llm
352
+ try {
353
+ llm = deps.ctx.get('llm')
354
+ } catch {
355
+ llm = undefined
356
+ }
357
+ if (llm === undefined || typeof llm.stream !== 'function') {
358
+ writeJson(res, 500, { ok: false, error: { code: 'internal', message: 'the llm service is unavailable' } })
359
+ return
360
+ }
361
+
362
+ // Cancel the model call when the browser goes away mid-flight.
363
+ const callerAbort = new AbortController()
364
+ const onClose = () => {
365
+ if (!res.writableEnded) callerAbort.abort()
366
+ }
367
+ res.on('close', onClose)
368
+ try {
369
+ let takenIds = []
370
+ try {
371
+ takenIds = (await deps.store.list()).cards.map((card) => card.id)
372
+ } catch {
373
+ // A catalog that cannot be read only costs the collision hint.
374
+ }
375
+ const result = await rewriteDraft(llm, {
376
+ provider: route.provider,
377
+ model: route.model,
378
+ system: CARD_DRAFT_SYSTEM,
379
+ userMessage: frameCardBrief(brief, takenIds),
380
+ maxTokens: DRAFT_MAX_TOKENS,
381
+ signal: callerAbort.signal,
382
+ })
383
+ const parsed = parseCardDraft(result.text)
384
+ if (parsed.error !== undefined) {
385
+ writeJson(res, 502, { ok: false, error: { code: 'upstream', message: parsed.error } })
386
+ return
387
+ }
388
+ const normalized = normalizeCard(parsed.document, { fallbackId: undefined, source: 'user' })
389
+ if (normalized.error !== undefined) {
390
+ writeJson(res, 502, {
391
+ ok: false,
392
+ error: { code: 'upstream', message: `the model produced an unusable card: ${normalized.error}` },
393
+ })
394
+ return
395
+ }
396
+ writeJson(res, 200, {
397
+ ok: true,
398
+ value: {
399
+ card: normalized.card,
400
+ provider: route.provider,
401
+ model: route.model,
402
+ elapsedMs: result.elapsedMs,
403
+ },
404
+ })
405
+ } catch (error) {
406
+ const code = errorCodeOf(error)
407
+ writeJson(res, code === 'timeout' ? 504 : code === 'unconfigured' ? 409 : 502, {
408
+ ok: false,
409
+ error: { code, message: error instanceof Error ? error.message : String(error) },
410
+ })
411
+ } finally {
412
+ res.off('close', onClose)
413
+ }
414
+ }
415
+
292
416
  /** One rewrite, plus the optional preservation check. */
293
417
  async function serveRewrite(deps, body, req, res) {
294
418
  const text = typeof body.text === 'string' ? body.text : ''
@@ -318,10 +442,29 @@ async function serveRewrite(deps, body, req, res) {
318
442
  const intensity = typeof body.intensity === 'string' && ['light', 'medium', 'strong', 'zealot'].includes(body.intensity)
319
443
  ? body.intensity
320
444
  : card.intensity
321
- // A caller may not widen fidelity beyond what the card declares: strategy
322
- // rewriting can add behavioural constraints, which is the card author's
323
- // decision, not a per-message toggle.
324
- const fidelity = card.fidelity
445
+
446
+ // Mode: the card's own, unless the caller asks for the other one AND the card
447
+ // can actually honour it. A template card with no `style` cannot be rewritten
448
+ // by a model, and an llm card with no `template` has nothing to substitute —
449
+ // so an override the card cannot satisfy falls back to the card's own mode
450
+ // rather than failing the request.
451
+ const canTemplate = typeof card.template === 'string' && card.template.trim() !== ''
452
+ const canRewrite = typeof card.style === 'string' && card.style.trim() !== ''
453
+ const mode = body.mode === 'template' && canTemplate
454
+ ? 'template'
455
+ : body.mode === 'llm' && canRewrite
456
+ ? 'llm'
457
+ : card.mode
458
+
459
+ // Fidelity may be NARROWED per message, never widened.
460
+ //
461
+ // A `strategy` card may be asked to behave as voice-only for one message:
462
+ // that only ever removes the extra behavioural constraints, so it is strictly
463
+ // safer than the card's own default. The reverse is refused — a `style` card
464
+ // can never be told to start adding behavioural constraints, because that
465
+ // changes the request beyond what the card's author allowed, and a per-message
466
+ // toggle is exactly the wrong place to grant that power.
467
+ const fidelity = card.fidelity === 'strategy' && body.fidelity === 'style' ? 'style' : card.fidelity
325
468
 
326
469
  // Cancel the model call when the browser goes away mid-flight.
327
470
  const callerAbort = new AbortController()
@@ -330,7 +473,7 @@ async function serveRewrite(deps, body, req, res) {
330
473
  }
331
474
  res.on('close', onClose)
332
475
  try {
333
- if (card.mode === 'template') {
476
+ if (mode === 'template') {
334
477
  const rendered = renderTemplate(card.template ?? '', text)
335
478
  writeJson(res, 200, {
336
479
  ok: true,
@@ -343,6 +486,9 @@ async function serveRewrite(deps, body, req, res) {
343
486
  fidelity,
344
487
  intensity,
345
488
  elapsedMs: 0,
489
+ // No model ran, so there is nothing a preservation check could
490
+ // disagree with: the draft is inserted verbatim.
491
+ facts: { state: 'ok', missing: [], added: [] },
346
492
  factCheck: { state: 'skipped', reason: 'template mode replaces the draft verbatim' },
347
493
  },
348
494
  })
@@ -371,7 +517,7 @@ async function serveRewrite(deps, body, req, res) {
371
517
  return
372
518
  }
373
519
 
374
- const system = buildSystemPrompt(card, intensity, examplesFor(card, intensity))
520
+ const system = buildSystemPrompt(card, intensity, examplesFor(card, intensity), fidelity)
375
521
  const result = await rewriteDraft(llm, {
376
522
  provider: route.provider,
377
523
  model: route.model,
@@ -381,10 +527,23 @@ async function serveRewrite(deps, body, req, res) {
381
527
  signal: callerAbort.signal,
382
528
  })
383
529
 
384
- // The preservation check is advisory: it never blocks a rewrite, it only
385
- // annotates it so the review panel can warn before the user sends.
530
+ // Two layers of preservation checking, deliberately split:
531
+ //
532
+ // - `facts` runs ALWAYS. It is a token comparison in this process: no
533
+ // tokens, no latency, and it proves the common failure outright (a
534
+ // dropped identifier, path, version or number).
535
+ // - `factCheck` is the optional second model call, for what a token
536
+ // comparison cannot see at all — a requirement reworded weaker, or a
537
+ // behavioural constraint invented in prose.
538
+ //
539
+ // The caller may switch the model check per request; the plugin config is
540
+ // the default. An explicit boolean from the client wins, so the settings
541
+ // toggle is authoritative for the person using it.
542
+ const facts = compareFacts(text, result.text)
543
+
544
+ const wantModelCheck = typeof body.modelCheck === 'boolean' ? body.modelCheck : config.factCheck
386
545
  let factCheck = { state: 'skipped', reason: 'disabled in settings' }
387
- if (config.factCheck) {
546
+ if (wantModelCheck) {
388
547
  const verdict = await checkPreservation(llm, {
389
548
  provider: route.provider,
390
549
  model: route.model,
@@ -407,6 +566,7 @@ async function serveRewrite(deps, body, req, res) {
407
566
  fidelity,
408
567
  intensity,
409
568
  elapsedMs: result.elapsedMs,
569
+ facts,
410
570
  factCheck,
411
571
  },
412
572
  })
@@ -43,3 +43,13 @@ export const CARD_DIFF_ENDPOINT = `${PREFIX}/cards/diff`
43
43
 
44
44
  /** Open (creating if needed) the user card directory in the OS file manager. */
45
45
  export const REVEAL_ENDPOINT = `${PREFIX}/reveal`
46
+
47
+ /**
48
+ * Turn a rough character idea into a filled-in card, using the session's model.
49
+ *
50
+ * The result is only ever a PREFILL for the card form: it is returned to the
51
+ * browser and never written to disk by this route. The user reviews it, edits
52
+ * it if they want, and saves it themselves through the existing save route —
53
+ * so a bad generation costs a reload, not a corrupted card catalog.
54
+ */
55
+ export const CARD_DRAFT_ENDPOINT = `${PREFIX}/cards/draft`