aegis-desktop 0.8.20 → 0.8.21

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.
@@ -80,6 +80,107 @@ try {
80
80
  }
81
81
  }
82
82
 
83
+ // The ONE platform module (client/platform.js), resolved the same two ways, for
84
+ // `desktopDir()` — the environment preamble names the user's REAL Desktop so a
85
+ // model asked to put a file there stops guessing `~/Desktop`, which is wrong
86
+ // under OneDrive redirection on Windows and on any localised Linux session.
87
+ let platformLib = null;
88
+ try {
89
+ platformLib = require('../../../client/platform.js');
90
+ } catch {
91
+ try {
92
+ platformLib = require('../../vendor/platform.js');
93
+ } catch {
94
+ platformLib = null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The environment facts the model needs to stop asking which OS it is on.
100
+ *
101
+ * Module scope, not a closure inside `createLocalEngine`, because there are now
102
+ * TWO hosts that must state these facts the same way: the desktop engine (whose
103
+ * `envFor` below is this function with the host's own `env` bag) and the CLI
104
+ * (`cli/src/app.js`), which had no environment block at all. A second copy over
105
+ * there is precisely how the two hosts would drift — the Windows report that
106
+ * started this ("it still writes to /home/you/... on my PC") was the CLI path,
107
+ * where a Linux-shaped persona was built from `process.platform` and nothing
108
+ * else.
109
+ *
110
+ * Everything is best-effort: a missing field is simply omitted, never guessed.
111
+ *
112
+ * base the host's own env bag ({ platform, homedir, cwd, … } or {})
113
+ * supplied a per-turn override bag (payload.env; wins key by key)
114
+ * payload the turn payload, for `model`
115
+ */
116
+ /**
117
+ * Resolve a tool call's path arguments, tolerating an injected tools module that
118
+ * does not implement it.
119
+ *
120
+ * `createLocalEngine({ tools })` accepts a PARTIAL tool layer — the engine tests
121
+ * inject `{ MUTATING_TOOLS, executeTool, toolsFor }` and nothing else — so
122
+ * calling `T.normalizeArgs` unconditionally turns every such caller into a
123
+ * TypeError. The injected module's own implementation wins when it has one; the
124
+ * real tools.js is the fallback, because path resolution is a property of the
125
+ * MACHINE (client/platform.js) and not of which executors a caller supplied.
126
+ */
127
+ function normalizeToolArgs(T, name, args, ctx) {
128
+ const fn =
129
+ (T && typeof T.normalizeArgs === 'function' && T.normalizeArgs) ||
130
+ (typeof toolsModule.normalizeArgs === 'function' && toolsModule.normalizeArgs) ||
131
+ null;
132
+ if (!fn) return { ok: true, args: args && typeof args === 'object' ? args : {}, error: null };
133
+ return fn(name, args, ctx);
134
+ }
135
+
136
+ function hostEnv(base = {}, supplied = {}, payload = null) {
137
+ const b = base || {};
138
+ const s = supplied || {};
139
+ const pick = (key, value) => (s[key] != null ? s[key] : value);
140
+ let homedir = b.homedir;
141
+ let cwd = b.cwd;
142
+ try {
143
+ if (!homedir) homedir = os.homedir();
144
+ if (!cwd) cwd = process.cwd();
145
+ } catch {
146
+ /* keep whatever we have */
147
+ }
148
+ // The shell the `exec` tool really runs in, from the module that decides it
149
+ // (shell.js resolveSessionShell) rather than a second guess here: on win32
150
+ // that is PowerShell, and a model told nothing reaches for `ls`/`cat`/`grep`
151
+ // — commands that do not exist there. Best-effort; a failed probe simply
152
+ // omits the line.
153
+ let shellName = b.shell;
154
+ let shellKind = b.shellKind;
155
+ try {
156
+ if (!shellName || !shellKind) {
157
+ const spec = require('./shell.js').resolveSessionShell();
158
+ shellName = shellName || spec.cmd;
159
+ shellKind = shellKind || spec.kind;
160
+ }
161
+ } catch {
162
+ /* no shell line */
163
+ }
164
+ let desktopdir = b.desktopdir;
165
+ try {
166
+ if (!desktopdir && platformLib) desktopdir = platformLib.desktopDir();
167
+ } catch {
168
+ /* no desktop line */
169
+ }
170
+ return {
171
+ platform: pick('platform', b.platform || process.platform),
172
+ arch: pick('arch', b.arch || process.arch),
173
+ homedir: pick('homedir', homedir),
174
+ desktopdir: pick('desktopdir', desktopdir),
175
+ cwd: pick('cwd', cwd),
176
+ shell: pick('shell', shellName),
177
+ shellKind: pick('shellKind', shellKind),
178
+ roots: pick('roots', b.roots),
179
+ appVersion: pick('appVersion', b.appVersion),
180
+ model: pick('model', payload && payload.model),
181
+ };
182
+ }
183
+
83
184
  /**
84
185
  * The shared pooled-brain selection rule (`client/brain-catalog.js`), resolved
85
186
  * the same two ways as every other shared module: repo-relative from a source
@@ -773,6 +874,13 @@ function createLocalEngine({
773
874
  * channel — the same reason it works identically in the GUI and the CLI.
774
875
  */
775
876
  async function guardedExecute(name, args, toolCtx) {
877
+ // Resolve `~/…` and refuse a foreign-OS path BEFORE anything below reads it
878
+ // (tools.js normalizeArgs; idempotent, so executeTool normalising again is a
879
+ // no-op). The covenant assert and recordWrite both inspect `args.file_path`,
880
+ // and a path they rule on must be the path that actually gets written.
881
+ const norm = normalizeToolArgs(T, name, args, toolCtx || {});
882
+ if (!norm.ok) return { ok: false, error: norm.error };
883
+ args = norm.args;
776
884
  const guard = toolCtx && toolCtx.guard;
777
885
  if (guard && name === 'exec') {
778
886
  const verdict = blocksDestructive(guard, args && args.command);
@@ -816,7 +924,13 @@ function createLocalEngine({
816
924
  }
817
925
 
818
926
  async function gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal }) {
819
- const { name, args } = call;
927
+ const { name } = call;
928
+ // Same normalisation as guardedExecute, and for the same reason one layer
929
+ // up: the diff the user approves is rendered from this path, the hash guard
930
+ // re-reads it, and recordWrite claims it. All three must mean one file.
931
+ const normalized = normalizeToolArgs(T, name, call.args, toolCtx || {});
932
+ if (!normalized.ok) return { ok: false, error: normalized.error };
933
+ const args = normalized.args;
820
934
  if (!T.MUTATING_TOOLS.has(name)) return guardedExecute(name, args, toolCtx);
821
935
  if (!confirmModeEnabled()) return guardedExecute(name, args, toolCtx);
822
936
  if (sessionAllows(rootSessionId, name)) return guardedExecute(name, args, toolCtx);
@@ -830,7 +944,7 @@ function createLocalEngine({
830
944
 
831
945
  let preview = null;
832
946
  if (name === 'writeFile' || name === 'editFile') {
833
- preview = T.previewMutation(name, args);
947
+ preview = T.previewMutation(name, args, toolCtx || {});
834
948
  if (!preview.ok) return { ok: false, error: preview.error };
835
949
  }
836
950
 
@@ -853,7 +967,7 @@ function createLocalEngine({
853
967
  if (decision === 'session') allowForSession(rootSessionId, name);
854
968
 
855
969
  if (preview) {
856
- const res = T.applyChecked(name, args, preview);
970
+ const res = T.applyChecked(name, args, preview, toolCtx || {});
857
971
  // Recorded on the same condition as guardedExecute's path: only a write
858
972
  // that landed is this session's. (The previous form returned here
859
973
  // unconditionally, making every line below it unreachable — the
@@ -1039,26 +1153,7 @@ function createLocalEngine({
1039
1153
  * Everything is best-effort: a missing field is simply omitted.
1040
1154
  */
1041
1155
  function envFor(payload) {
1042
- const supplied = (payload && payload.env) || {};
1043
- const base = env || {};
1044
- const pick = (key, value) => (supplied[key] != null ? supplied[key] : value);
1045
- let homedir = base.homedir;
1046
- let cwd = base.cwd;
1047
- try {
1048
- if (!homedir) homedir = os.homedir();
1049
- if (!cwd) cwd = process.cwd();
1050
- } catch {
1051
- /* keep whatever we have */
1052
- }
1053
- return {
1054
- platform: pick('platform', base.platform || process.platform),
1055
- arch: pick('arch', base.arch || process.arch),
1056
- homedir: pick('homedir', homedir),
1057
- cwd: pick('cwd', cwd),
1058
- roots: pick('roots', base.roots),
1059
- appVersion: pick('appVersion', base.appVersion),
1060
- model: pick('model', payload && payload.model),
1061
- };
1156
+ return hostEnv(env || {}, (payload && payload.env) || {}, payload);
1062
1157
  }
1063
1158
 
1064
1159
  /** One transport round for the chosen class. */
@@ -1938,4 +2033,4 @@ function createLocalEngine({
1938
2033
  };
1939
2034
  }
1940
2035
 
1941
- module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget, nativeEffort };
2036
+ module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget, nativeEffort, hostEnv };
@@ -59,41 +59,290 @@ function baseOf(baseURL) {
59
59
  }
60
60
 
61
61
  /**
62
- * DeepSeek's chat template (also the one a locally-pulled `deepseek-*` Ollama
63
- * model uses) wraps tool calls and turn boundaries in special tokens —
64
- * fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁ (U+2581) as its
65
- * word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|, |Assistant|,
66
- * |begin▁of▁sentence|. These are chat-template internals, never user-facing
67
- * text; when the local daemon's tool-call path leaks them into `delta.content`
68
- * they used to reach the screen verbatim — reported as the pasted
69
- * "||DSML||" envelope. Returns a per-turn stripper closure with a short
70
- * lookback buffer so a token split across two SSE chunks isn't half-rendered
71
- * before its closing pipe arrives.
62
+ * DeepSeek (also the chat template a locally-pulled `deepseek-*` Ollama model
63
+ * uses) emits its tool calls as DSML — an XML-ish dialect that the
64
+ * OpenAI-compatible wire format does NOT structure for us:
65
+ *
66
+ * <|DSML|tool_calls>
67
+ * <|DSML|invoke name="write">
68
+ * <|DSML|parameter name="path" string="true">src/app.js</|DSML|parameter>
69
+ * <|DSML|parameter name="content" string="true">…the file body…</|DSML|parameter>
70
+ * </|DSML|invoke>
71
+ * </|DSML|tool_calls>
72
+ *
73
+ * It arrives as plain `delta.content` text, and that produces two failures:
74
+ * the markup rendering verbatim (reported as the pasted "||DSML||" envelope),
75
+ * and the call itself never running because text is not a tool call. Ported
76
+ * from aegiscodex-dev's src/backend.js (also client/aegis.js, the other
77
+ * transport this engine shares), which carries the full history of this
78
+ * grammar's quirks (provider/template/model revision disagree about the exact
79
+ * identifier, angle brackets sometimes absent, tag names drift under
80
+ * sampling) — every part of the pattern below is deliberately optional and
81
+ * spacing-insensitive for that reason. `string="false"` marks a JSON
82
+ * parameter; `string="true"` (or an absent attribute) is the literal value,
83
+ * kept verbatim so a multi-line `content` is never mangled by a JSON
84
+ * round-trip.
85
+ */
86
+ // `\b` after each tag-name alternative matters: without it, "call" matches as
87
+ // a bare PREFIX of a drifted wrapper tag like "calls" (observed verbatim —
88
+ // `<|| calls>`, no "tool_"/"function_" prefix at all), swallowing everything
89
+ // up to the next real `</invoke>` as that spurious match's unnamed body and
90
+ // silently discarding a whole tool call with it. "call" and "calls" are both
91
+ // word characters, so plain alternation order cannot tell them apart.
92
+ const DSML_RE_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*>/g;
93
+ const DSML_RE_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/g;
94
+ const DSML_RE_LEADER = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:(?:tool|function)[_▁ ]*calls?|reasoning)[_▁ ]*(?:begin|end)?\s*>/gi;
95
+ const DSML_RE_ANY_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
96
+ const DSML_RE_SKELETON = /<\s*\/?\s*(?:tool_calls|function_calls|invoke|parameter|param|reasoning)\b[^>]{0,120}>/gi;
97
+ const DSML_RE_JSON_CALLS = /|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*begin[ \t]*|([\s\S]*?)|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*end[ \t]*|/gi;
98
+ const DSML_RE_ENVELOPE_TOKEN = /|[ \t]?[A-Za-z0-9_▁]*[ \t]?|/g;
99
+ const DSML_RE_DRIFT_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>/gi;
100
+ const DSML_RE_DRIFT_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>/gi;
101
+ const DSML_RE_PARAM_CLOSE = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/gi;
102
+ const DSML_RE_ATTR = /([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
103
+ const DSML_RE_STRUCT_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
104
+ const DSML_RE_STRUCT_CLOSER = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
105
+ /**
106
+ * A COMPLETE (its own '>' has arrived) wrapper/leader tag that carries no
107
+ * call of its own — observed verbatim: a bare `<||calls>`, with neither a
108
+ * "tool_"/"function_" prefix (so DSML_RE_LEADER above misses it) nor a JSON
109
+ * envelope pair. Safe to drop the moment it closes, UNLIKE invoke/parameter —
110
+ * excluded here via negative lookahead so a *partial* invoke/parameter head is
111
+ * never erased before its own close has a chance to pair it up with
112
+ * DSML_RE_INVOKE/DSML_RE_PARAM on a later call once more of it has arrived.
113
+ */
114
+ const DSML_RE_WRAPPER_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?!(?:invoke|call|tool_call|parameter|param|arg)\b)[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
115
+ /** A '<' (or '</') at the tail: the head of a tag whose pipe is still in flight. */
116
+ const DSML_RE_TRAILING_LT = /<\s*\/?\s*$/;
117
+ /** An in-flight identifier token: a pipe whose partner has not arrived yet. */
118
+ const DSML_RE_OPEN_TOKEN = /^|[ \t]?[A-Za-z0-9_▁]*[ \t]?$/;
119
+ /** Past this much buffered markup, a held tail is released as-is. */
120
+ const DSML_HOLD_MAX = 4 * 1024 * 1024;
121
+
122
+ /** The attributes of one tag head, lower-cased: name="write" → { name: 'write' }. */
123
+ function dsmlAttrs(text) {
124
+ const out = {};
125
+ if (!text) return out;
126
+ DSML_RE_ATTR.lastIndex = 0;
127
+ let m;
128
+ while ((m = DSML_RE_ATTR.exec(text))) out[m[1].toLowerCase()] = m[2] != null ? m[2] : m[3];
129
+ return out;
130
+ }
131
+
132
+ /** A JSON parameter — or, when it is not JSON after all, the text it came as. */
133
+ function dsmlJson(raw) {
134
+ const s = String(raw == null ? '' : raw).trim();
135
+ try {
136
+ const v = JSON.parse(s);
137
+ return v === undefined ? s : v;
138
+ } catch {
139
+ return s;
140
+ }
141
+ }
142
+
143
+ /** The `parameter` tags of one `invoke` body, as a tool call's args object. */
144
+ function dsmlArgs(body) {
145
+ const args = {};
146
+ DSML_RE_PARAM.lastIndex = 0;
147
+ let p;
148
+ while ((p = DSML_RE_PARAM.exec(body))) {
149
+ const a = dsmlAttrs(p[1]);
150
+ const key = a.name || a.key || `arg${Object.keys(args).length}`;
151
+ const isJson = String(a.string || '').toLowerCase() === 'false' || String(a.json || '').toLowerCase() === 'true';
152
+ args[key] = isJson ? dsmlJson(p[2]) : p[2];
153
+ }
154
+ return args;
155
+ }
156
+
157
+ /**
158
+ * Decode every DSML tool call in `text` and return the two things a caller
159
+ * needs: what may be shown, and what must be executed — `{ text, calls }`.
160
+ * The generic `|…|` tokens are deliberately left in place here: an `invoke`
161
+ * block still arriving is identified by its delimiters on the next pass, and
162
+ * eating them early would lose the call's name and arguments. They are
163
+ * removed from the *released* text instead (see dsmlResidue).
164
+ */
165
+ function decodeDeepSeekDSML(input) {
166
+ if (typeof input !== 'string' || !input || input.indexOf('|') === -1) {
167
+ return { text: input || '', calls: [] };
168
+ }
169
+ const calls = [];
170
+ let text = input;
171
+
172
+ // 1. One `invoke` block per call, the well-formed spelling.
173
+ DSML_RE_INVOKE.lastIndex = 0;
174
+ const spans = [];
175
+ let m;
176
+ while ((m = DSML_RE_INVOKE.exec(text))) {
177
+ const a = dsmlAttrs(m[1]);
178
+ const name = a.name || a.function || a.tool || '';
179
+ if (name) calls.push({ name, args: dsmlArgs(m[2]) });
180
+ spans.push([m.index, m.index + m[0].length]);
181
+ }
182
+ for (let i = spans.length - 1; i >= 0; i--) {
183
+ text = text.slice(0, spans[i][0]) + text.slice(spans[i][1]);
184
+ }
185
+
186
+ // 2. The older spelling: a JSON array wrapped in envelope tokens.
187
+ text = text.replace(DSML_RE_JSON_CALLS, (full, body) => {
188
+ const parsed = dsmlJson(body);
189
+ const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []);
190
+ for (const c of list) {
191
+ const fn = (c && c.function) || c || {};
192
+ const name = fn.name || (c && c.name) || '';
193
+ if (!name) continue;
194
+ let args = fn.arguments != null ? fn.arguments : (c && c.arguments);
195
+ if (typeof args === 'string') args = dsmlJson(args);
196
+ calls.push({ name, args: args && typeof args === 'object' ? args : {} });
197
+ }
198
+ return '';
199
+ });
200
+
201
+ // 3. The wrapper tags carry no call of their own.
202
+ text = text.replace(DSML_RE_LEADER, '');
203
+ return { text, calls };
204
+ }
205
+
206
+ /**
207
+ * Last-resort decode for markup that drifted out of well-formedness: a missing
208
+ * closing tag, or a stream cut mid-call. The call still names a tool and its
209
+ * parameters are still readable, so it is decoded rather than deleted — a call
210
+ * that reached the wire must not vanish because its closing tag did.
211
+ */
212
+ function decodeDeepSeekDSMLDrift(input) {
213
+ if (typeof input !== 'string' || !input || input.indexOf('|') === -1) return { text: input || '', calls: [] };
214
+ const opens = [];
215
+ DSML_RE_DRIFT_INVOKE.lastIndex = 0;
216
+ let m;
217
+ while ((m = DSML_RE_DRIFT_INVOKE.exec(input))) {
218
+ opens.push({ at: m.index, attrs: m[1], body: DSML_RE_DRIFT_INVOKE.lastIndex });
219
+ }
220
+ if (!opens.length) return { text: input, calls: [] };
221
+ const calls = [];
222
+ let text = '';
223
+ let cursor = 0;
224
+ for (let i = 0; i < opens.length; i++) {
225
+ // One call runs to the next `invoke` head, or to the end of the text.
226
+ const end = i + 1 < opens.length ? opens[i + 1].at : input.length;
227
+ const body = input.slice(opens[i].body, end);
228
+ const a = dsmlAttrs(opens[i].attrs);
229
+ const name = a.name || a.function || a.tool || '';
230
+ const args = {};
231
+ const params = [];
232
+ DSML_RE_DRIFT_PARAM.lastIndex = 0;
233
+ let pm;
234
+ while ((pm = DSML_RE_DRIFT_PARAM.exec(body))) {
235
+ params.push({ at: pm.index, attrs: pm[1], from: DSML_RE_DRIFT_PARAM.lastIndex });
236
+ }
237
+ for (let k = 0; k < params.length; k++) {
238
+ // A value runs to the next parameter tag — its closing tag may be the
239
+ // thing that never arrived.
240
+ const stop = k + 1 < params.length ? params[k + 1].at : body.length;
241
+ const raw = body.slice(params[k].from, stop).replace(DSML_RE_PARAM_CLOSE, '');
242
+ const pa = dsmlAttrs(params[k].attrs);
243
+ const key = pa.name || pa.key || `arg${Object.keys(args).length}`;
244
+ const isJson = String(pa.string || '').toLowerCase() === 'false';
245
+ args[key] = isJson ? dsmlJson(raw) : raw;
246
+ }
247
+ text += input.slice(cursor, opens[i].at);
248
+ cursor = end;
249
+ if (name) calls.push({ name, args });
250
+ }
251
+ text += input.slice(cursor);
252
+ return { text, calls };
253
+ }
254
+
255
+ /**
256
+ * Residue removal for text that is about to be shown. Two spellings have to go:
257
+ * a leftover envelope token (`|DSML|`, `|tool▁calls▁begin|`, `|Assistant|`),
258
+ * and the skeleton an earlier release left behind by stripping the delimiters
259
+ * out of a DSML block — `<tool_calls> <invoke name="write"> <parameter …>`,
260
+ * which is exactly what a user pasted back at us.
261
+ */
262
+ function dsmlResidue(text) {
263
+ if (!text) return text;
264
+ if (text.indexOf('|') === -1) return text.replace(DSML_RE_SKELETON, '');
265
+ return text.replace(DSML_RE_ANY_TAG, '').replace(DSML_RE_SKELETON, '').replace(DSML_RE_ENVELOPE_TOKEN, '');
266
+ }
267
+
268
+ /**
269
+ * Is the buffer, read from the first `|` in it, a construct that is still
270
+ * arriving? Only two shapes may be held back: a pipe whose partner has not
271
+ * arrived, and a DSML opener whose matching close has not landed. Everything
272
+ * else is prose — a `|` in an answer is a character, not a promise of markup,
273
+ * and holding the rest of the answer behind it would swallow the reply.
274
+ */
275
+ function dsmlUnterminated(s) {
276
+ if (DSML_RE_OPEN_TOKEN.test(s)) return true;
277
+ const openers = (s.match(DSML_RE_STRUCT_TAG) || []).length;
278
+ const closers = (s.match(DSML_RE_STRUCT_CLOSER) || []).length;
279
+ return openers > closers;
280
+ }
281
+
282
+ /**
283
+ * Where, if anywhere, an in-flight DSML construct still needs the rest of the
284
+ * buffer held back from view. By the time this runs, decodeDeepSeekDSML() has
285
+ * already removed every COMPLETE invoke/parameter span and the caller has
286
+ * already removed every complete non-invoke wrapper tag (DSML_RE_WRAPPER_TAG),
287
+ * so whatever is left can only be: clean prose, or the start of exactly ONE
288
+ * incomplete construct running to the end of the buffer — an opener whose own
289
+ * '|' has arrived but not its '>' (`<\s*\/?\s*|`, found wherever it starts,
290
+ * not just at the tail: a Write's `content` can be arbitrarily long, so the
291
+ * unresolved opener is usually far behind the cursor, not at it), a '<' that
292
+ * arrived with no '|' yet (the chunk boundary landed first), or a bare pipe
293
+ * token still accumulating its identifier (the plain envelope-leak case, e.g.
294
+ * '|tool▁cal'). Returns -1 when none apply — safe to release in full.
295
+ */
296
+ function dsmlHoldFrom(buf) {
297
+ const tagStart = buf.search(/<\s*\/?\s*|/);
298
+ if (tagStart !== -1) return tagStart;
299
+ const lt = DSML_RE_TRAILING_LT.exec(buf);
300
+ if (lt) return lt.index;
301
+ const openIdx = buf.indexOf('|');
302
+ if (openIdx !== -1 && dsmlUnterminated(buf.slice(openIdx))) return openIdx;
303
+ return -1;
304
+ }
305
+
306
+ /**
307
+ * The per-turn stripper this transport uses. Feed it each `delta.content`
308
+ * chunk: it returns the text that is safe to show, holds back whatever is
309
+ * still arriving (a Write's `content` parameter is a whole file — it belongs
310
+ * to the call, not to the screen), and collects the tool calls the model made
311
+ * in text form. `flush()` releases the tail once the stream is drained, and
312
+ * `calls()` returns everything decoded so far, which the caller merges into
313
+ * the turn's tool calls so a Write that arrived as DSML is executed like any
314
+ * other.
72
315
  */
73
- const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
74
316
  function makeDeepSeekEnvelopeStripper() {
75
317
  let buf = '';
318
+ const calls = [];
76
319
  const strip = (chunk) => {
77
- buf += chunk;
78
- buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
79
- const openIdx = buf.lastIndexOf('|');
80
- if (openIdx !== -1) {
81
- const tail = buf.slice(openIdx);
82
- if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
83
- const out = buf.slice(0, openIdx);
84
- buf = tail;
85
- return out;
86
- }
320
+ buf += typeof chunk === 'string' ? chunk : '';
321
+ const dec = decodeDeepSeekDSML(buf);
322
+ buf = dec.text;
323
+ for (const c of dec.calls) calls.push(c);
324
+ buf = buf.replace(DSML_RE_WRAPPER_TAG, '');
325
+ const hold = dsmlHoldFrom(buf);
326
+ let out;
327
+ if (hold !== -1 && buf.length - hold <= DSML_HOLD_MAX) {
328
+ out = buf.slice(0, hold);
329
+ buf = buf.slice(hold);
330
+ } else {
331
+ out = buf;
332
+ buf = '';
87
333
  }
88
- const out = buf;
89
- buf = '';
90
- return out;
334
+ return dsmlResidue(out);
91
335
  };
92
336
  strip.flush = () => {
93
- const out = buf;
337
+ const dec = decodeDeepSeekDSML(buf);
94
338
  buf = '';
95
- return out;
339
+ for (const c of dec.calls) calls.push(c);
340
+ const drift = decodeDeepSeekDSMLDrift(dec.text);
341
+ for (const c of drift.calls) calls.push(c);
342
+ return dsmlResidue(drift.text);
96
343
  };
344
+ /** Every tool call decoded from text so far, as `{ name, args }`. */
345
+ strip.calls = () => calls.slice();
97
346
  return strip;
98
347
  }
99
348
 
@@ -463,12 +712,29 @@ async function chat({
463
712
  const message = { role: 'assistant', content };
464
713
  if (toolCalls.length) message.tool_calls = toolCalls;
465
714
 
466
- return {
715
+ // DSML calls arrive as plain text, decoded by stripEnvelope rather than
716
+ // accumulated by index like structured delta.tool_calls — merged into one
717
+ // normalised `{ id, name, args }` list so a call that arrived as DSML (a
718
+ // locally-pulled deepseek-* model with no provider-native delta.tool_calls
719
+ // at all) is executed like any other. engine.js's extractToolCalls() reads
720
+ // this field first, ahead of message.tool_calls.
721
+ const dsmlCalls = stripEnvelope.calls();
722
+ const result = {
467
723
  id: 'local',
468
724
  model: String(model),
469
725
  choices: [{ index: 0, message, finish_reason: toolCalls.length ? 'tool_calls' : 'stop' }],
470
726
  ...(usage ? { usage } : {}),
471
727
  };
728
+ if (toolCalls.length || dsmlCalls.length) {
729
+ result.toolCalls = [
730
+ ...toolCalls.map((c) => ({ id: c.id, name: c.function.name, args: c.function.arguments })),
731
+ ...dsmlCalls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
732
+ ];
733
+ if (dsmlCalls.length && result.choices[0].finish_reason !== 'tool_calls') {
734
+ result.choices[0].finish_reason = 'tool_calls';
735
+ }
736
+ }
737
+ return result;
472
738
  }
473
739
 
474
740
  module.exports = {
@@ -61,7 +61,11 @@ const MAIN_CHAT_PROMPT =
61
61
 
62
62
  /** The tools the desktop actually advertises (kept in step with tools.js). */
63
63
  const TOOL_LINE =
64
- 'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute; ' +
64
+ 'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute and ' +
65
+ 'must be written in the form THIS machine uses — read the platform and home directory in the ' +
66
+ 'Environment block below and use them: on win32 that means C:\\Users\\you\\Desktop\\file.txt, ' +
67
+ 'not /home/you/Desktop/file.txt or /tmp/file.txt. "~" and "~/…" mean the home directory and are ' +
68
+ 'expanded for you; a relative path resolves against the working directory. ' +
65
69
  'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
66
70
  'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
67
71
  '(its own tool loop, same model) for a focused, self-contained piece of work.';
@@ -72,11 +76,25 @@ const TOOL_LINE =
72
76
  * the identity block.
73
77
  */
74
78
  function environmentPreamble(env = {}) {
75
- const { platform, arch, homedir, cwd, roots, appVersion, model } = env || {};
79
+ const { platform, arch, homedir, desktopdir, cwd, shell, shellKind, roots, appVersion, model } =
80
+ env || {};
76
81
  const bits = [];
77
82
  if (platform) bits.push(`platform: ${platform}${arch ? ` (${arch})` : ''}`);
78
83
  if (homedir) bits.push(`home directory: ${homedir}`);
84
+ // Named explicitly because `~/Desktop` is a GUESS that is wrong on two of the
85
+ // three platforms this app ships to: OneDrive folder redirection moves it on
86
+ // Windows, and a localised Linux session translates the directory itself
87
+ // (a Swedish desktop has ~/Skrivbord and no ~/Desktop). "Put this on my
88
+ // desktop" is a real request, and this is the line that makes it land.
89
+ if (desktopdir) {
90
+ bits.push(
91
+ `desktop directory: ${desktopdir} — use this exact path when the user says "my desktop"; ` +
92
+ 'do NOT build it yourself from the home directory, because it is not always a folder ' +
93
+ 'called "Desktop" there',
94
+ );
95
+ }
79
96
  if (cwd) bits.push(`working directory: ${cwd}`);
97
+ if (shell) bits.push(`exec shell: ${shell}${shellKind ? ` (${shellKind})` : ''}`);
80
98
  if (model) bits.push(`model: ${model}`);
81
99
  if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
82
100
 
@@ -86,10 +104,40 @@ function environmentPreamble(env = {}) {
86
104
  if (rootList.length) {
87
105
  lines.push(`repo roots:\n${rootList.map((r) => ` - ${r}`).join('\n')}`);
88
106
  }
107
+ // The command dialect, stated as an instruction rather than left for the model
108
+ // to infer from `platform: win32`. Inference is what failed: a model handed a
109
+ // Windows machine still reached for `ls`, `cat`, `grep` and `mkdir -p`, none
110
+ // of which exist in PowerShell, so every exec call failed until it guessed.
111
+ const dialect = commandDialectLine(platform, shellKind);
112
+ if (dialect) lines.push(dialect);
89
113
  if (!lines.length) return '';
90
114
  return `# Environment\n${lines.join('\n')}`;
91
115
  }
92
116
 
117
+ /**
118
+ * How `exec` commands must be written on this host. Empty when the platform is
119
+ * unknown — a guess here would be worse than silence.
120
+ */
121
+ function commandDialectLine(platform, shellKind) {
122
+ if (!platform) return '';
123
+ if (platform === 'win32') {
124
+ return (
125
+ `This is Windows: exec runs ${shellKind === 'powershell' ? 'PowerShell' : 'a Windows shell'}, ` +
126
+ 'so use Windows-native commands — Get-ChildItem/dir, Get-Content/type, Select-String/findstr, ' +
127
+ 'where, New-Item -ItemType Directory — NOT ls, cat, grep, mkdir -p, touch or rm -rf, which do ' +
128
+ 'not exist here. Separate paths with \\, and never assume /tmp, /usr or /home exist.'
129
+ );
130
+ }
131
+ if (platform === 'darwin') {
132
+ return (
133
+ 'This is macOS: exec runs a POSIX shell and the standard Unix commands are available, but the ' +
134
+ 'userland is BSD, not GNU — sed -i needs an argument (sed -i \'\'), and readlink -f, ' +
135
+ 'date -d and sed -r are absent. Prefer portable forms, or python3.'
136
+ );
137
+ }
138
+ return 'This is a POSIX host: the standard Unix commands are available in exec.';
139
+ }
140
+
93
141
  /**
94
142
  * The full system prompt for a desktop chat turn: identity + work rules +
95
143
  * the tool line + the environment block. Never empty.
@@ -105,5 +153,6 @@ module.exports = {
105
153
  MAIN_CHAT_PROMPT,
106
154
  TOOL_LINE,
107
155
  environmentPreamble,
156
+ commandDialectLine,
108
157
  buildSystemPrompt,
109
158
  };