aegis-desktop 1.0.13 → 1.0.15

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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
@@ -1,225 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * companion.js — the one greeting the app writes to you, once a day.
10
- *
11
- * WHAT THIS IS NOT. It is not a notification, a pop-up, a badge, or anything
12
- * that interrupts. It is one line in the sidebar that changes at most once per
13
- * local day and can be dismissed for that day. A "daily reminder" that steals
14
- * focus is a nag; the whole value here is that it is quiet enough to read.
15
- *
16
- * THE RULE THAT SHAPES EVERY MESSAGE: it may only say things this process
17
- * actually knows. There is no engagement metric in this file, no invented
18
- * streak, no "you usually work on Tuesdays". Every candidate message reads its
19
- * facts from the `facts` object the caller passes, and a message whose facts
20
- * are absent is not a candidate at all — it is never shown with a guess in the
21
- * blank. That is why `pickMessage` returns `{ kind, ... }` rather than a
22
- * pre-baked string: the copy is chosen by which facts exist, and each branch
23
- * says exactly which ones it used.
24
- *
25
- * Determinism matters for the daily behaviour: the same local day and the same
26
- * facts must select the same message, or a re-render (which happens on every
27
- * status read) would flip the suggestion while the user is looking at it. The
28
- * rotation between equally-valid tips is therefore seeded from the day key,
29
- * never from Math.random().
30
- *
31
- * Pure: no DOM, no Electron, no fs, no clock of its own — `now` is passed in.
32
- */
33
-
34
- /**
35
- * The local calendar day, as YYYY-MM-DD.
36
- *
37
- * Local, not UTC, on purpose: "a daily reminder" means the user's day. A UTC
38
- * key would roll the message over at 01:00 or 02:00 for most of Europe, which
39
- * is the middle of the evening for the people most likely to be running this.
40
- */
41
- function dayKey(now) {
42
- const d = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
43
- if (Number.isNaN(d.getTime())) return '';
44
- const p = (n) => String(n).padStart(2, '0');
45
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
46
- }
47
-
48
- /** Whole local days between two timestamps; null when either is unusable. */
49
- function daysBetween(a, b) {
50
- const t1 = a instanceof Date ? a.getTime() : Number(a);
51
- const t2 = b instanceof Date ? b.getTime() : Number(b);
52
- if (!Number.isFinite(t1) || !Number.isFinite(t2)) return null;
53
- const d1 = new Date(t1); d1.setHours(0, 0, 0, 0);
54
- const d2 = new Date(t2); d2.setHours(0, 0, 0, 0);
55
- return Math.round((d2.getTime() - d1.getTime()) / 86400000);
56
- }
57
-
58
- /** Stable small hash, so tip rotation is deterministic per day. */
59
- function hash(s) {
60
- let h = 2166136261;
61
- const str = String(s);
62
- for (let i = 0; i < str.length; i += 1) {
63
- h ^= str.charCodeAt(i);
64
- h = Math.imul(h, 16777619);
65
- }
66
- return h >>> 0;
67
- }
68
-
69
- /**
70
- * Tips that are TRUE OF THIS BUILD — each one names a real affordance. They are
71
- * the fallback rotation once nothing needs attention; none of them is a "come
72
- * back and use the app more" nudge, because that would be the app asking for
73
- * the user's time rather than giving them something back.
74
- */
75
- const TIPS = Object.freeze([
76
- { id: 'effort', text: 'Effort sizes the call — low for quick edits, high when the answer has to be complete.' },
77
- { id: 'tools', text: 'Turn on "confirm before running tools" in Tool approvals to see every command before it runs.' },
78
- { id: 'quick', text: 'The Quick Launcher shortcut opens a prompt from anywhere — no need to switch windows.' },
79
- { id: 'sessions', text: 'Sessions → Export writes the thread out as a file you keep, not something held in the app.' },
80
- { id: 'queue', text: 'Queued tasks run on their own class — they can stay on Aegis Cloud while you work locally.' },
81
- { id: 'memory', text: 'Memory entries are yours: the inspector shows the source and tags for every one.' },
82
- { id: 'local', text: 'Local model needs no key and no network — useful for anything you would rather not send anywhere.' },
83
- ]);
84
-
85
- /**
86
- * Choose today's message.
87
- *
88
- * `facts` (all optional; each one absent merely removes its branch):
89
- * keyConfigured {boolean} an AEGIS API key exists on this machine
90
- * modelCount {number} how many models the current class listed
91
- * lastTurnAt {number} epoch ms of the last finished turn, if any
92
- * firstSeenAt {number} epoch ms this install first ran
93
- * queuePending {number} unfinished queued tasks
94
- * memoryCount {number} memory entries visible in the inspector
95
- * providerNeedsKey {boolean} byok class has models but no provider key
96
- * updatePending {string} version string of a downloaded update, if any
97
- *
98
- * Returns `{ id, kind, text, usedFacts }` — `kind` is 'action' when the message
99
- * points at something concrete to do and 'tip' when it is just the rotation.
100
- */
101
- function pickMessage(facts, now) {
102
- const f = facts && typeof facts === 'object' ? facts : {};
103
- const day = dayKey(now);
104
- const used = [];
105
- const pick = (id, kind, text, keys) => ({
106
- id,
107
- kind,
108
- text,
109
- day,
110
- usedFacts: keys.filter((k) => f[k] !== undefined && f[k] !== null),
111
- });
112
-
113
- // 1. An update that is downloaded and waiting is the single most actionable
114
- // thing the app can know: it is finished work sitting idle until restart.
115
- if (typeof f.updatePending === 'string' && f.updatePending) {
116
- return pick(
117
- 'update-ready',
118
- 'action',
119
- `Update ${f.updatePending} is downloaded — restart when it suits you, it installs on quit.`,
120
- ['updatePending'],
121
- );
122
- }
123
-
124
- // 2. No key at all: the app can barely do anything, and the remedy is one
125
- // paste. Not an error message — a reminder is allowed to be the first
126
- // place the user hears it, so it is phrased as an invitation.
127
- if (f.keyConfigured === false) {
128
- return pick(
129
- 'connect',
130
- 'action',
131
- 'Paste your AEGIS API key in Status to run turns on Aegis Cloud — or pick Local model below for no key at all.',
132
- ['keyConfigured'],
133
- );
134
- }
135
-
136
- // 3. A provider class chosen with models listed but no provider key: the user
137
- // has already walked to the door and has not opened it.
138
- if (f.providerNeedsKey === true) {
139
- return pick(
140
- 'provider-key',
141
- 'action',
142
- 'Your provider class has models listed but no key saved — add one in Provider settings to use them.',
143
- ['providerNeedsKey'],
144
- );
145
- }
146
-
147
- // 4. Queued work still waiting. Counted, never estimated: "3 queued" is a
148
- // fact, "your queue is busy" would be a guess about progress.
149
- if (typeof f.queuePending === 'number' && f.queuePending > 0) {
150
- return pick(
151
- 'queue',
152
- 'action',
153
- `${f.queuePending} queued task${f.queuePending === 1 ? '' : 's'} still waiting — the queue card runs ${f.queuePending === 1 ? 'it' : 'them'} on its own class.`,
154
- ['queuePending'],
155
- );
156
- }
157
-
158
- // 4b. Memory this install already holds. Documented in the facts list from
159
- // the day this file was written, but with no branch behind it — so a user
160
- // with twenty saved entries was told nothing about them and got a generic
161
- // tip instead. Counted exactly, and it points at the inspector, which is
162
- // the only place in the app where the provenance of each entry is shown.
163
- if (typeof f.memoryCount === 'number' && f.memoryCount > 0) {
164
- return pick(
165
- 'memory-held',
166
- 'action',
167
- `${f.memoryCount} memory entr${f.memoryCount === 1 ? 'y is' : 'ies are'} already saved here — the inspector shows the source and tags for every one.`,
168
- ['memoryCount'],
169
- );
170
- }
171
-
172
- // 5. First-run, no key required path: say what the app is, once. Only on the
173
- // FIRST local day, so it does not become a permanent fixture.
174
- const age = daysBetween(f.firstSeenAt, now);
175
- if (age === 0 && f.lastTurnAt == null) {
176
- return pick(
177
- 'first-day',
178
- 'tip',
179
- 'First day. Pick a class and a model in the Model card, then send a turn — everything else here is optional.',
180
- ['firstSeenAt', 'lastTurnAt'],
181
- );
182
- }
183
-
184
- // 6. A gap after real use. Reported only when there is a genuine last turn,
185
- // and only past a threshold where the observation is worth stating; below
186
- // it, nothing is said at all (fall through to the rotation).
187
- const since = daysBetween(f.lastTurnAt, now);
188
- if (since != null && since >= 3) {
189
- return pick(
190
- 'returning',
191
- 'tip',
192
- 'Last turn was a few days ago — your sessions and queued tasks are still here.',
193
- ['lastTurnAt'],
194
- );
195
- }
196
-
197
- // 7. Nothing needs attention: rotate one true tip, seeded by the day so it is
198
- // stable for the whole day and different tomorrow.
199
- const tip = TIPS[hash(day) % TIPS.length];
200
- return pick(`tip-${tip.id}`, 'tip', tip.text, ['tipRotation']);
201
- }
202
-
203
- /** True when this message was already dismissed today. */
204
- function dismissedToday(dismissed, now) {
205
- const d = dismissed && typeof dismissed === 'object' ? dismissed : null;
206
- if (!d) return false;
207
- return d.day === dayKey(now);
208
- }
209
-
210
- if (typeof module !== 'undefined' && module.exports) {
211
- module.exports = { pickMessage, dayKey, daysBetween, dismissedToday, TIPS };
212
- }
213
-
214
- // Also a page global, because index.html loads this file as a classic script
215
- // (`<script src="companion.js">`) and app.js reads window.aegisCompanion. The
216
- // module branch above is for Node (test/renderer-providers.test.mjs requires
217
- // the file directly); the two are mutually exclusive in practice — a classic
218
- // script has no `module` — so this cannot clobber the module export.
219
- if (typeof window !== 'undefined') {
220
- (window.aegisCompanion = window.aegisCompanion || {}).pickMessage = pickMessage;
221
- window.aegisCompanion.dayKey = dayKey;
222
- window.aegisCompanion.daysBetween = daysBetween;
223
- window.aegisCompanion.dismissedToday = dismissedToday;
224
- window.aegisCompanion.TIPS = TIPS;
225
- }
3
+ "use strict";function dayKey(e){const t=e instanceof Date?e:new Date(null==e?Date.now():e);if(Number.isNaN(t.getTime()))return"";const n=e=>String(e).padStart(2,"0");return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())}`}function daysBetween(e,t){const n=e instanceof Date?e.getTime():Number(e),o=t instanceof Date?t.getTime():Number(t);if(!Number.isFinite(n)||!Number.isFinite(o))return null;const s=new Date(n);s.setHours(0,0,0,0);const i=new Date(o);return i.setHours(0,0,0,0),Math.round((i.getTime()-s.getTime())/864e5)}function hash(e){let t=2166136261;const n=String(e);for(let e=0;e<n.length;e+=1)t^=n.charCodeAt(e),t=Math.imul(t,16777619);return t>>>0}const TIPS=Object.freeze([{id:"effort",text:"Effort sizes the call — low for quick edits, high when the answer has to be complete."},{id:"tools",text:'Turn on "confirm before running tools" in Tool approvals to see every command before it runs.'},{id:"quick",text:"The Quick Launcher shortcut opens a prompt from anywhere — no need to switch windows."},{id:"sessions",text:"Sessions → Export writes the thread out as a file you keep, not something held in the app."},{id:"queue",text:"Queued tasks run on their own class — they can stay on Aegis Cloud while you work locally."},{id:"memory",text:"Memory entries are yours: the inspector shows the source and tags for every one."},{id:"local",text:"Local model needs no key and no network — useful for anything you would rather not send anywhere."}]);function pickMessage(e,t){const n=e&&"object"==typeof e?e:{},o=dayKey(t),s=(e,t,s,i)=>({id:e,kind:t,text:s,day:o,usedFacts:i.filter((e=>void 0!==n[e]&&null!==n[e]))});if("string"==typeof n.updatePending&&n.updatePending)return s("update-ready","action",`Update ${n.updatePending} is downloaded — restart when it suits you, it installs on quit.`,["updatePending"]);if(!1===n.keyConfigured)return s("connect","action","Paste your AEGIS API key in Status to run turns on Aegis Cloud — or pick Local model below for no key at all.",["keyConfigured"]);if(!0===n.providerNeedsKey)return s("provider-key","action","Your provider class has models listed but no key saved — add one in Provider settings to use them.",["providerNeedsKey"]);if("number"==typeof n.queuePending&&n.queuePending>0)return s("queue","action",`${n.queuePending} queued task${1===n.queuePending?"":"s"} still waiting — the queue card runs ${1===n.queuePending?"it":"them"} on its own class.`,["queuePending"]);if("number"==typeof n.memoryCount&&n.memoryCount>0)return s("memory-held","action",`${n.memoryCount} memory entr${1===n.memoryCount?"y is":"ies are"} already saved here — the inspector shows the source and tags for every one.`,["memoryCount"]);if(0===daysBetween(n.firstSeenAt,t)&&null==n.lastTurnAt)return s("first-day","tip","First day. Pick a class and a model in the Model card, then send a turn — everything else here is optional.",["firstSeenAt","lastTurnAt"]);const i=daysBetween(n.lastTurnAt,t);if(null!=i&&i>=3)return s("returning","tip","Last turn was a few days ago — your sessions and queued tasks are still here.",["lastTurnAt"]);const r=TIPS[hash(o)%TIPS.length];return s(`tip-${r.id}`,"tip",r.text,["tipRotation"])}function dismissedToday(e,t){const n=e&&"object"==typeof e?e:null;return!!n&&n.day===dayKey(t)}"undefined"!=typeof module&&module.exports&&(module.exports={pickMessage:pickMessage,dayKey:dayKey,daysBetween:daysBetween,dismissedToday:dismissedToday,TIPS:TIPS}),"undefined"!=typeof window&&((window.aegisCompanion=window.aegisCompanion||{}).pickMessage=pickMessage,window.aegisCompanion.dayKey=dayKey,window.aegisCompanion.daysBetween=daysBetween,window.aegisCompanion.dismissedToday=dismissedToday,window.aegisCompanion.TIPS=TIPS);
package/renderer/diff.js CHANGED
@@ -1,361 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * Line-level diff for the transcript's edit blocks.
10
- *
11
- * An `editFile`/`writeFile` call used to render as a single gray row
12
- * (" ⎿ → editFile src/hello.js ✓"): you could see that a file changed and
13
- * never *what* changed — the one thing a coding session is actually about.
14
- * This module turns the tool call's arguments into a real unified-style diff,
15
- * which diffview.js paints as a collapsible colored block.
16
- *
17
- * Where the two sides come from:
18
- * editFile — args.old_string → args.new_string. No I/O at all.
19
- * writeFile — the file as it was on disk *before* the call ran, against
20
- * args.content. app.js builds the preview when the tool's
21
- * `phase: 'run'` event arrives, which is strictly before the
22
- * executor writes, so the read is the pre-edit state.
23
- *
24
- * Pure: strings in, plain row objects out — no spans, no colors, no globals —
25
- * so the whole thing is unit-testable with no TTY. The DOM painting lives in
26
- * diffview.js, exactly the split render.js already keeps between structure and
27
- * paint.
28
- *
29
- * Ported from `cli/src/diff.js`; the engine's tool names are
30
- * `Edit`/`Write`/`MultiEdit`, this host's are `editFile`/`writeFile`, so
31
- * `editPreview` accepts both spellings. `MultiEdit` has no counterpart here
32
- * and is kept only so a newer engine frame cannot silently render nothing.
33
- *
34
- * `fs`/`path` are Node-only and the renderer is sandboxed (main.js sets
35
- * contextIsolation:true, nodeIntegration:false, sandbox:true), so `require`
36
- * may not exist at all when this loads as a classic <script>. The require is
37
- * therefore guarded and the pre-edit read is injectable (`opts.readFile`):
38
- * without either a Node fs or an injected reader a `Write` degrades to a
39
- * create-style diff instead of throwing — a preview is decoration, and
40
- * decoration must never break a turn.
41
- */
42
-
43
- // Guarded so the same file loads both as a Node module (tests, CLI reuse) and
44
- // as a sandboxed classic script, where `require` is simply absent.
45
- let fs = null;
46
- let path = null;
47
- try {
48
- if (typeof require === 'function') {
49
- fs = require('node:fs');
50
- path = require('node:path');
51
- }
52
- } catch {
53
- fs = null; // sandboxed renderer: fall back to an injected readFile
54
- path = null;
55
- }
56
-
57
- // Budgets. The preview is built synchronously inside the tool event, on the
58
- // render path, so a 200 MB writeFile must not stall the window — the read is
59
- // capped and the LCS table is bounded so a pathological pair of long files
60
- // cannot allocate hundreds of megabytes. Past a budget the middle collapses to
61
- // "remove everything, add everything", which is honest about being incomplete
62
- // (`truncated: true` on the preview, `… N unchanged lines` in the block).
63
- const DIFF_LIMITS = {
64
- fileBytes: 256 * 1024, // bytes read from disk for writeFile
65
- lcsCells: 250000, // n*m cap for the LCS table (~1 MB of Int32)
66
- rows: 400, // diff rows kept per preview
67
- context: 3, // unchanged lines kept around each change
68
- };
69
-
70
- /**
71
- * Split text into display lines. A trailing newline is a terminator, not an
72
- * empty last line — otherwise every normal file diff ends in a phantom
73
- * "- " / "+ " pair. CRLF is normalized so a Windows checkout doesn't render as
74
- * "every line changed".
75
- */
76
- function splitLines(text) {
77
- const s = String(text == null ? '' : text).replace(/\r\n?/g, '\n');
78
- if (s === '') return [];
79
- const lines = s.split('\n');
80
- if (lines.length && lines[lines.length - 1] === '') lines.pop();
81
- return lines;
82
- }
83
-
84
- /**
85
- * Unified-style diff of two strings.
86
- *
87
- * @returns {{rows: Array<{sign: string, text: string, no: number|null,
88
- * marker?: boolean, note?: string}>, adds: number, dels: number,
89
- * truncated: boolean}} `rows` carries context rows around each change run
90
- * (DIFF_LIMITS.context lines), collapsed runs of unchanged lines as `marker`
91
- * rows, and every changed line with a `no` for the gutter (old number on
92
- * '-', new number otherwise).
93
- */
94
- function lineDiff(oldText, newText) {
95
- const a = splitLines(oldText);
96
- const b = splitLines(newText);
97
- let pre = 0;
98
- while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
99
- let post = 0;
100
- while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++;
101
-
102
- const midA = a.slice(pre, a.length - post);
103
- const midB = b.slice(pre, b.length - post);
104
- const ops = lcsOps(midA, midB);
105
- const truncated = ops === null;
106
- // Nothing changed: say so with no rows at all. The prefix/suffix trim ate the
107
- // whole file (pre === a.length, post === 0), so the context arithmetic below
108
- // would otherwise paint "… N unchanged lines" plus three orphans over an
109
- // identical pair — a marker claiming an omission that never happened. This
110
- // guard is what keeps `lineDiff(x, x)` from rendering as a fake edit.
111
-
112
- const body = ops || [
113
- ...midA.map((text) => ({ sign: '-', text })),
114
- ...midB.map((text) => ({ sign: '+', text })),
115
- ];
116
- if (!body.some((op) => op.sign !== ' ')) return { rows: [], adds: 0, dels: 0, truncated };
117
-
118
- const ctx = DIFF_LIMITS.context;
119
-
120
- const rows = [];
121
- let oldNo = 1;
122
- let newNo = 1;
123
- // Leading context: keep *all* of it when it fits, otherwise the last `ctx`
124
- // lines plus a marker for the ones actually dropped. The engine's original
125
- // condition (`ctxBeforeStart < pre`) fires whenever the change is preceded by
126
- // any line at all, so an ordinary three-line edit grew a bogus
127
- // "… 1 unchanged line" marker and a change deep in a file reported `ctx` as
128
- // the number hidden rather than the real count. A marker means "something was
129
- // omitted", so it may only appear when something was.
130
- const leadStart = pre > ctx ? pre - ctx : 0;
131
- const hiddenLead = leadStart;
132
- if (hiddenLead > 0) {
133
- rows.push({ sign: ' ', text: '', marker: true, note: `… ${hiddenLead} unchanged line${hiddenLead === 1 ? '' : 's'}` });
134
- }
135
- for (let i = leadStart; i < pre; i++) {
136
- rows.push({ sign: ' ', text: a[i], no: newNo });
137
- newNo++;
138
- oldNo++;
139
- }
140
- for (const op of body) {
141
- if (op.sign === ' ') {
142
- rows.push({ sign: ' ', text: op.text, no: newNo });
143
- newNo++;
144
- oldNo++;
145
- } else if (op.sign === '-') {
146
- rows.push({ sign: '-', text: op.text, no: oldNo });
147
- oldNo++;
148
- } else {
149
- rows.push({ sign: '+', text: op.text, no: newNo });
150
- newNo++;
151
- }
152
- }
153
- // Suffix context: the first `ctx` lines of the untouched tail, then a marker
154
- // for whatever unchanged tail is left over. Both the index into `b` and the
155
- // gutter number come from `b`'s tail, not `a`'s: the engine read
156
- // `b[a.length - post + i]`, which is the wrong line — and the wrong numbering
157
- // — for every edit that changes the line count.
158
- const tailStart = b.length - post;
159
- const tailCount = Math.min(post, ctx);
160
- newNo = tailStart + 1;
161
- for (let i = 0; i < tailCount; i++) {
162
- rows.push({ sign: ' ', text: b[tailStart + i], no: newNo });
163
- newNo++;
164
- oldNo++;
165
- }
166
- if (post > ctx) {
167
- const n = post - ctx;
168
- rows.push({ sign: ' ', text: '', marker: true, note: `… ${n} unchanged line${n === 1 ? '' : 's'}` });
169
- }
170
-
171
- const adds = rows.filter((r) => r.sign === '+').length;
172
- const dels = rows.filter((r) => r.sign === '-').length;
173
- let out = rows;
174
- let cut = false;
175
- if (out.length > DIFF_LIMITS.rows) {
176
- out = out.slice(0, DIFF_LIMITS.rows);
177
- out.push({ sign: ' ', text: '', marker: true, note: `… ${rows.length - DIFF_LIMITS.rows} more diff lines` });
178
- cut = true;
179
- }
180
- return { rows: out, adds, dels, truncated: truncated || cut };
181
- }
182
-
183
- /**
184
- * Longest-common-subsequence walk over two line arrays.
185
- * @returns {Array<{sign: string, text: string}>|null} ops, or null when the
186
- * table would exceed DIFF_LIMITS.lcsCells (the caller then treats the middle
187
- * as a full replacement).
188
- */
189
- function lcsOps(a, b) {
190
- const n = a.length;
191
- const m = b.length;
192
- if (!n || !m) {
193
- return [
194
- ...a.map((text) => ({ sign: '-', text })),
195
- ...b.map((text) => ({ sign: '+', text })),
196
- ];
197
- }
198
- if (n * m > DIFF_LIMITS.lcsCells) return null;
199
- const w = m + 1;
200
- const dp = new Int32Array((n + 1) * w); // dp[i*w+j] = |LCS(a[i:], b[j:])|
201
- for (let i = n - 1; i >= 0; i--) {
202
- for (let j = m - 1; j >= 0; j--) {
203
- dp[i * w + j] = a[i] === b[j]
204
- ? dp[(i + 1) * w + (j + 1)] + 1
205
- : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
206
- }
207
- }
208
- const ops = [];
209
- let i = 0;
210
- let j = 0;
211
- while (i < n && j < m) {
212
- if (a[i] === b[j]) { ops.push({ sign: ' ', text: a[i] }); i++; j++; }
213
- else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) { ops.push({ sign: '-', text: a[i] }); i++; }
214
- else { ops.push({ sign: '+', text: b[j] }); j++; }
215
- }
216
- while (i < n) ops.push({ sign: '-', text: a[i++] });
217
- while (j < m) ops.push({ sign: '+', text: b[j++] });
218
- return ops;
219
- }
220
-
221
- /** Capped read for the pre-edit state. Never throws; missing/unreadable → null. */
222
- function readCapped(file, opts = {}) {
223
- // No Node fs (sandboxed renderer) → report missing so a Write degrades to a
224
- // create-style diff rather than throwing on the render path.
225
- if (!fs) return { text: null, missing: true };
226
- const bytes = opts.bytes == null ? DIFF_LIMITS.fileBytes : opts.bytes;
227
- try {
228
- const st = fs.statSync(file);
229
- if (!st.isFile()) return { text: null, missing: true };
230
- const text = fs.readFileSync(file, 'utf8');
231
- if (st.size > bytes) return { text: text.slice(0, bytes), missing: false, truncated: true };
232
- return { text, missing: false };
233
- } catch {
234
- return { text: null, missing: true };
235
- }
236
- }
237
-
238
- /** Path as the user thinks of it: relative to the session cwd when it is inside it. */
239
- function shortPath(file, cwd) {
240
- const f = String(file || '');
241
- if (!cwd || !path) return f; // no node:path in the sandbox → show the raw path
242
- const rel = path.relative(cwd, f);
243
- if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
244
- const parts = f.split(path.sep).filter(Boolean);
245
- const base = f.startsWith(path.sep) ? path.sep : '';
246
- return parts.length > 2 ? base + '…' + path.sep + parts.slice(-2).join(path.sep) : f;
247
- }
248
-
249
- /**
250
- * Build the diff preview for a tool call, or null when the call isn't an edit
251
- * or its arguments don't carry enough to diff (a partial stream, a malformed
252
- * call). Never throws — a preview is decoration, not the tool result, so a bad
253
- * argument object must not be able to break a turn.
254
- *
255
- * @param {string} name Tool name — this host's `editFile`/`writeFile`, or the
256
- * engine's `Edit`/`Write`/`MultiEdit` (a vendored frame can carry either).
257
- * @param {object|string} args Tool arguments as parsed from the stream.
258
- * @param {{cwd?: string, readFile?: Function}} [opts] `readFile` is injectable
259
- * so tests never touch disk and the sandboxed renderer can inject a bridge.
260
- */
261
- function editPreview(name, args, opts = {}) {
262
- try {
263
- const cwd = opts.cwd == null ? (typeof process !== 'undefined' ? process.cwd() : '') : opts.cwd;
264
- const readFile = opts.readFile || readCapped;
265
- if (!args || typeof args !== 'object') return null;
266
- const file = typeof args.file_path === 'string' ? args.file_path
267
- : typeof args.path === 'string' ? args.path : null;
268
- if (!file) return null;
269
- const base = { file, short: shortPath(file, cwd) };
270
-
271
- if (name === 'Edit' || name === 'editFile') {
272
- if (typeof args.old_string !== 'string' || typeof args.new_string !== 'string') return null;
273
- return Object.assign(
274
- { kind: 'Update' },
275
- base,
276
- lineDiff(args.old_string, args.new_string),
277
- { replaceAll: !!args.replace_all },
278
- );
279
- }
280
-
281
- if (name === 'Write' || name === 'writeFile') {
282
- if (typeof args.content !== 'string') return null;
283
- const prev = readFile(file) || { text: null, missing: true };
284
- const d = lineDiff(prev.text == null ? '' : prev.text, args.content);
285
- return Object.assign(
286
- { kind: prev.missing ? 'Create' : 'Write' },
287
- base,
288
- d,
289
- { truncated: d.truncated || !!prev.truncated },
290
- );
291
- }
292
-
293
- if (name === 'MultiEdit') {
294
- const edits = (Array.isArray(args.edits) ? args.edits : []).filter(
295
- (e) => e && typeof e.old_string === 'string' && typeof e.new_string === 'string',
296
- );
297
- if (!edits.length) return null;
298
- const prev = readFile(file) || { text: null, missing: true };
299
- if (typeof prev.text === 'string') {
300
- let cur = prev.text;
301
- let misses = 0;
302
- for (const e of edits) {
303
- if (cur.indexOf(e.old_string) === -1) { misses++; continue; }
304
- cur = e.replace_all
305
- ? cur.split(e.old_string).join(e.new_string)
306
- : cur.replace(e.old_string, e.new_string);
307
- }
308
- const d = lineDiff(prev.text, cur);
309
- return Object.assign(
310
- { kind: 'Update' },
311
- base,
312
- d,
313
- { misses, replaceAll: edits.some((e) => e.replace_all) },
314
- );
315
- }
316
- // No base on disk: stack each edit's own diff rather than inventing one.
317
- let rows = [];
318
- let adds = 0;
319
- let dels = 0;
320
- let truncated = false;
321
- edits.forEach((e, i) => {
322
- const d = lineDiff(e.old_string, e.new_string);
323
- if (i) rows.push({ sign: ' ', text: '', marker: true, note: `… edit ${i + 1} of ${edits.length}` });
324
- rows = rows.concat(d.rows);
325
- adds += d.adds;
326
- dels += d.dels;
327
- truncated = truncated || d.truncated;
328
- });
329
- return Object.assign(
330
- { kind: 'Update' },
331
- base,
332
- { rows, adds, dels, truncated, stacked: edits.length },
333
- );
334
- }
335
-
336
- return null;
337
- } catch {
338
- return null; // decoration must never break a turn
339
- }
340
- }
341
-
342
- /** Human count line in the reference's wording: "Added 3 lines, removed 1 line". */
343
- function changeSummary(preview) {
344
- const parts = [];
345
- if (preview.adds) parts.push(`Added ${preview.adds} line${preview.adds === 1 ? '' : 's'}`);
346
- if (preview.dels) parts.push(`removed ${preview.dels} line${preview.dels === 1 ? '' : 's'}`);
347
- return parts.length ? parts.join(', ') : 'no line changes';
348
- }
349
-
350
- if (typeof module !== 'undefined' && module.exports) {
351
- module.exports = {
352
- DIFF_LIMITS,
353
- splitLines,
354
- lineDiff,
355
- lcsOps,
356
- readCapped,
357
- shortPath,
358
- changeSummary,
359
- editPreview,
360
- };
361
- }
3
+ "use strict";let fs=null,path=null;try{"function"==typeof require&&(fs=require("node:fs"),path=require("node:path"))}catch{fs=null,path=null}const DIFF_LIMITS={fileBytes:262144,lcsCells:25e4,rows:400,context:3};function splitLines(t){const e=String(null==t?"":t).replace(/\r\n?/g,"\n");if(""===e)return[];const n=e.split("\n");return n.length&&""===n[n.length-1]&&n.pop(),n}function lineDiff(t,e){const n=splitLines(t),s=splitLines(e);let i=0;for(;i<n.length&&i<s.length&&n[i]===s[i];)i++;let l=0;for(;l<n.length-i&&l<s.length-i&&n[n.length-1-l]===s[s.length-1-l];)l++;const r=n.slice(i,n.length-l),o=s.slice(i,s.length-l),a=lcsOps(r,o),c=null===a,u=a||[...r.map((t=>({sign:"-",text:t}))),...o.map((t=>({sign:"+",text:t})))];if(!u.some((t=>" "!==t.sign)))return{rows:[],adds:0,dels:0,truncated:c};const p=DIFF_LIMITS.context,f=[];let g=1,d=1;const h=i>p?i-p:0,x=h;x>0&&f.push({sign:" ",text:"",marker:!0,note:`… ${x} unchanged line${1===x?"":"s"}`});for(let t=h;t<i;t++)f.push({sign:" ",text:n[t],no:d}),d++,g++;for(const t of u)" "===t.sign?(f.push({sign:" ",text:t.text,no:d}),d++,g++):"-"===t.sign?(f.push({sign:"-",text:t.text,no:g}),g++):(f.push({sign:"+",text:t.text,no:d}),d++);const m=s.length-l,I=Math.min(l,p);d=m+1;for(let t=0;t<I;t++)f.push({sign:" ",text:s[m+t],no:d}),d++,g++;if(l>p){const t=l-p;f.push({sign:" ",text:"",marker:!0,note:`… ${t} unchanged line${1===t?"":"s"}`})}const y=f.filter((t=>"+"===t.sign)).length,_=f.filter((t=>"-"===t.sign)).length;let F=f,w=!1;return F.length>DIFF_LIMITS.rows&&(F=F.slice(0,DIFF_LIMITS.rows),F.push({sign:" ",text:"",marker:!0,note:`… ${f.length-DIFF_LIMITS.rows} more diff lines`}),w=!0),{rows:F,adds:y,dels:_,truncated:c||w}}function lcsOps(t,e){const n=t.length,s=e.length;if(!n||!s)return[...t.map((t=>({sign:"-",text:t}))),...e.map((t=>({sign:"+",text:t})))];if(n*s>DIFF_LIMITS.lcsCells)return null;const i=s+1,l=new Int32Array((n+1)*i);for(let r=n-1;r>=0;r--)for(let n=s-1;n>=0;n--)l[r*i+n]=t[r]===e[n]?l[(r+1)*i+(n+1)]+1:Math.max(l[(r+1)*i+n],l[r*i+(n+1)]);const r=[];let o=0,a=0;for(;o<n&&a<s;)t[o]===e[a]?(r.push({sign:" ",text:t[o]}),o++,a++):l[(o+1)*i+a]>=l[o*i+(a+1)]?(r.push({sign:"-",text:t[o]}),o++):(r.push({sign:"+",text:e[a]}),a++);for(;o<n;)r.push({sign:"-",text:t[o++]});for(;a<s;)r.push({sign:"+",text:e[a++]});return r}function readCapped(t,e={}){if(!fs)return{text:null,missing:!0};const n=null==e.bytes?DIFF_LIMITS.fileBytes:e.bytes;try{const e=fs.statSync(t);if(!e.isFile())return{text:null,missing:!0};const s=fs.readFileSync(t,"utf8");return e.size>n?{text:s.slice(0,n),missing:!1,truncated:!0}:{text:s,missing:!1}}catch{return{text:null,missing:!0}}}function shortPath(t,e){const n=String(t||"");if(!e||!path)return n;const s=path.relative(e,n);if(s&&!s.startsWith("..")&&!path.isAbsolute(s))return s;const i=n.split(path.sep).filter(Boolean),l=n.startsWith(path.sep)?path.sep:"";return i.length>2?l+"…"+path.sep+i.slice(-2).join(path.sep):n}function editPreview(t,e,n={}){try{const s=null==n.cwd?"undefined"!=typeof process?process.cwd():"":n.cwd,i=n.readFile||readCapped;if(!e||"object"!=typeof e)return null;const l="string"==typeof e.file_path?e.file_path:"string"==typeof e.path?e.path:null;if(!l)return null;const r={file:l,short:shortPath(l,s)};if("Edit"===t||"editFile"===t)return"string"!=typeof e.old_string||"string"!=typeof e.new_string?null:Object.assign({kind:"Update"},r,lineDiff(e.old_string,e.new_string),{replaceAll:!!e.replace_all});if("Write"===t||"writeFile"===t){if("string"!=typeof e.content)return null;const t=i(l)||{text:null,missing:!0},n=lineDiff(null==t.text?"":t.text,e.content);return Object.assign({kind:t.missing?"Create":"Write"},r,n,{truncated:n.truncated||!!t.truncated})}if("MultiEdit"===t){const t=(Array.isArray(e.edits)?e.edits:[]).filter((t=>t&&"string"==typeof t.old_string&&"string"==typeof t.new_string));if(!t.length)return null;const n=i(l)||{text:null,missing:!0};if("string"==typeof n.text){let e=n.text,s=0;for(const n of t)-1!==e.indexOf(n.old_string)?e=n.replace_all?e.split(n.old_string).join(n.new_string):e.replace(n.old_string,n.new_string):s++;const i=lineDiff(n.text,e);return Object.assign({kind:"Update"},r,i,{misses:s,replaceAll:t.some((t=>t.replace_all))})}let s=[],o=0,a=0,c=!1;return t.forEach(((e,n)=>{const i=lineDiff(e.old_string,e.new_string);n&&s.push({sign:" ",text:"",marker:!0,note:`… edit ${n+1} of ${t.length}`}),s=s.concat(i.rows),o+=i.adds,a+=i.dels,c=c||i.truncated})),Object.assign({kind:"Update"},r,{rows:s,adds:o,dels:a,truncated:c,stacked:t.length})}return null}catch{return null}}function changeSummary(t){const e=[];return t.adds&&e.push(`Added ${t.adds} line${1===t.adds?"":"s"}`),t.dels&&e.push(`removed ${t.dels} line${1===t.dels?"":"s"}`),e.length?e.join(", "):"no line changes"}"undefined"!=typeof module&&module.exports&&(module.exports={DIFF_LIMITS:DIFF_LIMITS,splitLines:splitLines,lineDiff:lineDiff,lcsOps:lcsOps,readCapped:readCapped,shortPath:shortPath,changeSummary:changeSummary,editPreview:editPreview});