@husk-ai/sessions 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat.d.ts +11 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +2 -0
- package/dist/chat.js.map +1 -0
- package/dist/distill.d.ts +130 -0
- package/dist/distill.d.ts.map +1 -0
- package/dist/distill.js +773 -0
- package/dist/distill.js.map +1 -0
- package/dist/distiller.d.ts +18 -0
- package/dist/distiller.d.ts.map +1 -0
- package/dist/distiller.js +23 -0
- package/dist/distiller.js.map +1 -0
- package/dist/formatter.d.ts +8 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +11 -0
- package/dist/formatter.js.map +1 -0
- package/dist/importers/chatgpt.d.ts +12 -0
- package/dist/importers/chatgpt.d.ts.map +1 -0
- package/dist/importers/chatgpt.js +223 -0
- package/dist/importers/chatgpt.js.map +1 -0
- package/dist/importers/claude.d.ts +37 -0
- package/dist/importers/claude.d.ts.map +1 -0
- package/dist/importers/claude.js +512 -0
- package/dist/importers/claude.js.map +1 -0
- package/dist/importers/cursor.d.ts +12 -0
- package/dist/importers/cursor.d.ts.map +1 -0
- package/dist/importers/cursor.js +229 -0
- package/dist/importers/cursor.js.map +1 -0
- package/dist/importers/fsutil.d.ts +22 -0
- package/dist/importers/fsutil.d.ts.map +1 -0
- package/dist/importers/fsutil.js +79 -0
- package/dist/importers/fsutil.js.map +1 -0
- package/dist/importers/gemini.d.ts +13 -0
- package/dist/importers/gemini.d.ts.map +1 -0
- package/dist/importers/gemini.js +228 -0
- package/dist/importers/gemini.js.map +1 -0
- package/dist/importers/index.d.ts +70 -0
- package/dist/importers/index.d.ts.map +1 -0
- package/dist/importers/index.js +258 -0
- package/dist/importers/index.js.map +1 -0
- package/dist/importers/markdown.d.ts +14 -0
- package/dist/importers/markdown.d.ts.map +1 -0
- package/dist/importers/markdown.js +188 -0
- package/dist/importers/markdown.js.map +1 -0
- package/dist/importers/universal.d.ts +16 -0
- package/dist/importers/universal.d.ts.map +1 -0
- package/dist/importers/universal.js +106 -0
- package/dist/importers/universal.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +151 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +109 -0
- package/dist/prompts.js.map +1 -0
- package/dist/redactor.d.ts +38 -0
- package/dist/redactor.d.ts.map +1 -0
- package/dist/redactor.js +144 -0
- package/dist/redactor.js.map +1 -0
- package/dist/scaffold.d.ts +40 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +0 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/serialize.d.ts +15 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +122 -0
- package/dist/serialize.js.map +1 -0
- package/package.json +28 -0
package/dist/distill.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { clampText, estimateTokens, mapLimit, parseSpec, slug, deepMerge, } from '@husk-ai/core';
|
|
2
|
+
import { CandidateSchema, MergedSchema, extractSystemPrompt, extractUserPrompt, mergeSystemPrompt, mergeUserPrompt, renderWindow, } from './prompts.js';
|
|
3
|
+
import { redactDistilled } from './redactor.js';
|
|
4
|
+
import { containsHarnessArtifact, isScaffoldingOnly, looksLikeStatusReport, markupRatio, stripScaffolding, } from './scaffold.js';
|
|
5
|
+
/**
|
|
6
|
+
* Two distillers, on purpose.
|
|
7
|
+
*
|
|
8
|
+
* `distillHeuristic` is the free path. It has to produce a bot somebody would
|
|
9
|
+
* actually use, because most people will never set an API key. `distillWithModel`
|
|
10
|
+
* is better when a model is reachable, and falls back to the free path rather
|
|
11
|
+
* than failing.
|
|
12
|
+
*/
|
|
13
|
+
// -- shared mining helpers ---------------------------------------------------
|
|
14
|
+
/** Claude Code and friends inject synthetic user turns. They are not the user. */
|
|
15
|
+
const SYNTHETIC_USER = [
|
|
16
|
+
/^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|user-prompt-submit-hook|system-reminder|task-notification|ci-monitor-event)>/i,
|
|
17
|
+
/^\[SYSTEM NOTIFICATION\b/i,
|
|
18
|
+
/^Caveat: The messages below were generated by the user while running/i,
|
|
19
|
+
/^\[Request interrupted/i,
|
|
20
|
+
/^\[Image: original \d+x\d+/i,
|
|
21
|
+
/^\[image: /i,
|
|
22
|
+
/^Base directory for this skill:/i,
|
|
23
|
+
/^This session is being continued from a previous conversation/i,
|
|
24
|
+
/^Please continue the conversation from where we left it off/i,
|
|
25
|
+
];
|
|
26
|
+
const CORRECTION = [
|
|
27
|
+
/^\s*(no+[,.! ]|nope\b|wrong\b|incorrect\b|stop\b|undo\b|revert\b|redo\b)/i,
|
|
28
|
+
/^\s*(that'?s (not|wrong|incorrect)|not what i|try again|fix (that|it|this)|you (didn'?t|did not|missed|broke|forgot))/i,
|
|
29
|
+
/\b(you were supposed to|i (already )?said|i asked you to|that'?s not what i|still (broken|failing|wrong)|doesn'?t work)\b/i,
|
|
30
|
+
];
|
|
31
|
+
export const DIRECTIVES = [
|
|
32
|
+
// Deliberately anchored. A bare "you're" in the middle of a sentence is not a
|
|
33
|
+
// role definition, and treating it as one produces a nonsense persona opener.
|
|
34
|
+
{
|
|
35
|
+
re: /^(?:you are|you're)\s+(?:a|an|the|my|our)\b|^(?:act|behave|work) as\b|\byour role is\b|^you (?:are|act) (?:now )?(?:a|an|the)\b/i,
|
|
36
|
+
weight: 4,
|
|
37
|
+
kind: 'identity',
|
|
38
|
+
},
|
|
39
|
+
// People rarely write "you are a senior engineer". They write "think like a
|
|
40
|
+
// CTO", "approach this as a reviewer", "wear your PM hat". Missing these was
|
|
41
|
+
// the difference between a persona and a topic sentence on real transcripts.
|
|
42
|
+
{
|
|
43
|
+
re: /^(?:think|reason|respond|behave|approach\s+\w+)\s+(?:like|as)\s+(?:a|an|the)?\b|^(?:you|please)?\s*(?:think|act)\s+like\s+/i,
|
|
44
|
+
weight: 4,
|
|
45
|
+
kind: 'identity',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
re: /\b(?:wear(?:ing)?|put on)\s+(?:your|the|a|an)\s+[\w\s-]{0,24}\bhat\b|\bas (?:if you were|though you were)\b/i,
|
|
49
|
+
weight: 3,
|
|
50
|
+
kind: 'identity',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
re: /^(?:be|become)\s+(?:a|an|the|my|our)\s+\w+|^pretend (?:to be|you)\b|^imagine (?:you are|you're)\b|^assume the role\b/i,
|
|
54
|
+
weight: 3,
|
|
55
|
+
kind: 'identity',
|
|
56
|
+
},
|
|
57
|
+
{ re: /\b(from now on|going forward|every time|whenever|each time)\b/i, weight: 3, kind: 'rule' },
|
|
58
|
+
{ re: /\balways\b/i, weight: 3, kind: 'rule' },
|
|
59
|
+
{ re: /\bnever\b/i, weight: 3, kind: 'rule' },
|
|
60
|
+
{ re: /\b(do not|don'?t|avoid|refuse to|under no circumstances)\b/i, weight: 2, kind: 'rule' },
|
|
61
|
+
{ re: /\b(make sure|ensure|be sure to|you must|you should|remember to)\b/i, weight: 2, kind: 'rule' },
|
|
62
|
+
{ re: /\b(use|prefer|stick to|default to|rely on)\b/i, weight: 1, kind: 'preference' },
|
|
63
|
+
{ re: /\b(keep it|be)\s+(brief|concise|short|terse|detailed|thorough|specific|blunt|honest)\b/i, weight: 2, kind: 'style' },
|
|
64
|
+
{ re: /\b(respond|reply|answer|write|output|format)\b.{0,30}\b(in|as|with|using)\b/i, weight: 1, kind: 'style' },
|
|
65
|
+
];
|
|
66
|
+
const TOOL_BUNDLES = [
|
|
67
|
+
{ bundle: 'computer', re: /^(bash|powershell|shell|sh|zsh|terminal|cmd|exec|execute|run|command|bashoutput|killshell|killbash|computer|container)/i },
|
|
68
|
+
{ bundle: 'files', re: /^(read|write|edit|multiedit|str_replace|create_file|notebook(read|edit)?|glob|grep|ls|listdir|list_files|search_files|file)/i },
|
|
69
|
+
{ bundle: 'web', re: /^(webfetch|websearch|web_search|browse|browser|fetch|navigate|http|curl|url|search)/i },
|
|
70
|
+
];
|
|
71
|
+
/** Orchestration plumbing. Real in the transcript, meaningless in a husk.yaml. */
|
|
72
|
+
const IGNORED_TOOLS = /^(task|todowrite|todoread|exitplanmode|enterplanmode|sendmessage|taskoutput|taskstop|toolsearch|skill|monitor|askuserquestion|thinking)$/i;
|
|
73
|
+
function isSynthetic(text) {
|
|
74
|
+
const head = text.trimStart();
|
|
75
|
+
if (SYNTHETIC_USER.some((re) => re.test(head)))
|
|
76
|
+
return true;
|
|
77
|
+
// A turn that is nothing but a harness block is plumbing whatever role it
|
|
78
|
+
// was filed under. The importer normally removes these; a transcript
|
|
79
|
+
// imported before that fix, or by another importer, still gets screened.
|
|
80
|
+
return isScaffoldingOnly(head);
|
|
81
|
+
}
|
|
82
|
+
function isCorrection(text) {
|
|
83
|
+
const head = text.trimStart().slice(0, 400);
|
|
84
|
+
return CORRECTION.some((re) => re.test(head));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Real user turns: what a person typed, with the plumbing removed.
|
|
88
|
+
*
|
|
89
|
+
* When the importer recorded `promptSource` -- Claude Code does -- that field is
|
|
90
|
+
* authoritative and the text heuristics are skipped. It is the difference
|
|
91
|
+
* between a prompt the user wrote and a skill document the harness pasted in
|
|
92
|
+
* wearing the user's role.
|
|
93
|
+
*/
|
|
94
|
+
export function humanTurns(messages) {
|
|
95
|
+
const candidates = messages
|
|
96
|
+
.filter((m) => m.role === 'user' && !m.toolName && m.content.trim().length > 0)
|
|
97
|
+
// Attribution decides *which* turns to consider; it never licenses a turn
|
|
98
|
+
// that is plainly machine output. An importer that mis-attributes one
|
|
99
|
+
// harness block should cost us that block, not the whole screen.
|
|
100
|
+
.filter((m) => !isSynthetic(m.content));
|
|
101
|
+
const attributed = candidates.filter((m) => typeof m.meta?.promptSource === 'string');
|
|
102
|
+
return attributed.length ? attributed : candidates;
|
|
103
|
+
}
|
|
104
|
+
function assistantSpeech(messages) {
|
|
105
|
+
return messages.filter((m) => m.role === 'assistant' && !m.toolName && m.content.trim().length > 0);
|
|
106
|
+
}
|
|
107
|
+
/** Sentence-ish units, with markdown chrome removed and fragments discarded. */
|
|
108
|
+
function sentences(text) {
|
|
109
|
+
const withoutCode = text.replace(/```[\s\S]*?```/g, ' ');
|
|
110
|
+
return withoutCode
|
|
111
|
+
.split(/\n{2,}|(?<=[.!?])\s+(?=[A-Z"'`\-*\d])|\n(?=[-*•]\s)|\n/g)
|
|
112
|
+
.map((s) => s
|
|
113
|
+
.replace(/^[-*•]\s+/, '')
|
|
114
|
+
.replace(/^\d+[.)]\s+/, '')
|
|
115
|
+
.replace(/^#+\s*/, '')
|
|
116
|
+
.replace(/\*\*|__|`/g, '')
|
|
117
|
+
.replace(/\s+/g, ' ')
|
|
118
|
+
.trim())
|
|
119
|
+
.filter((s) => s.length >= 12 && s.length <= 300)
|
|
120
|
+
// A unit that starts lower-case and does not open with a directive verb is
|
|
121
|
+
// the tail of a wrapped line, not an instruction.
|
|
122
|
+
.filter((s) => !/^[a-z]/.test(s) || /^(always|never|don'?t|do not|avoid|use|prefer|make sure|ensure|keep|you|only|stick)\b/i.test(s));
|
|
123
|
+
}
|
|
124
|
+
function normaliseKey(s) {
|
|
125
|
+
return s
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^a-z ]+/g, ' ')
|
|
128
|
+
.replace(/\s+/g, ' ')
|
|
129
|
+
.trim()
|
|
130
|
+
.slice(0, 120);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Standing instructions, ranked.
|
|
134
|
+
*
|
|
135
|
+
* Recurrence is the signal that matters: a sentence the user typed once might be
|
|
136
|
+
* about today's task, and one they typed four times is how they want to be
|
|
137
|
+
* worked with.
|
|
138
|
+
*/
|
|
139
|
+
export function mineRules(messages, limit = 12) {
|
|
140
|
+
const seen = new Map();
|
|
141
|
+
for (const m of humanTurns(messages)) {
|
|
142
|
+
if (isCorrection(m.content))
|
|
143
|
+
continue;
|
|
144
|
+
for (const raw of sentences(m.content)) {
|
|
145
|
+
if (/^https?:\/\//i.test(raw) || /^[`{[]/.test(raw))
|
|
146
|
+
continue;
|
|
147
|
+
// A sentence carrying a tool-use id or a temp path is describing one run.
|
|
148
|
+
if (containsHarnessArtifact(raw))
|
|
149
|
+
continue;
|
|
150
|
+
let weight = 0;
|
|
151
|
+
let kind = 'rule';
|
|
152
|
+
for (const d of DIRECTIVES) {
|
|
153
|
+
if (!d.re.test(raw))
|
|
154
|
+
continue;
|
|
155
|
+
if (d.weight > weight) {
|
|
156
|
+
weight = d.weight;
|
|
157
|
+
kind = d.kind;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (weight < 2)
|
|
161
|
+
continue;
|
|
162
|
+
const key = normaliseKey(raw);
|
|
163
|
+
if (!key)
|
|
164
|
+
continue;
|
|
165
|
+
const existing = seen.get(key);
|
|
166
|
+
if (existing) {
|
|
167
|
+
existing.occurrences++;
|
|
168
|
+
if (raw.length < existing.text.length)
|
|
169
|
+
existing.text = raw;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
seen.set(key, { text: raw.replace(/\s+/g, ' ').trim(), kind, occurrences: 1, score: weight });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...seen.values()]
|
|
177
|
+
.map((r) => ({ ...r, score: r.score * (1 + Math.log2(r.occurrences)) }))
|
|
178
|
+
.sort((a, b) => b.score - a.score || a.text.length - b.text.length)
|
|
179
|
+
.slice(0, limit);
|
|
180
|
+
}
|
|
181
|
+
/** Clip on a word boundary. A title cut mid-word reads like a bug. */
|
|
182
|
+
function trimTitle(s, max) {
|
|
183
|
+
const t = s.trim();
|
|
184
|
+
if (t.length <= max)
|
|
185
|
+
return t;
|
|
186
|
+
const cut = t.slice(0, max);
|
|
187
|
+
const space = cut.lastIndexOf(' ');
|
|
188
|
+
return (space > max * 0.5 ? cut.slice(0, space) : cut).trimEnd() + '...';
|
|
189
|
+
}
|
|
190
|
+
const FACT_SIGNALS = [
|
|
191
|
+
/```/,
|
|
192
|
+
/https?:\/\//,
|
|
193
|
+
/(^|\s)[A-Za-z]:[\\/]|(^|\s)\/[a-z]+\//,
|
|
194
|
+
/^\s*[-*]\s+\S+/m,
|
|
195
|
+
/^\s*\w[\w .-]{0,40}:\s+\S+/m,
|
|
196
|
+
];
|
|
197
|
+
/**
|
|
198
|
+
* Longest raw candidate that can still be a fact.
|
|
199
|
+
*
|
|
200
|
+
* Above this it is a dump, and clamping it only produces a head, a tail and
|
|
201
|
+
* "[N bytes elided]" in the middle -- a shape that teaches a model nothing and
|
|
202
|
+
* costs a fortune in every request. Several short reusable facts beat one 5 KB
|
|
203
|
+
* blob, so the blob is refused rather than mutilated.
|
|
204
|
+
*/
|
|
205
|
+
const KNOWLEDGE_HARD_MAX = 3000;
|
|
206
|
+
/** What survives the floor is still clamped, so one item cannot dominate. */
|
|
207
|
+
const KNOWLEDGE_CLAMP = 1200;
|
|
208
|
+
/** Above this share of angle-bracket markup outside code fences, it is a dump. */
|
|
209
|
+
const KNOWLEDGE_MAX_MARKUP = 0.15;
|
|
210
|
+
/**
|
|
211
|
+
* The quality floor for a knowledge item.
|
|
212
|
+
*
|
|
213
|
+
* Everything here is a way of asking one question: would this still be true,
|
|
214
|
+
* and still be useful, in a conversation that happens next week? A tool-use id
|
|
215
|
+
* would not. A temp path would not. "Agent X finished" would not.
|
|
216
|
+
*/
|
|
217
|
+
export function isUsableKnowledge(title, content) {
|
|
218
|
+
if (content.length > KNOWLEDGE_HARD_MAX)
|
|
219
|
+
return false;
|
|
220
|
+
if (containsHarnessArtifact(title) || containsHarnessArtifact(content))
|
|
221
|
+
return false;
|
|
222
|
+
// Anything the scaffolding stripper still wants to cut is not a fact.
|
|
223
|
+
if (stripScaffolding(content).length !== content.length)
|
|
224
|
+
return false;
|
|
225
|
+
const cleanTitle = title.trim();
|
|
226
|
+
if (cleanTitle.length < 8)
|
|
227
|
+
return false;
|
|
228
|
+
// A title that is only a tag ("<task-notification>") names nothing.
|
|
229
|
+
if (/^<[^>]*>$/.test(cleanTitle))
|
|
230
|
+
return false;
|
|
231
|
+
if (markupRatio(content) > KNOWLEDGE_MAX_MARKUP)
|
|
232
|
+
return false;
|
|
233
|
+
if (looksLikeStatusReport(content))
|
|
234
|
+
return false;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
export function mineKnowledge(messages, limit = 6) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (const m of humanTurns(messages)) {
|
|
241
|
+
const text = m.content.trim();
|
|
242
|
+
if (text.length < 180)
|
|
243
|
+
continue;
|
|
244
|
+
if (isCorrection(text))
|
|
245
|
+
continue;
|
|
246
|
+
if (text.endsWith('?') && text.length < 400)
|
|
247
|
+
continue;
|
|
248
|
+
const signals = FACT_SIGNALS.filter((re) => re.test(text)).length;
|
|
249
|
+
if (signals === 0)
|
|
250
|
+
continue;
|
|
251
|
+
const firstLine = (text.split('\n').find((l) => l.trim().length > 3) ?? text).trim();
|
|
252
|
+
const title = trimTitle(firstLine.replace(/^#+\s*/, '').replace(/[.:]$/, ''), 72);
|
|
253
|
+
if (!isUsableKnowledge(title, text))
|
|
254
|
+
continue;
|
|
255
|
+
const key = normaliseKey(title);
|
|
256
|
+
if (!key || seen.has(key))
|
|
257
|
+
continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
out.push({ title, content: clampText(text, KNOWLEDGE_CLAMP).text, weight: signals * Math.log2(text.length) });
|
|
260
|
+
}
|
|
261
|
+
return out
|
|
262
|
+
.sort((a, b) => b.weight - a.weight)
|
|
263
|
+
.slice(0, limit)
|
|
264
|
+
.map(({ title, content }) => ({ title, content }));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Exchanges worth imitating.
|
|
268
|
+
*
|
|
269
|
+
* A turn the user immediately corrected is a counter-example, so it is excluded
|
|
270
|
+
* rather than held up as how the bot should behave.
|
|
271
|
+
*/
|
|
272
|
+
export function mineExamples(messages, limit = 5) {
|
|
273
|
+
const humans = humanTurns(messages);
|
|
274
|
+
const indexOfHuman = new Map();
|
|
275
|
+
humans.forEach((m, i) => indexOfHuman.set(m, i));
|
|
276
|
+
const candidates = [];
|
|
277
|
+
for (let i = 0; i < messages.length; i++) {
|
|
278
|
+
const u = messages[i];
|
|
279
|
+
if (!u || !indexOfHuman.has(u))
|
|
280
|
+
continue;
|
|
281
|
+
const humanIdx = indexOfHuman.get(u);
|
|
282
|
+
// The next thing the user typed. If it is a correction, this pair is a bad example.
|
|
283
|
+
const next = humans[humanIdx + 1];
|
|
284
|
+
if (next && isCorrection(next.content))
|
|
285
|
+
continue;
|
|
286
|
+
let reply;
|
|
287
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
288
|
+
const m = messages[j];
|
|
289
|
+
if (!m)
|
|
290
|
+
continue;
|
|
291
|
+
if (indexOfHuman.has(m))
|
|
292
|
+
break;
|
|
293
|
+
if (m.role === 'assistant' && !m.toolName && m.content.trim().length >= 40) {
|
|
294
|
+
reply = m;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!reply)
|
|
299
|
+
continue;
|
|
300
|
+
const userText = u.content.trim();
|
|
301
|
+
const replyText = reply.content.trim();
|
|
302
|
+
if (userText.length < 15 || userText.length > 1200)
|
|
303
|
+
continue;
|
|
304
|
+
if (replyText.length > 2000)
|
|
305
|
+
continue;
|
|
306
|
+
// A few-shot exemplar is a model of how to talk to this bot. A system
|
|
307
|
+
// notification standing in for the user teaches exactly the wrong thing,
|
|
308
|
+
// and an answer full of tool-use ids teaches the wrong voice back.
|
|
309
|
+
if (isSynthetic(userText) || containsHarnessArtifact(userText))
|
|
310
|
+
continue;
|
|
311
|
+
if (containsHarnessArtifact(replyText) || looksLikeStatusReport(replyText))
|
|
312
|
+
continue;
|
|
313
|
+
candidates.push({
|
|
314
|
+
user: userText,
|
|
315
|
+
assistant: replyText,
|
|
316
|
+
weight: Math.min(replyText.length, 800) + (next ? 0 : 120),
|
|
317
|
+
at: i,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
// Two exemplars with the same prompt teach one lesson and cost two. People
|
|
321
|
+
// repeat themselves most when a session stalls ("continue where you left
|
|
322
|
+
// off"), so the duplicates cluster on the least interesting turns.
|
|
323
|
+
const bestByPrompt = new Map();
|
|
324
|
+
for (const c of candidates) {
|
|
325
|
+
// Whole-text key, not `normaliseKey`: that one truncates at 120 chars and
|
|
326
|
+
// would collide two long prompts that merely share an opening.
|
|
327
|
+
const key = c.user.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
328
|
+
const held = bestByPrompt.get(key);
|
|
329
|
+
if (!held || c.weight > held.weight)
|
|
330
|
+
bestByPrompt.set(key, c);
|
|
331
|
+
}
|
|
332
|
+
const unique = [...bestByPrompt.values()];
|
|
333
|
+
// Spread the picks across the conversation rather than taking the first five.
|
|
334
|
+
const sorted = unique.sort((a, b) => b.weight - a.weight).slice(0, limit * 4);
|
|
335
|
+
sorted.sort((a, b) => a.at - b.at);
|
|
336
|
+
if (sorted.length <= limit)
|
|
337
|
+
return sorted.map(({ user, assistant }) => ({ user, assistant }));
|
|
338
|
+
const step = sorted.length / limit;
|
|
339
|
+
const picked = [];
|
|
340
|
+
for (let i = 0; i < limit; i++) {
|
|
341
|
+
const item = sorted[Math.floor(i * step)];
|
|
342
|
+
if (item)
|
|
343
|
+
picked.push({ user: item.user, assistant: item.assistant });
|
|
344
|
+
}
|
|
345
|
+
return picked;
|
|
346
|
+
}
|
|
347
|
+
export function observedTools(transcript) {
|
|
348
|
+
const raw = new Set();
|
|
349
|
+
for (const m of transcript.messages)
|
|
350
|
+
if (m.toolName)
|
|
351
|
+
raw.add(m.toolName);
|
|
352
|
+
const meta = transcript.meta ?? {};
|
|
353
|
+
for (const key of ['tools', 'sidechainTools']) {
|
|
354
|
+
const v = meta[key];
|
|
355
|
+
if (Array.isArray(v))
|
|
356
|
+
for (const n of v)
|
|
357
|
+
if (typeof n === 'string')
|
|
358
|
+
raw.add(n);
|
|
359
|
+
}
|
|
360
|
+
const bundles = new Set();
|
|
361
|
+
const passthrough = new Set();
|
|
362
|
+
for (const name of raw) {
|
|
363
|
+
if (IGNORED_TOOLS.test(name))
|
|
364
|
+
continue;
|
|
365
|
+
const hit = TOOL_BUNDLES.find((b) => b.re.test(name));
|
|
366
|
+
if (hit)
|
|
367
|
+
bundles.add(hit.bundle);
|
|
368
|
+
else
|
|
369
|
+
passthrough.add(name);
|
|
370
|
+
}
|
|
371
|
+
const order = ['computer', 'files', 'web'];
|
|
372
|
+
const suggested = [
|
|
373
|
+
...order.filter((b) => bundles.has(b)),
|
|
374
|
+
...[...passthrough].sort().slice(0, 10),
|
|
375
|
+
];
|
|
376
|
+
return {
|
|
377
|
+
raw: [...raw].sort(),
|
|
378
|
+
bundles: [...bundles],
|
|
379
|
+
suggested,
|
|
380
|
+
needsComputer: bundles.has('computer'),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// -- the free path -----------------------------------------------------------
|
|
384
|
+
/**
|
|
385
|
+
* Reject mined fragments that are not instructions.
|
|
386
|
+
*
|
|
387
|
+
* Markdown headings ("Humanizer rules:") and dangling references ("Same as
|
|
388
|
+
* always:") survive the mining pass because they are shaped like directives.
|
|
389
|
+
* In a persona they read as noise and teach the model nothing, so a rule has to
|
|
390
|
+
* be a whole sentence that stands on its own.
|
|
391
|
+
*/
|
|
392
|
+
function isUsableRule(text) {
|
|
393
|
+
if (text.length < 12)
|
|
394
|
+
return false;
|
|
395
|
+
if (text.endsWith(':'))
|
|
396
|
+
return false;
|
|
397
|
+
if (text.split(/\s+/).length < 4)
|
|
398
|
+
return false;
|
|
399
|
+
// Nothing that names a tool-use id, a temp path or a finished run belongs in
|
|
400
|
+
// a system prompt. This is the last gate before the persona.
|
|
401
|
+
if (containsHarnessArtifact(text))
|
|
402
|
+
return false;
|
|
403
|
+
if (looksLikeStatusReport(text))
|
|
404
|
+
return false;
|
|
405
|
+
if (/^<[a-z][a-z0-9-]*>/i.test(text))
|
|
406
|
+
return false;
|
|
407
|
+
// A fragment that only points elsewhere carries no instruction with it.
|
|
408
|
+
if (/^(same as (always|above|before)|see (above|below)|as (above|discussed))/i.test(text))
|
|
409
|
+
return false;
|
|
410
|
+
if (/^(part \d|step \d|#{2,}\s)/i.test(text))
|
|
411
|
+
return false;
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
function buildPersona(identity, rules, tools) {
|
|
415
|
+
const lines = [identity];
|
|
416
|
+
const ruleLines = rules
|
|
417
|
+
.filter((r) => r.kind !== 'identity')
|
|
418
|
+
.map((r) => r.text.trim())
|
|
419
|
+
.filter(isUsableRule)
|
|
420
|
+
.map((text) => `- ${text}`);
|
|
421
|
+
if (ruleLines.length)
|
|
422
|
+
lines.push('', 'How you work:', ...ruleLines);
|
|
423
|
+
if (tools.needsComputer) {
|
|
424
|
+
lines.push('', 'You have a Linux computer. Run commands to check things rather than guessing,', 'and show the command you ran when the result matters.');
|
|
425
|
+
}
|
|
426
|
+
return lines.join('\n').trim();
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* No model, no network, no key. Mines the transcript for standing instructions,
|
|
430
|
+
* durable facts, uncorrected exchanges and observed tools.
|
|
431
|
+
*/
|
|
432
|
+
export function distillHeuristic(transcript, opts = {}) {
|
|
433
|
+
const messages = transcript.messages;
|
|
434
|
+
const rules = mineRules(messages, opts.maxRules ?? 10);
|
|
435
|
+
const knowledge = mineKnowledge(messages, opts.maxKnowledge ?? 6);
|
|
436
|
+
const examples = mineExamples(messages, opts.maxExamples ?? 5);
|
|
437
|
+
const tools = observedTools(transcript);
|
|
438
|
+
const humans = humanTurns(messages);
|
|
439
|
+
const speech = assistantSpeech(messages);
|
|
440
|
+
const identityRule = rules.find((r) => r.kind === 'identity');
|
|
441
|
+
const subject = transcript.title?.replace(/[.\s]+$/, '') ??
|
|
442
|
+
humans[0]?.content.split('\n')[0]?.slice(0, 60) ??
|
|
443
|
+
'this conversation';
|
|
444
|
+
const identity = identityRule
|
|
445
|
+
? `${identityRule.text.replace(/^\s*(so|ok|okay)[,\s]+/i, '').trim()}`
|
|
446
|
+
: `You are an assistant distilled from a real working session about ${subject}.`;
|
|
447
|
+
const name = slug(transcript.title ?? `${transcript.source}-bot`, 48) || 'husk-bot';
|
|
448
|
+
const description = clampText(transcript.title ?? `Distilled from a ${transcript.source} transcript with ${messages.length} messages.`, 240).text;
|
|
449
|
+
const notes = [];
|
|
450
|
+
if (!identityRule)
|
|
451
|
+
notes.push('No role instruction found -- nothing matching "you are a...", "act as", or "think like a..." -- so the persona opens with the topic instead.');
|
|
452
|
+
if (!rules.length)
|
|
453
|
+
notes.push('No recurring standing instructions found. The persona is topic-only.');
|
|
454
|
+
if (!knowledge.length)
|
|
455
|
+
notes.push('No durable user-supplied facts found worth carrying as knowledge.');
|
|
456
|
+
if (!examples.length)
|
|
457
|
+
notes.push('No uncorrected user/assistant exchange was clean enough to use as an example.');
|
|
458
|
+
if (!tools.suggested.length)
|
|
459
|
+
notes.push('No tool use observed; the bot is configured without tools.');
|
|
460
|
+
if (tools.raw.some((t) => IGNORED_TOOLS.test(t)))
|
|
461
|
+
notes.push('Orchestration tools (Task, TodoWrite, ...) were observed but are not portable to a husk.');
|
|
462
|
+
notes.push('Heuristic distillation: no model was used. Review the persona before shipping.');
|
|
463
|
+
// Capped below 1 on purpose. Pattern-matching sentences is a decent first
|
|
464
|
+
// pass, not a reading of the conversation, and the number should say so.
|
|
465
|
+
const HEURISTIC_CEILING = 0.75;
|
|
466
|
+
const confidence = Math.round(100 *
|
|
467
|
+
HEURISTIC_CEILING *
|
|
468
|
+
Math.min(1, (Math.min(rules.length, 6) / 6) * 0.35 +
|
|
469
|
+
(Math.min(examples.length, 4) / 4) * 0.3 +
|
|
470
|
+
(Math.min(knowledge.length, 3) / 3) * 0.15 +
|
|
471
|
+
(tools.suggested.length ? 0.1 : 0) +
|
|
472
|
+
(Math.min(speech.length, 20) / 20) * 0.1)) / 100;
|
|
473
|
+
const agent = {
|
|
474
|
+
name,
|
|
475
|
+
description,
|
|
476
|
+
persona: buildPersona(identity, rules, tools),
|
|
477
|
+
knowledge,
|
|
478
|
+
examples,
|
|
479
|
+
suggestedTools: tools.suggested,
|
|
480
|
+
needsComputer: tools.needsComputer,
|
|
481
|
+
confidence,
|
|
482
|
+
notes,
|
|
483
|
+
};
|
|
484
|
+
const observedModel = typeof transcript.meta?.model === 'string' ? transcript.meta.model : undefined;
|
|
485
|
+
const alias = observedModel ? modelAlias(observedModel) : undefined;
|
|
486
|
+
if (alias)
|
|
487
|
+
agent.suggestedModel = alias;
|
|
488
|
+
return opts.redact === false ? agent : redactDistilled(agent).agent;
|
|
489
|
+
}
|
|
490
|
+
/** Map an observed provider model id onto a Husk alias. Unknown ids stay unset. */
|
|
491
|
+
export function modelAlias(model) {
|
|
492
|
+
const m = model.toLowerCase();
|
|
493
|
+
if (m.includes('opus'))
|
|
494
|
+
return 'opus';
|
|
495
|
+
if (m.includes('sonnet'))
|
|
496
|
+
return 'sonnet';
|
|
497
|
+
if (m.includes('haiku'))
|
|
498
|
+
return 'haiku';
|
|
499
|
+
if (m.includes('gpt-4') || m.includes('gpt-5') || m.startsWith('o1') || m.startsWith('o3'))
|
|
500
|
+
return 'gpt';
|
|
501
|
+
if (m.includes('flash'))
|
|
502
|
+
return 'flash';
|
|
503
|
+
if (m.includes('gemini'))
|
|
504
|
+
return 'gemini';
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Split the whole transcript into windows.
|
|
509
|
+
*
|
|
510
|
+
* Every message lands in exactly one window plus an overlap. Nothing is dropped:
|
|
511
|
+
* truncating a 500-message transcript to its first 50 throws away the part where
|
|
512
|
+
* the user finally explained what they wanted.
|
|
513
|
+
*/
|
|
514
|
+
export function windowMessages(messages, windowTokens, overlap) {
|
|
515
|
+
const windows = [];
|
|
516
|
+
let current = [];
|
|
517
|
+
let tokens = 0;
|
|
518
|
+
for (const m of messages) {
|
|
519
|
+
const cost = estimateTokens(m.content) + 8;
|
|
520
|
+
if (current.length && tokens + cost > windowTokens) {
|
|
521
|
+
windows.push(current);
|
|
522
|
+
current = overlap > 0 ? current.slice(-overlap) : [];
|
|
523
|
+
tokens = current.reduce((n, x) => n + estimateTokens(x.content) + 8, 0);
|
|
524
|
+
}
|
|
525
|
+
current.push(m);
|
|
526
|
+
tokens += cost;
|
|
527
|
+
}
|
|
528
|
+
if (current.length)
|
|
529
|
+
windows.push(current);
|
|
530
|
+
return windows.length ? windows : [[]];
|
|
531
|
+
}
|
|
532
|
+
function stripFence(s) {
|
|
533
|
+
const t = s.trim();
|
|
534
|
+
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/.exec(t);
|
|
535
|
+
const body = (fenced?.[1] ?? t).trim();
|
|
536
|
+
// Some models prepend a sentence. Take the outermost JSON object.
|
|
537
|
+
const first = body.indexOf('{');
|
|
538
|
+
const last = body.lastIndexOf('}');
|
|
539
|
+
return first >= 0 && last > first ? body.slice(first, last + 1) : body;
|
|
540
|
+
}
|
|
541
|
+
function unionRules(candidates) {
|
|
542
|
+
const seen = new Map();
|
|
543
|
+
for (const c of candidates) {
|
|
544
|
+
for (const raw of c.personaRules) {
|
|
545
|
+
const text = raw.trim();
|
|
546
|
+
if (text.length < 8)
|
|
547
|
+
continue;
|
|
548
|
+
const key = normaliseKey(text);
|
|
549
|
+
const hit = seen.get(key);
|
|
550
|
+
if (hit)
|
|
551
|
+
hit.occurrences++;
|
|
552
|
+
else
|
|
553
|
+
seen.set(key, { text, kind: 'rule', occurrences: 1, score: 1 });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return [...seen.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, 12);
|
|
557
|
+
}
|
|
558
|
+
/** The same floor the heuristic path applies, for exemplars a model proposed. */
|
|
559
|
+
function isCleanExample(e) {
|
|
560
|
+
if (isSynthetic(e.user) || containsHarnessArtifact(e.user))
|
|
561
|
+
return false;
|
|
562
|
+
return !containsHarnessArtifact(e.assistant) && !looksLikeStatusReport(e.assistant);
|
|
563
|
+
}
|
|
564
|
+
/** Deterministic merge, used when the reduce call fails or returns junk. */
|
|
565
|
+
function mergeLocally(candidates, fallback, tools) {
|
|
566
|
+
const rules = unionRules(candidates);
|
|
567
|
+
if (!rules.length)
|
|
568
|
+
return fallback;
|
|
569
|
+
const knowledge = [];
|
|
570
|
+
const seenK = new Set();
|
|
571
|
+
for (const c of candidates)
|
|
572
|
+
for (const k of c.knowledge) {
|
|
573
|
+
const key = normaliseKey(k.title);
|
|
574
|
+
if (!key || seenK.has(key))
|
|
575
|
+
continue;
|
|
576
|
+
if (!isUsableKnowledge(k.title, k.content))
|
|
577
|
+
continue;
|
|
578
|
+
seenK.add(key);
|
|
579
|
+
knowledge.push({ title: k.title, content: k.content });
|
|
580
|
+
if (knowledge.length >= 8)
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
const examples = [];
|
|
584
|
+
for (const c of candidates)
|
|
585
|
+
for (const e of c.examples)
|
|
586
|
+
if (examples.length < 6 && isCleanExample(e))
|
|
587
|
+
examples.push(e);
|
|
588
|
+
const named = candidates.find((c) => c.name.trim().length > 0);
|
|
589
|
+
return {
|
|
590
|
+
...fallback,
|
|
591
|
+
...(named ? { name: slug(named.name, 48) } : {}),
|
|
592
|
+
persona: buildPersona(fallback.persona.split('\n')[0] ?? '', rules, tools),
|
|
593
|
+
knowledge: knowledge.length ? knowledge : fallback.knowledge,
|
|
594
|
+
examples: examples.length ? examples : fallback.examples,
|
|
595
|
+
confidence: Math.min(0.7, fallback.confidence + 0.1),
|
|
596
|
+
notes: [...fallback.notes, 'Model reduce pass failed; candidates were merged deterministically.'],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Map over every window to extract candidates, then one reduce pass to merge.
|
|
601
|
+
* Any failure degrades to `distillHeuristic` rather than throwing.
|
|
602
|
+
*/
|
|
603
|
+
export async function distillWithModel(transcript, router, opts = {}) {
|
|
604
|
+
const heuristic = distillHeuristic(transcript, { ...opts, redact: false });
|
|
605
|
+
const tools = observedTools(transcript);
|
|
606
|
+
const model = opts.model ?? 'auto';
|
|
607
|
+
const notes = [];
|
|
608
|
+
const windows = windowMessages(transcript.messages, opts.windowTokens ?? 12_000, opts.overlap ?? 2);
|
|
609
|
+
const meta = { source: transcript.source, ...(transcript.title ? { title: transcript.title } : {}) };
|
|
610
|
+
const results = await mapLimit(windows, opts.concurrency ?? 3, async (win, index) => {
|
|
611
|
+
if (!win.length)
|
|
612
|
+
return undefined;
|
|
613
|
+
try {
|
|
614
|
+
const res = await router.chat({
|
|
615
|
+
model,
|
|
616
|
+
system: extractSystemPrompt(),
|
|
617
|
+
messages: [{ role: 'user', content: extractUserPrompt(renderWindow(win), { ...meta, index, total: windows.length }) }],
|
|
618
|
+
temperature: 0,
|
|
619
|
+
responseFormat: { type: 'json' },
|
|
620
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
621
|
+
});
|
|
622
|
+
const parsed = CandidateSchema.safeParse(JSON.parse(stripFence(res.text)));
|
|
623
|
+
if (!parsed.success) {
|
|
624
|
+
notes.push(`Slice ${index + 1}/${windows.length}: model JSON did not validate.`);
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
return parsed.data;
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
notes.push(`Slice ${index + 1}/${windows.length}: extraction failed.`);
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
const candidates = results.filter((c) => c !== undefined);
|
|
635
|
+
if (!candidates.length) {
|
|
636
|
+
return finish({
|
|
637
|
+
...heuristic,
|
|
638
|
+
notes: [...heuristic.notes, 'Model distillation produced nothing usable; fell back to heuristics.', ...notes],
|
|
639
|
+
}, opts);
|
|
640
|
+
}
|
|
641
|
+
let merged;
|
|
642
|
+
try {
|
|
643
|
+
const res = await router.chat({
|
|
644
|
+
model,
|
|
645
|
+
system: mergeSystemPrompt(),
|
|
646
|
+
messages: [{ role: 'user', content: mergeUserPrompt(candidates, meta) }],
|
|
647
|
+
temperature: 0,
|
|
648
|
+
responseFormat: { type: 'json' },
|
|
649
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
650
|
+
});
|
|
651
|
+
const parsed = MergedSchema.safeParse(JSON.parse(stripFence(res.text)));
|
|
652
|
+
if (!parsed.success)
|
|
653
|
+
throw new Error('merge did not validate');
|
|
654
|
+
const m = parsed.data;
|
|
655
|
+
merged = {
|
|
656
|
+
name: slug(m.name || heuristic.name, 48),
|
|
657
|
+
description: clampText(m.description || heuristic.description, 240).text,
|
|
658
|
+
persona: m.persona.trim() || buildPersona(heuristic.persona.split('\n')[0] ?? '', unionRules(candidates), tools),
|
|
659
|
+
knowledge: m.knowledge.filter((k) => isUsableKnowledge(k.title, k.content)).slice(0, 8),
|
|
660
|
+
examples: m.examples.filter(isCleanExample).slice(0, 6),
|
|
661
|
+
// The transcript is ground truth about tools; the model only adds to it.
|
|
662
|
+
suggestedTools: [...new Set([...tools.suggested, ...m.tools.filter((t) => !IGNORED_TOOLS.test(t))])],
|
|
663
|
+
needsComputer: tools.needsComputer || m.needsComputer,
|
|
664
|
+
confidence: Math.min(1, Math.max(0, m.confidence)),
|
|
665
|
+
notes: [...m.notes, ...notes, `Distilled with a model over ${windows.length} window(s).`],
|
|
666
|
+
};
|
|
667
|
+
if (heuristic.suggestedModel)
|
|
668
|
+
merged.suggestedModel = heuristic.suggestedModel;
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
merged = mergeLocally(candidates, heuristic, tools);
|
|
672
|
+
merged.notes = [...merged.notes, ...notes];
|
|
673
|
+
}
|
|
674
|
+
return finish(merged, opts);
|
|
675
|
+
}
|
|
676
|
+
function finish(agent, opts) {
|
|
677
|
+
return opts.redact === false ? agent : redactDistilled(agent).agent;
|
|
678
|
+
}
|
|
679
|
+
/** Model path when a router is given, free path otherwise. */
|
|
680
|
+
export async function distill(transcript, opts = {}) {
|
|
681
|
+
return opts.router ? distillWithModel(transcript, opts.router, opts) : distillHeuristic(transcript, opts);
|
|
682
|
+
}
|
|
683
|
+
/** The bundles `@husk-ai/agent` can actually resolve. Anything else is a typo. */
|
|
684
|
+
const KNOWN_BUNDLES = new Set(['computer', 'files', 'web', 'http']);
|
|
685
|
+
const FALLBACK_PERSONA = 'You are a careful, concise assistant.';
|
|
686
|
+
/**
|
|
687
|
+
* `suggestedTools` is advisory; `spec.tools` is executable.
|
|
688
|
+
*
|
|
689
|
+
* The transcript contributes whatever the host tool happened to call things --
|
|
690
|
+
* `Bash`, `WebFetch`, `mcp__foo__bar` -- and the model path lets the model add
|
|
691
|
+
* more. Writing those through verbatim produces a husk.yaml that validates and
|
|
692
|
+
* then resolves to nothing at run time, which is the worst of both. Names are
|
|
693
|
+
* mapped onto bundles, unrecognised ones are dropped, and a husk always ends up
|
|
694
|
+
* with at least one tool.
|
|
695
|
+
*/
|
|
696
|
+
export function normaliseTools(suggested, needsComputer) {
|
|
697
|
+
const out = new Set();
|
|
698
|
+
for (const raw of suggested ?? []) {
|
|
699
|
+
const name = String(raw).trim();
|
|
700
|
+
if (!name)
|
|
701
|
+
continue;
|
|
702
|
+
const lower = name.toLowerCase();
|
|
703
|
+
if (KNOWN_BUNDLES.has(lower)) {
|
|
704
|
+
out.add(lower);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const hit = TOOL_BUNDLES.find((b) => b.re.test(name));
|
|
708
|
+
if (hit)
|
|
709
|
+
out.add(hit.bundle);
|
|
710
|
+
}
|
|
711
|
+
if (needsComputer)
|
|
712
|
+
out.add('computer');
|
|
713
|
+
if (!out.size)
|
|
714
|
+
out.add('files');
|
|
715
|
+
return [...out];
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Turn a distilled agent into a spec that `parseSpec` accepts, every time.
|
|
719
|
+
* Names are slugged, strings are clamped to the schema's limits, and `origin`
|
|
720
|
+
* records where the husk came from.
|
|
721
|
+
*
|
|
722
|
+
* This is the **only** `DistilledAgent -> HuskSpec` mapper. The CLI's
|
|
723
|
+
* `specFromDistilled` and the server's `/v1/sessions/distill` both call it, so a
|
|
724
|
+
* husk distilled at the terminal and the same husk distilled over HTTP are the
|
|
725
|
+
* same file. They were not: the two call sites wrote `metadata.distilledConfidence`
|
|
726
|
+
* and `metadata.distillConfidence` respectively, and disagreed about what
|
|
727
|
+
* `origin.source` meant.
|
|
728
|
+
*/
|
|
729
|
+
export function toSpec(agent, overrides = {}) {
|
|
730
|
+
const { transcript, ...rest } = overrides;
|
|
731
|
+
const name = normaliseName(agent.name);
|
|
732
|
+
const base = {
|
|
733
|
+
apiVersion: 'husk/v1',
|
|
734
|
+
name,
|
|
735
|
+
displayName: humaniseName(agent.name || name),
|
|
736
|
+
description: clampText(agent.description, 280).text.slice(0, 280),
|
|
737
|
+
model: agent.suggestedModel ?? 'auto',
|
|
738
|
+
persona: agent.persona || FALLBACK_PERSONA,
|
|
739
|
+
knowledge: agent.knowledge.map((k) => ({
|
|
740
|
+
title: k.title,
|
|
741
|
+
content: k.content,
|
|
742
|
+
...(k.source ? { source: k.source } : {}),
|
|
743
|
+
})),
|
|
744
|
+
examples: agent.examples,
|
|
745
|
+
tools: normaliseTools(agent.suggestedTools, agent.needsComputer),
|
|
746
|
+
computer: { enabled: agent.needsComputer },
|
|
747
|
+
metadata: {
|
|
748
|
+
distilledConfidence: agent.confidence,
|
|
749
|
+
distillerNotes: agent.notes,
|
|
750
|
+
},
|
|
751
|
+
...(transcript
|
|
752
|
+
? {
|
|
753
|
+
origin: {
|
|
754
|
+
source: transcript.origin ?? transcript.source,
|
|
755
|
+
transcriptId: transcript.id,
|
|
756
|
+
importedAt: new Date().toISOString(),
|
|
757
|
+
messageCount: transcript.messages.length,
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
: {}),
|
|
761
|
+
};
|
|
762
|
+
return parseSpec(deepMerge(base, rest));
|
|
763
|
+
}
|
|
764
|
+
function humaniseName(input) {
|
|
765
|
+
const words = input.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
766
|
+
const cased = words.charAt(0).toUpperCase() + words.slice(1);
|
|
767
|
+
return cased.slice(0, 96);
|
|
768
|
+
}
|
|
769
|
+
function normaliseName(input) {
|
|
770
|
+
const s = slug(input || 'husk-bot', 64).replace(/^[^a-z0-9]+/, '');
|
|
771
|
+
return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s.slice(0, 64) : 'husk-bot';
|
|
772
|
+
}
|
|
773
|
+
//# sourceMappingURL=distill.js.map
|