@hncode/hncode 0.3.5 → 0.4.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/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
- package/src/tui.js.bak_20260921_191527 +0 -9203
package/src/llm.js
CHANGED
|
@@ -31,14 +31,65 @@ export function anthropicToolDefs(tools) {
|
|
|
31
31
|
input_schema: t.parameters,
|
|
32
32
|
}));
|
|
33
33
|
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Multimodal content
|
|
36
|
+
//
|
|
37
|
+
// A tool may return `{ media: [...] }` instead of a string — ReadMediaFile does,
|
|
38
|
+
// so a screenshot reaches the model as an IMAGE rather than as a description of
|
|
39
|
+
// one. The entries are normalized here, once, into the shapes each protocol
|
|
40
|
+
// wants; everything upstream (agent, tools) stays protocol-agnostic.
|
|
41
|
+
//
|
|
42
|
+
// { type: 'image', mimeType: 'image/png', data: '<base64>' }
|
|
43
|
+
// { type: 'text', text: '...' }
|
|
44
|
+
//
|
|
45
|
+
// `data` is base64 WITHOUT the `data:` prefix; the prefix is added per protocol.
|
|
46
|
+
|
|
47
|
+
function isMediaContent(c) {
|
|
48
|
+
return !!c && typeof c === 'object' && Array.isArray(c.media);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mediaText(c) {
|
|
52
|
+
return String(c.text == null ? '' : c.text);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** OpenAI: content parts, `image_url` carries the data URL. */
|
|
56
|
+
function toOpenAiContent(c) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
const text = mediaText(c);
|
|
59
|
+
if (text) parts.push({ type: 'text', text });
|
|
60
|
+
for (const m of c.media) {
|
|
61
|
+
if (m && m.type === 'image' && m.data) {
|
|
62
|
+
parts.push({ type: 'image_url', image_url: { url: `data:${m.mimeType || 'image/png'};base64,${m.data}` } });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A media block with no image and no text would serialise as an empty array,
|
|
66
|
+
// which some providers reject. Fall back to a plain string.
|
|
67
|
+
if (!parts.length) return text || '';
|
|
68
|
+
return parts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Anthropic: content blocks, `image` carries source bytes. */
|
|
72
|
+
function toAnthropicContent(c) {
|
|
73
|
+
const blocks = [];
|
|
74
|
+
const text = mediaText(c);
|
|
75
|
+
if (text) blocks.push({ type: 'text', text });
|
|
76
|
+
for (const m of c.media) {
|
|
77
|
+
if (m && m.type === 'image' && m.data) {
|
|
78
|
+
blocks.push({ type: 'image', source: { type: 'base64', media_type: m.mimeType || 'image/png', data: m.data } });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!blocks.length) return text || '';
|
|
82
|
+
return blocks;
|
|
83
|
+
}
|
|
34
84
|
|
|
35
85
|
// Internal canonical messages -> OpenAI request shape.
|
|
36
86
|
export function toOpenAi(messages) {
|
|
37
87
|
const out = [];
|
|
38
88
|
for (const m of messages) {
|
|
39
89
|
if (m.role === 'system') out.push({ role: 'system', content: m.content });
|
|
40
|
-
else if (m.role === 'user')
|
|
41
|
-
|
|
90
|
+
else if (m.role === 'user') {
|
|
91
|
+
out.push({ role: 'user', content: isMediaContent(m.content) ? toOpenAiContent(m.content) : m.content });
|
|
92
|
+
} else if (m.role === 'assistant') {
|
|
42
93
|
const hasText = typeof m.content === 'string' && m.content.trim();
|
|
43
94
|
const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
|
|
44
95
|
if (!hasText && !hasCalls) continue; // 上游不允许空 assistant,直接丢弃
|
|
@@ -52,7 +103,11 @@ export function toOpenAi(messages) {
|
|
|
52
103
|
}
|
|
53
104
|
out.push(msg);
|
|
54
105
|
} else if (m.role === 'tool') {
|
|
55
|
-
out.push({
|
|
106
|
+
out.push({
|
|
107
|
+
role: 'tool',
|
|
108
|
+
tool_call_id: m.toolCallId,
|
|
109
|
+
content: isMediaContent(m.content) ? toOpenAiContent(m.content) : m.content,
|
|
110
|
+
});
|
|
56
111
|
}
|
|
57
112
|
}
|
|
58
113
|
return out;
|
|
@@ -64,9 +119,20 @@ export function toAnthropic(messages) {
|
|
|
64
119
|
const out = [];
|
|
65
120
|
for (const m of messages) {
|
|
66
121
|
if (m.role === 'system') { sys.push(m.content); continue; }
|
|
67
|
-
if (m.role === 'user') {
|
|
122
|
+
if (m.role === 'user') {
|
|
123
|
+
const c = isMediaContent(m.content) ? toAnthropicContent(m.content) : [{ type: 'text', text: m.content }];
|
|
124
|
+
out.push({ role: 'user', content: c });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
68
127
|
if (m.role === 'tool') {
|
|
69
|
-
out.push({
|
|
128
|
+
out.push({
|
|
129
|
+
role: 'user',
|
|
130
|
+
content: [{
|
|
131
|
+
type: 'tool_result',
|
|
132
|
+
tool_use_id: m.toolCallId,
|
|
133
|
+
content: isMediaContent(m.content) ? toAnthropicContent(m.content) : m.content,
|
|
134
|
+
}],
|
|
135
|
+
});
|
|
70
136
|
continue;
|
|
71
137
|
}
|
|
72
138
|
if (m.role === 'assistant') {
|
|
@@ -78,7 +144,7 @@ export function toAnthropic(messages) {
|
|
|
78
144
|
out.push({ role: 'assistant', content: blocks });
|
|
79
145
|
}
|
|
80
146
|
}
|
|
81
|
-
|
|
147
|
+
return { system: sys.join('\n\n').trim(), messages: out };
|
|
82
148
|
}
|
|
83
149
|
|
|
84
150
|
function authHeaders(cfg) {
|
|
@@ -94,17 +160,31 @@ function authHeaders(cfg) {
|
|
|
94
160
|
return h;
|
|
95
161
|
}
|
|
96
162
|
|
|
97
|
-
function buildBody(cfg, messages, streaming = true) {
|
|
163
|
+
function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
98
164
|
const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
|
|
99
165
|
if (cfg.temperature != null) common.temperature = cfg.temperature;
|
|
100
166
|
// Thinking / reasoning. The wire form depends on the protocol and the chosen
|
|
101
167
|
// effort (see config.effortWire): OpenAI-compatible sends `reasoning_effort`,
|
|
102
168
|
// Anthropic sends a `thinking` budget. Models that think by default need no
|
|
103
169
|
// flag, so nothing is sent when the effort is off/unset.
|
|
104
|
-
|
|
170
|
+
//
|
|
171
|
+
// `noReasoning` opts a request OUT of it. Internal one-shot requests that cap
|
|
172
|
+
// their output (the session title, the compaction summary) must not ask for
|
|
173
|
+
// thinking: a reasoning model spends the whole small budget on
|
|
174
|
+
// `reasoning_content`, returns `finish_reason: "length"` with empty `content`,
|
|
175
|
+
// and the request comes back as a failure that never even shows an error.
|
|
176
|
+
const wire = opts.noReasoning ? {} : (effortWire(cfg, cfg.effort) || {});
|
|
177
|
+
// Internal requests that have no use for tools must not carry the ~25 KB tool
|
|
178
|
+
// block: it is pure input cost on top of a request that only wants one line.
|
|
179
|
+
const defs = Object.prototype.hasOwnProperty.call(opts, 'tools') ? openAiToolDefs(opts.tools) : openAiToolDefs(llmToolsList);
|
|
105
180
|
if (cfg.protocol === 'anthropic') {
|
|
106
181
|
const { system, messages: am } = toAnthropic(messages);
|
|
107
|
-
const body = {
|
|
182
|
+
const body = {
|
|
183
|
+
model: cfg.innerModel, system, messages: am,
|
|
184
|
+
tools: opts.noTools ? undefined : anthropicToolDefs(opts.tools || llmToolsList),
|
|
185
|
+
...common, ...wire,
|
|
186
|
+
};
|
|
187
|
+
if (!body.tools || !body.tools.length) delete body.tools;
|
|
108
188
|
// Prompt caching: mark the stable prefix (tools + system + conversation head)
|
|
109
189
|
// so the provider bills it as a cache READ instead of fresh input. The key is
|
|
110
190
|
// the SESSION id, so it stays identical across turns and never thrashes.
|
|
@@ -113,7 +193,11 @@ function buildBody(cfg, messages, streaming = true) {
|
|
|
113
193
|
sessionKey: cfg.sessionId,
|
|
114
194
|
}));
|
|
115
195
|
}
|
|
116
|
-
const body = {
|
|
196
|
+
const body = {
|
|
197
|
+
model: cfg.innerModel, messages: toOpenAi(messages),
|
|
198
|
+
...common, ...wire,
|
|
199
|
+
};
|
|
200
|
+
if (!opts.noTools) body.tools = defs;
|
|
117
201
|
return JSON.stringify(applyPromptCache('openai', body, {
|
|
118
202
|
enabled: cfg.promptCache !== false,
|
|
119
203
|
sessionKey: cfg.sessionId,
|
|
@@ -153,14 +237,18 @@ export class LLM {
|
|
|
153
237
|
// Simple non-streaming text request — used for internal tasks (e.g. context
|
|
154
238
|
// summarization during compaction) that should not surface a tool plan to the
|
|
155
239
|
// user. Returns the assistant's text content or null on failure.
|
|
156
|
-
|
|
240
|
+
//
|
|
241
|
+
// `opts.noReasoning` / `opts.noTools` exist for the small one-shot requests:
|
|
242
|
+
// a capped budget plus a reasoning model is what silently produced an empty
|
|
243
|
+
// title (see buildBody), and an internal request has no tools to call.
|
|
244
|
+
async requestText(messages, opts = {}) {
|
|
157
245
|
const cfg = this.cfg;
|
|
158
246
|
this.controller = new AbortController();
|
|
159
247
|
try {
|
|
160
248
|
const res = await fetch(cfg.endpoint, {
|
|
161
249
|
method: 'POST',
|
|
162
250
|
headers: authHeaders(cfg),
|
|
163
|
-
body: buildBody(cfg, messages, false),
|
|
251
|
+
body: buildBody(cfg, messages, false, opts),
|
|
164
252
|
signal: this.controller.signal,
|
|
165
253
|
});
|
|
166
254
|
if (!res || !res.ok) {
|
|
@@ -179,7 +267,18 @@ export class LLM {
|
|
|
179
267
|
const msg = json.choices && json.choices[0] && json.choices[0].message;
|
|
180
268
|
if (msg && msg.content) text = msg.content;
|
|
181
269
|
}
|
|
182
|
-
|
|
270
|
+
// An EMPTY reply that hit the output cap is a failure, not an answer. The
|
|
271
|
+
// title request used to receive `content: ""` + `finish_reason: "length"`
|
|
272
|
+
// and treat it as "the model had nothing to say", which is why the title
|
|
273
|
+
// was silently never generated (and never retried). Returning it marked
|
|
274
|
+
// lets the caller decide — the title path retries with a bigger budget.
|
|
275
|
+
if (!text) {
|
|
276
|
+
const truncated = cfg.protocol === 'anthropic'
|
|
277
|
+
? (json.stop_reason === 'max_tokens')
|
|
278
|
+
: !!(json.choices && json.choices[0] && json.choices[0].finish_reason === 'length');
|
|
279
|
+
return truncated ? '' : null;
|
|
280
|
+
}
|
|
281
|
+
return text;
|
|
183
282
|
} catch (e) {
|
|
184
283
|
if (e && e.name === 'AbortError') return null;
|
|
185
284
|
return null;
|
|
@@ -394,9 +493,16 @@ function processEvent(protocol, raw, onEvent, state) {
|
|
|
394
493
|
}
|
|
395
494
|
|
|
396
495
|
function processOpenAI(d, onEvent, state) {
|
|
397
|
-
const
|
|
496
|
+
const choice = d.choices && d.choices[0];
|
|
497
|
+
const delta = choice && choice.delta;
|
|
498
|
+
const finishReason = choice && choice.finish_reason;
|
|
499
|
+
// Some servers put the final chunk's content AND its finish_reason in the SAME
|
|
500
|
+
// frame, so the stop reason has to be handled AFTER the delta — checking it
|
|
501
|
+
// only on a delta-less frame missed the truncation marker entirely (a stream
|
|
502
|
+
// that reported `length` alongside the last token looked like a normal end).
|
|
398
503
|
if (!delta) {
|
|
399
|
-
if (
|
|
504
|
+
if (finishReason) {
|
|
505
|
+
if (finishReason === 'length') onEvent({ type: 'truncated' });
|
|
400
506
|
onEvent({ type: 'end' });
|
|
401
507
|
return 'end';
|
|
402
508
|
}
|
|
@@ -440,6 +546,14 @@ function processOpenAI(d, onEvent, state) {
|
|
|
440
546
|
}
|
|
441
547
|
}
|
|
442
548
|
}
|
|
549
|
+
// A frame can carry BOTH the last delta and the stop reason (see above). Now
|
|
550
|
+
// that the delta is consumed, report the cut and end the round — mirroring the
|
|
551
|
+
// `message_stop` path for Anthropic.
|
|
552
|
+
if (finishReason) {
|
|
553
|
+
if (finishReason === 'length') onEvent({ type: 'truncated' });
|
|
554
|
+
onEvent({ type: 'end' });
|
|
555
|
+
return 'end';
|
|
556
|
+
}
|
|
443
557
|
return 'continue';
|
|
444
558
|
}
|
|
445
559
|
|
|
@@ -447,6 +561,11 @@ function processAnthropic(d, onEvent, state) {
|
|
|
447
561
|
const t = d.type;
|
|
448
562
|
if (t === 'message_start' || t === 'message_delta' || t === 'message_stop') {
|
|
449
563
|
if (t === 'message_stop') { onEvent({ type: 'end' }); return 'end'; }
|
|
564
|
+
// `message_delta` carries the stop reason; `max_tokens` there means the reply
|
|
565
|
+
// was cut off, not finished (see processOpenAI for the OpenAI spelling).
|
|
566
|
+
if (t === 'message_delta' && d.delta && d.delta.stop_reason === 'max_tokens') {
|
|
567
|
+
onEvent({ type: 'truncated' });
|
|
568
|
+
}
|
|
450
569
|
return 'continue';
|
|
451
570
|
}
|
|
452
571
|
if (t === 'content_block_start') {
|
|
@@ -481,6 +600,7 @@ export function consumeNonStreaming(protocol, json, onEvent) {
|
|
|
481
600
|
const state = { tagPending: '', inThink: false };
|
|
482
601
|
if (protocol === 'anthropic') {
|
|
483
602
|
const content = json.content || [];
|
|
603
|
+
if (json.stop_reason === 'max_tokens') onEvent({ type: 'truncated' });
|
|
484
604
|
for (const cb of content) {
|
|
485
605
|
if (cb.type === 'text' && cb.text) feedContent(cb.text, onEvent, state);
|
|
486
606
|
else if (cb.type === 'tool_use') {
|
|
@@ -490,8 +610,11 @@ export function consumeNonStreaming(protocol, json, onEvent) {
|
|
|
490
610
|
}
|
|
491
611
|
}
|
|
492
612
|
} else {
|
|
493
|
-
const
|
|
613
|
+
const choice = json.choices && json.choices[0];
|
|
614
|
+
const msg = choice && choice.message;
|
|
494
615
|
if (!msg) { onEvent({ type: 'error', error: new Error('empty LLM response') }); return; }
|
|
616
|
+
// Non-streaming: the truncation marker comes on the same object as the message.
|
|
617
|
+
if (choice.finish_reason === 'length') onEvent({ type: 'truncated' });
|
|
495
618
|
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) onEvent({ type: 'think', text: msg.reasoning_content });
|
|
496
619
|
else if (typeof msg.reasoning === 'string' && msg.reasoning) onEvent({ type: 'think', text: msg.reasoning });
|
|
497
620
|
else if (Array.isArray(msg.reasoning_details)) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Permission rules — standing allow / ask / deny decisions, mirroring Claude Code's
|
|
2
|
+
// `permissions` block in settings.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: without it, "always allow npm test" has no home. The user answers
|
|
5
|
+
// the same approval prompt on every turn, or switches the whole session to Auto
|
|
6
|
+
// and loses the prompt for genuinely dangerous commands too. A rule set lets the
|
|
7
|
+
// routine work through while the rest keeps asking.
|
|
8
|
+
//
|
|
9
|
+
// Rule syntax (a subset of Claude Code's, chosen to stay readable):
|
|
10
|
+
// Bash — every Bash call
|
|
11
|
+
// Bash(npm run test) — that exact command
|
|
12
|
+
// Bash(npm run test *) — a command starting with `npm run test `
|
|
13
|
+
// Bash(git push *) — same idea, for a command with arguments
|
|
14
|
+
// Read(./src/**) — a tool whose `path` / `file_path` argument matches the glob
|
|
15
|
+
// Write(./dist/**) — same, for the path-taking tools
|
|
16
|
+
// Edit — the tool, whatever its arguments
|
|
17
|
+
// An empty argument list means "any call of this tool".
|
|
18
|
+
//
|
|
19
|
+
// Precedence is deny > ask > allow, and it is checked before every other mode rule:
|
|
20
|
+
// a deny must not be reachable around by switching to YOLO, and an explicit ask must
|
|
21
|
+
// still prompt inside a mode that would otherwise auto-approve.
|
|
22
|
+
|
|
23
|
+
// Which argument names hold a path, per tool. Mirrors the tools' own schemas.
|
|
24
|
+
const PATH_ARGS = {
|
|
25
|
+
Read: ['path', 'file_path'],
|
|
26
|
+
Write: ['path', 'file_path'],
|
|
27
|
+
Edit: ['path', 'file_path'],
|
|
28
|
+
FileLines: ['path', 'file_path'],
|
|
29
|
+
Glob: ['path', 'pattern'],
|
|
30
|
+
Grep: ['path', 'pattern'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// `Bash(npm run test)` / `Read(./src/**)` -> { tool, arg }
|
|
34
|
+
export function parseRule(rule) {
|
|
35
|
+
const text = String(rule == null ? '' : rule).trim();
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
const open = text.indexOf('(');
|
|
38
|
+
if (open === -1) return { tool: text, arg: null };
|
|
39
|
+
if (!text.endsWith(')')) return null; // malformed: a `(` with no `)`
|
|
40
|
+
const tool = text.slice(0, open).trim();
|
|
41
|
+
if (!tool) return null;
|
|
42
|
+
return { tool, arg: text.slice(open + 1, -1).trim() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Glob -> RegExp. Supports `**` (any depth), `*` (within a segment) and `?`.
|
|
46
|
+
// Deliberately small: enough for path and command-prefix matching, with no
|
|
47
|
+
// dependency and no surprise backtracking.
|
|
48
|
+
function globToRegExp(pattern) {
|
|
49
|
+
let out = '';
|
|
50
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
51
|
+
const c = pattern[i];
|
|
52
|
+
if (c === '*') {
|
|
53
|
+
if (pattern[i + 1] === '*') { out += '.*'; i++; if (pattern[i + 1] === '/') i++; }
|
|
54
|
+
else out += '[^/]*';
|
|
55
|
+
} else if (c === '?') out += '[^/]';
|
|
56
|
+
else out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
}
|
|
58
|
+
return new RegExp('^' + out + '$');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Does `rule` (already parsed) match this call? `args` is the tool's argument object.
|
|
62
|
+
export function ruleMatches(parsed, toolName, args) {
|
|
63
|
+
if (!parsed || parsed.tool !== toolName) return false;
|
|
64
|
+
if (parsed.arg === null || parsed.arg === '') return true; // bare tool name: any call
|
|
65
|
+
|
|
66
|
+
const a = args || {};
|
|
67
|
+
// Bash: the rule names a command. A trailing `*` means "starts with"; otherwise the
|
|
68
|
+
// command must match exactly, so `Bash(rm -rf /)` cannot be widened by an argument.
|
|
69
|
+
if (toolName === 'Bash') {
|
|
70
|
+
const cmd = String(a.command || a.cmd || '').trim();
|
|
71
|
+
if (parsed.arg.endsWith('*')) {
|
|
72
|
+
const prefix = parsed.arg.slice(0, -1).trim();
|
|
73
|
+
return cmd === prefix || cmd.startsWith(prefix + ' ');
|
|
74
|
+
}
|
|
75
|
+
return cmd === parsed.arg;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Path-taking tools: match any of the tool's path-ish arguments against the glob,
|
|
79
|
+
// so `Read(./src/**)` works whether the model sent `path` or `file_path`.
|
|
80
|
+
const names = PATH_ARGS[toolName] || ['path', 'file_path', 'pattern'];
|
|
81
|
+
for (const n of names) {
|
|
82
|
+
const v = a[n];
|
|
83
|
+
if (typeof v !== 'string' || !v) continue;
|
|
84
|
+
// Accept both `./src/**` and `src/**` for the same intent.
|
|
85
|
+
const norm = v.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
86
|
+
const pat = parsed.arg.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
87
|
+
if (globToRegExp(pat).test(norm) || globToRegExp(pat).test(v.replace(/\\/g, '/'))) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The decision for one call: 'deny' | 'ask' | 'allow' | null (no rule matched).
|
|
93
|
+
export function decideFromRules(rules, toolName, args) {
|
|
94
|
+
const list = rules || {};
|
|
95
|
+
const test = (key) => {
|
|
96
|
+
const arr = Array.isArray(list[key]) ? list[key] : [];
|
|
97
|
+
for (const raw of arr) {
|
|
98
|
+
if (ruleMatches(parseRule(raw), toolName, args)) return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
};
|
|
102
|
+
// deny wins outright; then an explicit ask; then allow.
|
|
103
|
+
if (test('deny')) return 'deny';
|
|
104
|
+
if (test('ask')) return 'ask';
|
|
105
|
+
if (test('allow')) return 'allow';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Human-readable summary for /permissions, so a user can see what is in force.
|
|
110
|
+
export function describeRules(rules) {
|
|
111
|
+
const list = rules || {};
|
|
112
|
+
const lines = [];
|
|
113
|
+
for (const key of ['allow', 'ask', 'deny']) {
|
|
114
|
+
const arr = Array.isArray(list[key]) ? list[key] : [];
|
|
115
|
+
lines.push(`${key} (${arr.length}):`);
|
|
116
|
+
if (!arr.length) lines.push(' (none)');
|
|
117
|
+
else for (const r of arr) lines.push(' ' + r);
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|