aegis-desktop 0.8.24 → 0.8.27

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.
@@ -95,6 +95,30 @@ try {
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * The module that decides where this install's data directory IS
100
+ * (`$AEGISCODE_HOME`, else `~/.aegiscode`) — the same two-layout resolution as
101
+ * `platformLib` above, and for the same reason: the environment block must
102
+ * state the path this machine actually writes, not a path this file guessed.
103
+ *
104
+ * Reported live (DeepSeek, Windows, 2026-10-04): asked to check the AEGIS data
105
+ * directory, the model produced `C:\Users\Dator.aegiscode` — the home directory
106
+ * with the separator EATEN — and then ran
107
+ * `Get-ChildItem -Filter aegis -Recurse -Depth 2` over the whole profile when
108
+ * that path came back empty. Nothing in the prompt had ever named the real
109
+ * directory, so building it from the home directory was the only move it had.
110
+ */
111
+ let credentialsLib = null;
112
+ try {
113
+ credentialsLib = require('../../../client/credentials.js');
114
+ } catch {
115
+ try {
116
+ credentialsLib = require('../../vendor/credentials.js');
117
+ } catch {
118
+ credentialsLib = null;
119
+ }
120
+ }
121
+
98
122
  /**
99
123
  * The environment facts the model needs to stop asking which OS it is on.
100
124
  *
@@ -167,11 +191,54 @@ function hostEnv(base = {}, supplied = {}, payload = null) {
167
191
  } catch {
168
192
  /* no desktop line */
169
193
  }
194
+ // The data directory (`$AEGISCODE_HOME`, else `~/.aegiscode`), read from the
195
+ // module every host already resolves it through. Stated rather than left to
196
+ // be built from `homedir`: a model that concatenates home + ".aegiscode"
197
+ // itself is one dropped separator away from `Dator.aegiscode`, a path that
198
+ // does not exist, and it has no way to tell the difference from the answer.
199
+ let dataDir = b.dataDir;
200
+ try {
201
+ if (!dataDir && credentialsLib) dataDir = credentialsLib.aegisHome();
202
+ } catch {
203
+ /* no data-directory line */
204
+ }
205
+ // The user's own locale, from the module that already resolves home and
206
+ // Desktop (client/platform.js appLocale). Stated for the same reason the
207
+ // Desktop path is: it is a fact about THIS machine that the model cannot
208
+ // derive from `platform: win32`, and until it is stated the model mirrors
209
+ // whatever language the message arrived in and defaults to the prompt's
210
+ // English otherwise — so a Swedish user had no way to stop being greeted in
211
+ // English, and a one-word request ("städa") had no language to be read in.
212
+ // Best-effort: a failed probe simply omits the line.
213
+ let locale = b.locale;
214
+ let localeName = b.localeName;
215
+ try {
216
+ if (!locale && platformLib && typeof platformLib.appLocale === 'function') {
217
+ locale = platformLib.appLocale();
218
+ }
219
+ if (locale && !localeName && platformLib && typeof platformLib.languageName === 'function') {
220
+ localeName = platformLib.languageName(locale);
221
+ }
222
+ } catch {
223
+ /* no locale line */
224
+ }
170
225
  return {
171
226
  platform: pick('platform', b.platform || process.platform),
172
227
  arch: pick('arch', b.arch || process.arch),
173
228
  homedir: pick('homedir', homedir),
174
229
  desktopdir: pick('desktopdir', desktopdir),
230
+ dataDir: pick('dataDir', dataDir),
231
+ locale: pick('locale', locale),
232
+ localeName: pick('localeName', localeName),
233
+ // Durable language EVIDENCE, which is a different thing from `locale` and is
234
+ // passed through rather than probed: `locale` is where the machine is set
235
+ // up, this is what the holder's own saved writing is in. A host that has
236
+ // folded the avatar profile (lib/avatar/profile.js foldLanguage, which
237
+ // already emits "writes durably in Swedish (sv), so reply in Swedish unless
238
+ // asked otherwise") hands the facets in here; a host with no avatar passes
239
+ // nothing and gets the exact pre-locale turn. Shapes: a `{ code, name }`,
240
+ // a `{ key, value }` facet, or a bare string.
241
+ languages: pick('languages', b.languages),
175
242
  cwd: pick('cwd', cwd),
176
243
  shell: pick('shell', shellName),
177
244
  shellKind: pick('shellKind', shellKind),
@@ -1486,12 +1553,20 @@ function createLocalEngine({
1486
1553
  // and always disposed when the turn ends.
1487
1554
  let shell = null;
1488
1555
  const getShell = () => shell || (shell = new ShellSession({ cwd: envFor(payload).cwd }));
1489
- const toolCtx = { getShell, signal };
1490
1556
  // The turn's working directory. It rides on every tool frame because a
1491
1557
  // tool row's args are the command or the path — never the directory the
1492
1558
  // command runs in — which left "where is it working in" unanswerable from
1493
- // the transcript.
1494
- const turnCwd = envFor(payload).cwd;
1559
+ // the transcript. It also has to ride on `toolCtx`, which is what
1560
+ // normalizeArgs (tools.js) actually resolves a RELATIVE readFile/writeFile/
1561
+ // editFile/listDir/glob/grep path against — omitting `cwd` here meant that
1562
+ // resolution fell back to the HOST PROCESS's own cwd (the Electron install
1563
+ // dir off Linux, per baseDir()'s comment in tools.js, which fixed this for
1564
+ // the no-path default but never for a path the model actually supplied).
1565
+ // `desktopdir` rides the same way so resolvePath can correct a model's
1566
+ // literal "Desktop" guess to the real one (platform.js's own alias fix).
1567
+ const turnEnv = envFor(payload);
1568
+ const turnCwd = turnEnv.cwd;
1569
+ const toolCtx = { getShell, signal, cwd: turnCwd, desktopdir: turnEnv.desktopdir };
1495
1570
 
1496
1571
  try {
1497
1572
  // byok's settings row lives under the provider named IN THE MODEL id
@@ -70,16 +70,86 @@ const TOOL_LINE =
70
70
  'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
71
71
  '(its own tool loop, same model) for a focused, self-contained piece of work.';
72
72
 
73
+ /**
74
+ * One durable-language entry as a sentence fragment, accepting the three shapes
75
+ * a host can hand in without this file growing a dependency on the avatar
76
+ * module that produces them: a bare string, `{ code, name }`, or a folded
77
+ * profile facet `{ key, value }` (whose `value` is already prose —
78
+ * "writes durably in Swedish (sv), so reply in Swedish unless asked otherwise" —
79
+ * and is preferred as-is rather than re-rendered into something longer).
80
+ */
81
+ function languageLine(entry) {
82
+ if (typeof entry === 'string') return entry.trim();
83
+ if (!entry || typeof entry !== 'object') return '';
84
+ if (typeof entry.value === 'string' && entry.value.trim()) return entry.value.trim();
85
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
86
+ const code = typeof entry.code === 'string' ? entry.code.trim() : typeof entry.key === 'string' ? entry.key.trim() : '';
87
+ if (name && code) return `${name} (${code})`;
88
+ return name || code;
89
+ }
90
+
73
91
  /**
74
92
  * Render the environment preamble. Everything is optional: a missing field is
75
93
  * simply omitted, so a headless caller can build a persona with nothing but
76
94
  * the identity block.
77
95
  */
78
96
  function environmentPreamble(env = {}) {
79
- const { platform, arch, homedir, desktopdir, cwd, shell, shellKind, roots, appVersion, model } =
80
- env || {};
97
+ const {
98
+ platform,
99
+ arch,
100
+ homedir,
101
+ desktopdir,
102
+ dataDir,
103
+ locale,
104
+ localeName,
105
+ languages,
106
+ cwd,
107
+ shell,
108
+ shellKind,
109
+ roots,
110
+ appVersion,
111
+ model,
112
+ } = env || {};
81
113
  const bits = [];
82
114
  if (platform) bits.push(`platform: ${platform}${arch ? ` (${arch})` : ''}`);
115
+ // The other half of "who and where is this machine": the platform line says
116
+ // what it runs, this says what language its user is in. It was the ONE
117
+ // environment fact a turn never carried, and the consequence was visible —
118
+ // the model mirrored whatever language the message arrived in and otherwise
119
+ // fell back to this prompt's English, so a Swedish user could not stop being
120
+ // greeted in English and a one-word request ("städa") arrived with no
121
+ // language to be read in.
122
+ if (locale) {
123
+ const base = String(locale).split('-')[0].toLowerCase();
124
+ const name = localeName ? ` (${localeName})` : '';
125
+ // The instruction is only load-bearing for a NON-English host: English is
126
+ // this prompt's own language, so "reply in English" would be noise that
127
+ // competes with whatever language the request is actually written in.
128
+ bits.push(
129
+ base === 'en'
130
+ ? `locale: ${locale}${name}`
131
+ : `locale: ${locale}${name} — the user's own machine and account run in this language, ` +
132
+ 'so expect requests written in it and answer in the language the user writes to you ' +
133
+ 'in. Use this to resolve an otherwise ambiguous request (a bare path, a pasted ' +
134
+ 'error, a one-word command) — never to override a request written in another language.'
135
+ );
136
+ }
137
+ // Durable EVIDENCE, which is deliberately a separate line from `locale`: one
138
+ // is how the machine is configured, the other is what the user's own saved
139
+ // writing is in, and they can disagree (an English-configured machine used by
140
+ // someone who writes Swedish all day). The request in front of the model
141
+ // still outranks both, so the line says so rather than reading as an order to
142
+ // switch languages.
143
+ const langLines = (Array.isArray(languages) ? languages : [])
144
+ .map((entry) => languageLine(entry))
145
+ .filter(Boolean);
146
+ if (langLines.length) {
147
+ bits.push(
148
+ `recorded language evidence from the user's own saved writing: ${langLines.join('; ')} — ` +
149
+ 'the language of the request in front of you always wins; this is evidence, not an ' +
150
+ 'instruction to switch'
151
+ );
152
+ }
83
153
  if (homedir) bits.push(`home directory: ${homedir}`);
84
154
  // Named explicitly because `~/Desktop` is a GUESS that is wrong on two of the
85
155
  // three platforms this app ships to: OneDrive folder redirection moves it on
@@ -93,6 +163,20 @@ function environmentPreamble(env = {}) {
93
163
  'called "Desktop" there',
94
164
  );
95
165
  }
166
+ // Same failure mode as the desktop line, one directory over, and reported
167
+ // live: asked about the AEGIS data directory, DeepSeek emitted
168
+ // `C:\Users\Dator.aegiscode` — the home directory with the separator dropped —
169
+ // got an empty listing, and concluded the store did not exist. The path is
170
+ // given verbatim AND the wrong form is named, because a model reading a
171
+ // missing path cannot tell a typo from an empty directory.
172
+ if (dataDir) {
173
+ bits.push(
174
+ `AEGIS data directory: ${dataDir} — this is the store itself (config, sessions, ` +
175
+ 'credentials, queue). Use this exact path; do NOT append it to the home directory ' +
176
+ 'yourself, because a dropped separator ("C:\\Users\\you.aegiscode") names a path that ' +
177
+ 'does not exist and looks like an empty directory rather than a mistake',
178
+ );
179
+ }
96
180
  if (cwd) bits.push(`working directory: ${cwd}`);
97
181
  if (shell) bits.push(`exec shell: ${shell}${shellKind ? ` (${shellKind})` : ''}`);
98
182
  if (model) bits.push(`model: ${model}`);
@@ -375,7 +375,19 @@ function normalizeArgs(name, args, ctx = {}) {
375
375
  for (const key of keys) {
376
376
  const value = input[key];
377
377
  if (typeof value !== 'string' || !value.trim()) continue; // absent → the executor's default
378
- const res = platform.resolvePath(value, { cwd: ctx && ctx.cwd ? ctx.cwd : undefined });
378
+ const res = platform.resolvePath(value, {
379
+ // The tool layer's platform seam, the same one `client/platform.js`
380
+ // takes on every function and `shell.js` already passes at its call
381
+ // site: `ctx.platform` names the host whose path rules apply. Absent
382
+ // (every existing caller), `resolvePath` falls back to
383
+ // `process.platform`, so this changes nothing at runtime — but it is
384
+ // what lets the win32 and darwin branches of THIS layer be exercised
385
+ // from a Linux CI, instead of being reachable only on a foreign host.
386
+ // See test/platform-parity.test.mjs.
387
+ platform: ctx && ctx.platform ? ctx.platform : undefined,
388
+ cwd: ctx && ctx.cwd ? ctx.cwd : undefined,
389
+ desktopdir: ctx && ctx.desktopdir ? ctx.desktopdir : undefined,
390
+ });
379
391
  if (!res.ok) return { ok: false, args: input, error: `${name}: ${key} — ${res.error}` };
380
392
  if (res.path !== value) {
381
393
  if (out === input) out = { ...input };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.24",
4
+ "version": "0.8.27",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -38,6 +38,7 @@
38
38
  "test:budget": "node ../test/budget.test.mjs",
39
39
  "test:foreign-memory": "node ../test/foreign-memory.test.mjs",
40
40
  "test:tools": "node ../test/local-tools.test.mjs",
41
+ "test:platform-parity": "node ../test/platform-parity.test.mjs",
41
42
  "test:engine": "node ../test/local-engine.test.mjs",
42
43
  "test:highlight": "node ../test/aegis-highlight.test.mjs",
43
44
  "test:markdown": "node ../test/markdown.test.mjs",
@@ -438,6 +438,97 @@ function hasDisplay(options = {}) {
438
438
  // homeDir / resolvePath
439
439
  // ---------------------------------------------------------------------------
440
440
 
441
+ /**
442
+ * Normalise anything a machine calls a locale into a BCP-47 `ll` / `ll-CC`
443
+ * code, or `''` for "no answer".
444
+ *
445
+ * Four spellings reach this function and only the first is already usable:
446
+ * `sv-SE`, the POSIX `sv_SE.UTF-8` (and `sv_SE.utf8`, `sv_SE`), the GNU
447
+ * `LANGUAGE` list (`sv:en:de` — the caller splits it), and the two settings
448
+ * that mean NOT a language: `C` and `POSIX`, which say "no locale configured"
449
+ * and must never render as a language called "c". The modifier tail
450
+ * (`@euro`, `@latin`) is dropped: it changes collation, not language.
451
+ */
452
+ function normalizeLocale(value) {
453
+ if (value == null) return '';
454
+ const raw = String(value).trim();
455
+ if (!raw) return '';
456
+ const head = raw.split('@')[0].split('.')[0];
457
+ if (/^(c|posix)$/i.test(head)) return '';
458
+ const m = /^([A-Za-z]{2,3})(?:[_-]([A-Za-z0-9]{2,8}))?$/.exec(head);
459
+ if (!m) return '';
460
+ const lang = m[1].toLowerCase();
461
+ if (lang === 'c' || lang === 'posix') return '';
462
+ return m[2] ? `${lang}-${m[2].toUpperCase()}` : lang;
463
+ }
464
+
465
+ /**
466
+ * The language the user's own machine and account run in — `''` when nothing
467
+ * can say.
468
+ *
469
+ * The model has always been told the platform, the home directory and the real
470
+ * Desktop path, but never this, so a Swedish user's DeepSeek turn started with
471
+ * no field saying the machine is Swedish: the model mirrored whatever language
472
+ * arrived in the message and defaulted to the prompt's English otherwise.
473
+ * Mirrored language is not the same as known language — an up-front
474
+ * `locale: sv-SE` is what lets it answer a Swedish one-word request ("städa")
475
+ * in Swedish instead of asking which language to use.
476
+ *
477
+ * POSIX order is the order below (`LC_ALL` overrides `LC_CTYPE` overrides
478
+ * `LC_MESSAGES` overrides `LANG`); `LANGUAGE` is a GNU preference LIST and is
479
+ * consulted last only for its first entry. Windows sets none of these, and
480
+ * macOS sets none of them for an app launched from Finder, so `Intl` is the
481
+ * fallback — it reads the ICU default the runtime was started with, which is
482
+ * the same answer the OS gives the app. An `env` the caller INJECTED is trusted
483
+ * as given (never topped up from `Intl`), which is what makes a test with
484
+ * `env: {}` deterministic instead of dependent on the CI runner's locale.
485
+ */
486
+ function appLocale(options = {}) {
487
+ const { platform, env, o } = seams(options);
488
+ if (typeof o.locale === 'string') return normalizeLocale(o.locale);
489
+ const injected = o.env && typeof o.env === 'object';
490
+ for (const key of ['LC_ALL', 'LC_CTYPE', 'LC_MESSAGES', 'LANG']) {
491
+ const hit = normalizeLocale(envValue(env, key, platform));
492
+ if (hit) return hit;
493
+ }
494
+ const list = String(envValue(env, 'LANGUAGE', platform) || '');
495
+ if (list) {
496
+ // A colon list is a PREFERENCE order, and an empty field in it (":de" — how
497
+ // a launch script that cleared the first entry leaves it) means "skip this
498
+ // one", not "the answer is nothing".
499
+ for (const part of list.split(':')) {
500
+ const first = normalizeLocale(part);
501
+ if (first) return first;
502
+ }
503
+ }
504
+ if (injected) return ''; // the caller owns the environment; it said nothing
505
+ try {
506
+ return normalizeLocale(new Intl.DateTimeFormat().resolvedOptions().locale);
507
+ } catch {
508
+ return '';
509
+ }
510
+ }
511
+
512
+ /**
513
+ * The English name of a language code — `Swedish` for `sv`, `''` when the
514
+ * runtime has no display table for it (Node without full ICU). Named in
515
+ * ENGLISH on purpose: it is interpolated into the English system prompt, so a
516
+ * Swedish host must not render it as "svenska".
517
+ */
518
+ function languageName(code, options = {}) {
519
+ const { o } = seams(options);
520
+ if (typeof o.languageName === 'string') return o.languageName;
521
+ const hit = normalizeLocale(code);
522
+ if (!hit) return '';
523
+ const base = hit.split('-')[0];
524
+ try {
525
+ const display = new Intl.DisplayNames(['en'], { type: 'language' });
526
+ return display.of(base) || '';
527
+ } catch {
528
+ return '';
529
+ }
530
+ }
531
+
441
532
  /**
442
533
  * This user's home directory, `''` when nothing can say what it is.
443
534
  *
@@ -628,6 +719,36 @@ function desktopDir(options = {}) {
628
719
  return '';
629
720
  }
630
721
 
722
+ /**
723
+ * If `work` names this user's home "Desktop" folder by its generic literal
724
+ * name — the one a model guesses, in English, regardless of locale or
725
+ * OneDrive redirection — rewrite it to the REAL desktop directory the caller
726
+ * already asked Windows/XDG for (`desktopDir()` above).
727
+ *
728
+ * The environment preamble already NAMES the real directory and tells the
729
+ * model to copy it verbatim rather than build it — but that is advisory, and
730
+ * a model that still writes `~/Desktop/notes.txt` was silently creating (and
731
+ * reporting success into) a spurious English "Desktop" folder next to the
732
+ * user's real one, which on a redirected or localised Windows session is a
733
+ * different, empty directory the user never sees. This is the opposite of
734
+ * desktopDir()'s own job: that function stops US from guessing a locale name;
735
+ * this stops a model's literal "Desktop" guess from winning when we were
736
+ * already told the real answer.
737
+ *
738
+ * No-op when no `desktopdir` was supplied, when it is simply `home/Desktop`
739
+ * (nothing to correct), or when `work` is not rooted at the guessed path.
740
+ */
741
+ function applyDesktopAlias(work, { home, desktopdir, win32, P }) {
742
+ if (!home || !desktopdir) return work;
743
+ const guessed = P.join(home, 'Desktop');
744
+ const fold = (s) => (win32 ? s.toLowerCase() : s);
745
+ if (fold(guessed) === fold(desktopdir)) return work;
746
+ if (fold(work) === fold(guessed)) return desktopdir;
747
+ const prefix = guessed + P.sep;
748
+ if (fold(work).startsWith(fold(prefix))) return desktopdir + P.sep + work.slice(prefix.length);
749
+ return work;
750
+ }
751
+
631
752
  /** `C:\…` / `C:/…` — a drive-qualified Windows path. */
632
753
  const WIN_DRIVE_RE = /^[A-Za-z]:[\\/]/;
633
754
  /** `C:` with nothing after it — drive-relative, and never what a model means. */
@@ -703,6 +824,12 @@ function resolvePath(input, options = {}) {
703
824
  );
704
825
  }
705
826
 
827
+ // ── the desktop alias ─────────────────────────────────────────────────────
828
+ // Runs before the wrong-OS check below because a corrected `work` is always
829
+ // native by construction (desktopDir() returns a real path for THIS
830
+ // platform), exactly like a `~` expansion already is.
831
+ work = applyDesktopAlias(work, { home: homeDir(options), desktopdir: o.desktopdir, win32, P });
832
+
706
833
  // ── absolute-for-the-wrong-OS ─────────────────────────────────────────────
707
834
  // Skipped after a `~` expansion: that result is native by construction.
708
835
  if (!expandedHome) {
@@ -755,6 +882,9 @@ const api = {
755
882
  desktopDir,
756
883
  resolvePath,
757
884
  hasPosixModes,
885
+ appLocale,
886
+ languageName,
887
+ normalizeLocale,
758
888
  };
759
889
 
760
890
  module.exports = api;
@@ -288,6 +288,31 @@ function recordExchange(dir, exchange) {
288
288
  });
289
289
  }
290
290
 
291
+ /**
292
+ * Stamp a conversation's name.
293
+ *
294
+ * The name is what the session LIST shows (the desktop app, the MCP plugin and
295
+ * `/resume` all read it from here or from their own mirror), and it is NOT the
296
+ * raw first message: `recordExchange` seeds `title` with the prompt so a
297
+ * session has something to show before anything has named it, and this replaces
298
+ * that seed with the real name once one exists (the CLI's `naming.js`).
299
+ *
300
+ * An empty name is refused rather than written: a namer that failed must not
301
+ * blank a name that is already there.
302
+ */
303
+ function setTitle(dir, sessionId, title) {
304
+ const clean = String(title == null ? '' : title).trim().slice(0, 200);
305
+ if (!sessionId || !clean) return null;
306
+ return mutate(dir, (sessions) => {
307
+ const session = sessions[sessionId];
308
+ if (!session) return null;
309
+ session.title = clean;
310
+ session.updatedAt = Date.now();
311
+ session.seq = nextSeq(sessions);
312
+ return session;
313
+ });
314
+ }
315
+
291
316
  function listSessions(dir) {
292
317
  const sessions = load(dir);
293
318
  return Object.values(sessions)
@@ -389,10 +414,15 @@ function listSummaries(dir, limit = 8) {
389
414
  .map((s) => {
390
415
  const messages = Array.isArray(s.messages) ? s.messages : [];
391
416
  const first = messages.find((m) => m && m.role === 'user');
417
+ // A named session lists by its name. `title` is seeded from the prompt at
418
+ // record time, so for an unnamed session this is the same string the
419
+ // first message would have produced — no visible change — while a real
420
+ // name (setTitle) wins over the raw prompt, which is the whole point of
421
+ // naming a conversation.
392
422
  return {
393
423
  id: s.id,
394
424
  cwd: (s.cwd || '').split(/[\\/]/).filter(Boolean).pop() || '~',
395
- summary: String((first && first.content) || s.title || '').slice(0, 60),
425
+ summary: String(s.title || (first && first.content) || '').slice(0, 60),
396
426
  time: new Date(s.updatedAt || Date.now()).toISOString(),
397
427
  own: true,
398
428
  origin: s.origin || 'unknown',
@@ -499,6 +529,7 @@ module.exports = {
499
529
  upsertSession,
500
530
  appendMessage,
501
531
  recordExchange,
532
+ setTitle,
502
533
  listSessions,
503
534
  getSession,
504
535
  deleteSession,