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/CHANGELOG.md +87 -2
- package/README.en.md +515 -0
- package/README.md +201 -248
- package/cards/muscle-crew.yml +3 -3
- package/lib/client.js +409 -13
- package/lib/index.js +16 -3
- package/lib/preserve.js +138 -0
- package/lib/prompts.js +164 -3
- package/lib/routes.js +169 -9
- package/lib/shared/protocol.js +10 -0
- package/lib/store.js +24 -2
- package/package.json +3 -3
- package/README.zh-CN.md +0 -297
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/preserve.js
ADDED
|
@@ -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[
|
|
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
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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 (
|
|
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
|
-
//
|
|
385
|
-
//
|
|
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 (
|
|
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
|
})
|
package/lib/shared/protocol.js
CHANGED
|
@@ -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`
|