aegis-desktop 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/lib/local/autonomous.js +45 -4
- package/lib/local/engine.js +38 -0
- package/lib/local/queue.js +4 -0
- package/lib/local/session-rounds.js +19 -2
- package/lib/settings.js +43 -0
- package/lib/sync/persist-gate.js +120 -0
- package/main.js +110 -6
- package/package.json +4 -2
- package/preload.js +31 -0
- package/renderer/app.js +107 -3
- package/renderer/diff.js +356 -0
- package/renderer/diffview.js +333 -0
- package/renderer/index.html +2 -0
- package/renderer/style.css +70 -0
package/README.md
CHANGED
|
@@ -45,6 +45,88 @@ queue that flushes on each "Sync now" or heartbeat retry. The **remember**
|
|
|
45
45
|
button on any assistant reply pins that message to cross-machine memory —
|
|
46
46
|
queued locally if you're offline.
|
|
47
47
|
|
|
48
|
+
## Autonomous queue
|
|
49
|
+
|
|
50
|
+
The unattended work queue, shared with the CLI: tasks are appended to
|
|
51
|
+
`~/.aegiscode/queue.jsonl` and drained later, one at a time, with tool approval
|
|
52
|
+
**disabled** — there is nobody there to click an approval card. The desktop
|
|
53
|
+
sidebar's queue card and `aegiscode autonomous` are two views of the same file.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
aegiscode autonomous add "fix the flaky retry test" --cwd ~/repo --commit
|
|
57
|
+
aegiscode autonomous list
|
|
58
|
+
aegiscode autonomous run # drain one task, then stop
|
|
59
|
+
aegiscode autonomous proceed --max 3 # drain up to three
|
|
60
|
+
aegiscode autonomous reconcile --auto # queue the next unfinished PLAN.md phase, then drain it
|
|
61
|
+
aegiscode autonomous retry <id> # put a finished task back
|
|
62
|
+
aegiscode autonomous clear --all # empty the queue
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A task's text is stored verbatim — `add "fix --json in the parser"` is a task,
|
|
66
|
+
not a flag. Drains commit only the paths that task's own tool layer wrote, so a
|
|
67
|
+
drain never sweeps a peer's in-flight edits into your commit.
|
|
68
|
+
|
|
69
|
+
### Aegis Cloud only
|
|
70
|
+
|
|
71
|
+
A queued task runs on the pooled brain — **`nexus-brain`** (alias
|
|
72
|
+
`aegis-brain`) — for the same reason the Claude Code plugin is cloud-only: the
|
|
73
|
+
queue hands work to a loop with no human in it, and the pooled class is the one
|
|
74
|
+
the server can route, budget, and bill on its own. A task that *states* another
|
|
75
|
+
model id is refused where you can still see it, instead of failing minutes into
|
|
76
|
+
a drain as an opaque server error:
|
|
77
|
+
|
|
78
|
+
| Where the model was stated | What happens |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `autonomous add --model <id>` | refused, with the reason, at add time — nothing is queued |
|
|
81
|
+
| A hand-edited `queue.jsonl` | refused pre-flight by the worker (`ms: 0`), before any turn is billed |
|
|
82
|
+
| `AEGIS_MODEL=<id>` in the environment | **ignored for queue runs**, and reported as a note — that variable is shared with the interactive surfaces, which *do* run direct providers |
|
|
83
|
+
| nothing stated | `nexus-brain` |
|
|
84
|
+
|
|
85
|
+
### What a queued task costs
|
|
86
|
+
|
|
87
|
+
The queue has two shapes, and the cheap one is the default:
|
|
88
|
+
|
|
89
|
+
| | Single pass (default) | Fan-out (opt in) |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Provider calls | 1 | `workers` + 1 (investigation passes, then synthesis) |
|
|
92
|
+
| Effort rung | `medium` | `high` |
|
|
93
|
+
| How to ask for it | nothing — it is the default | `AEGIS_AUTONOMOUS_FANOUT=1` |
|
|
94
|
+
|
|
95
|
+
An earlier version sent **every** queued task as a fan-out at the priciest rung:
|
|
96
|
+
one queued line could become several reasoning calls plus a synthesis, all at
|
|
97
|
+
`high`. Making the fan-out opt-in, and letting effort follow the shape of the
|
|
98
|
+
task rather than always topping out, removes the worker multiplication and
|
|
99
|
+
roughly halves the budget on a one-line task. `--effort` (or
|
|
100
|
+
`AEGIS_AUTONOMOUS_EFFORT`) still wins outright, and `--workers N` is only sent
|
|
101
|
+
when you are actually fanning out.
|
|
102
|
+
|
|
103
|
+
> **Known gap:** there is no `--fanout` flag yet — the opt-in is the environment
|
|
104
|
+
> variable or `singlePass: false` in the task record. `--single-pass` still
|
|
105
|
+
> parses, but it now agrees with the default instead of overriding it.
|
|
106
|
+
|
|
107
|
+
### A turn that runs out of rounds keeps its work
|
|
108
|
+
|
|
109
|
+
The tool loop runs against a round horizon: 24 rounds for an interactive turn
|
|
110
|
+
(`AEGIS_CHAT_MAX_ROUNDS`), 40 for a queued one
|
|
111
|
+
(`AEGIS_AUTONOMOUS_MAX_ROUNDS`). A model that reached it mid-turn used to lose
|
|
112
|
+
everything it had assembled, because the cap was *turn* state.
|
|
113
|
+
|
|
114
|
+
The horizon is now **session state**, held in `lib/local/session-rounds.js`. When
|
|
115
|
+
a turn stops at the cap the interruption is filed against the session, and the
|
|
116
|
+
next message in that conversation is prefixed with a continuation preamble —
|
|
117
|
+
*cut off after N tool rounds; continue, do not restart* — along with
|
|
118
|
+
`max(4, ⌈horizon/4⌉)` extra rounds so re-orientation does not eat the new
|
|
119
|
+
horizon. The resume is announced in the transcript, so it is visible rather
|
|
120
|
+
than silent.
|
|
121
|
+
|
|
122
|
+
- In-memory and **process-local** (30-minute TTL, 64 entries) — it never leaves
|
|
123
|
+
the process and never touches disk.
|
|
124
|
+
- Claiming an entry **consumes** it: one resume per interruption, so a chain of
|
|
125
|
+
interruptions is a chain of deliberate asks, never an automatic loop.
|
|
126
|
+
- A **stated** horizon always wins; the ledger only pads its own default.
|
|
127
|
+
- A caller that mints a fresh session key every turn adopts the most recent held
|
|
128
|
+
entry (bounded to 10 minutes) instead of losing the work.
|
|
129
|
+
|
|
48
130
|
## Keyboard shortcuts
|
|
49
131
|
|
|
50
132
|
### In the main window
|
|
@@ -124,6 +206,9 @@ main.js Electron main process — window + IPC shell only
|
|
|
124
206
|
preload.js Context-isolated IPC bridge exposed to the renderer
|
|
125
207
|
renderer/ UI (vanilla JS, no framework)
|
|
126
208
|
lib/local/ Model classes, providers, agentic tool loop, prompt
|
|
209
|
+
lib/local/queue.js The shared work queue (~/.aegiscode/queue.jsonl)
|
|
210
|
+
lib/local/autonomous.js The unattended worker — directive, digest, commits
|
|
211
|
+
lib/local/session-rounds.js Session-scoped tool-round ledger (in-memory)
|
|
127
212
|
lib/sync/ Local session/memory persistence + sync queue
|
|
128
213
|
vendor/aegis.js The AEGIS transport client (thin — no engine logic)
|
|
129
214
|
bin/aegis.js `aegis` CLI entry point for the global npm install
|
package/lib/local/autonomous.js
CHANGED
|
@@ -55,6 +55,15 @@ const gitScope = require('./git-scope.js');
|
|
|
55
55
|
/** Default tool-round horizon for an unattended turn (matches the engine's). */
|
|
56
56
|
const DEFAULT_ROUNDS = 40;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Consecutive tool-round-horizon stops a queued task gets before runOne gives
|
|
60
|
+
* up on it as unable to converge and settles it 'error' instead of 'pending'.
|
|
61
|
+
* Without this bound a task that never finishes would keep proceed()'s drain
|
|
62
|
+
* loop picking it back up forever — proceed() defaults to no --max at all, so
|
|
63
|
+
* nothing else would ever stop it.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_ROUND_STOPS = 3;
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
68
|
* The engine tools that report the file they write, and the argument holding
|
|
60
69
|
* it. This set is the attribution record for a task's commit: `exec` is absent
|
|
@@ -326,7 +335,8 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
|
|
|
326
335
|
*/
|
|
327
336
|
function digestLine(item, result) {
|
|
328
337
|
const task = String(item.task || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
329
|
-
const
|
|
338
|
+
const paused = Boolean(result && result.ok && result.stoppedOnRounds);
|
|
339
|
+
const mark = paused ? '⏸' : result && result.ok ? '✓' : '✗';
|
|
330
340
|
const files = (result && Array.isArray(result.files) && result.files.slice(0, 6)) || [];
|
|
331
341
|
const where = files.length ? ` — touched: ${files.join(', ')}${result.files.length > files.length ? ', …' : ''}` : '';
|
|
332
342
|
const why = !result || result.ok ? '' : ` — ${String((result && result.error) || 'failed').slice(0, 120)}`;
|
|
@@ -616,13 +626,41 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
616
626
|
const items = queue.loadQueue(env);
|
|
617
627
|
const claimed = queue.markRunning(items, item.id, { now: now() });
|
|
618
628
|
if (!claimed) return { ok: false, error: `unknown task #${item.id}`, carry };
|
|
629
|
+
const priorRoundStops = Number(claimed.roundStops) || 0;
|
|
619
630
|
queue.saveQueue(env, items);
|
|
620
631
|
|
|
621
|
-
const
|
|
632
|
+
const raw = await runTask(claimed, { carry, commit });
|
|
633
|
+
|
|
634
|
+
// A task that hit its tool-round horizon has NOT finished — engine.js's
|
|
635
|
+
// session ledger (session-rounds.js) is holding the rest of the work
|
|
636
|
+
// under this task's stable session id (sessionIdFor(item.id)), ready to
|
|
637
|
+
// inject a continuation preamble the moment this item is dispatched
|
|
638
|
+
// again. Settling it 'done' here (the old behavior) threw that away: the
|
|
639
|
+
// queue believed the task was finished, so nothing ever sent the next
|
|
640
|
+
// turn that would have consumed the resume, and a human had to notice
|
|
641
|
+
// the output was incomplete and re-queue the whole task from scratch.
|
|
642
|
+
//
|
|
643
|
+
// Left 'pending' instead, so proceed()'s own drain loop picks it straight
|
|
644
|
+
// back up — unless it has now failed to converge MAX_ROUND_STOPS times in
|
|
645
|
+
// a row, at which point looping on it forever (an unattended proceed()
|
|
646
|
+
// with no --max has no other cap at all) is worse than a visible error.
|
|
647
|
+
const hitRoundCap = Boolean(raw.ok && raw.stoppedOnRounds);
|
|
648
|
+
const roundStops = hitRoundCap ? priorRoundStops + 1 : 0;
|
|
649
|
+
const exhausted = hitRoundCap && roundStops > MAX_ROUND_STOPS;
|
|
650
|
+
const result = exhausted
|
|
651
|
+
? {
|
|
652
|
+
...raw,
|
|
653
|
+
ok: false,
|
|
654
|
+
error:
|
|
655
|
+
`stopped at its tool-round horizon ${roundStops} times in a row without finishing — ` +
|
|
656
|
+
'raise AEGIS_AUTONOMOUS_MAX_ROUNDS or split the task into smaller ones',
|
|
657
|
+
}
|
|
658
|
+
: raw;
|
|
659
|
+
const status = exhausted ? 'error' : hitRoundCap ? 'pending' : result.ok ? 'done' : 'error';
|
|
622
660
|
|
|
623
661
|
const after = queue.loadQueue(env);
|
|
624
662
|
queue.settle(after, claimed.id, {
|
|
625
|
-
status
|
|
663
|
+
status,
|
|
626
664
|
result: {
|
|
627
665
|
ok: result.ok,
|
|
628
666
|
output: result.output || '',
|
|
@@ -634,13 +672,15 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
634
672
|
error: result.ok ? null : result.error,
|
|
635
673
|
now: now(),
|
|
636
674
|
});
|
|
675
|
+
const settled = queue.findTask(after, claimed.id);
|
|
676
|
+
if (settled) settled.roundStops = roundStops;
|
|
637
677
|
queue.saveQueue(env, after);
|
|
638
678
|
queue.appendRun(env, {
|
|
639
679
|
id: claimed.id,
|
|
640
680
|
task: claimed.task,
|
|
641
681
|
cwd: claimed.cwd,
|
|
642
682
|
model: resolveModel({ model: claimed.model, env }),
|
|
643
|
-
status:
|
|
683
|
+
status: status === 'pending' ? 'stopped' : status,
|
|
644
684
|
at: new Date(now()).toISOString(),
|
|
645
685
|
ms: result.ms,
|
|
646
686
|
usage: result.usage || null,
|
|
@@ -722,6 +762,7 @@ function commitMessage(item) {
|
|
|
722
762
|
module.exports = {
|
|
723
763
|
DEFAULT_MODEL,
|
|
724
764
|
DEFAULT_ROUNDS,
|
|
765
|
+
MAX_ROUND_STOPS,
|
|
725
766
|
AEGIS_MODEL_IDS,
|
|
726
767
|
isAegisModel,
|
|
727
768
|
modelRefusal,
|
package/lib/local/engine.js
CHANGED
|
@@ -945,6 +945,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
945
945
|
|
|
946
946
|
const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
|
|
947
947
|
const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
|
|
948
|
+
// Index into `history` of everything THIS turn added as opposed to what the
|
|
949
|
+
// caller brought with it. Reset after the resume rehydration below, so a
|
|
950
|
+
// restored transcript is never re-recorded as if this turn had produced it
|
|
951
|
+
// (which would double it on every interruption in a chain).
|
|
952
|
+
let historyStart = history.length;
|
|
948
953
|
let prompt = (payload && payload.prompt) || '';
|
|
949
954
|
|
|
950
955
|
// Lazily start ONE shell session for this turn; the exec tool shares it so
|
|
@@ -1053,6 +1058,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1053
1058
|
}
|
|
1054
1059
|
if (resumed) {
|
|
1055
1060
|
const preamble = sessionRounds.resumePreamble(resumed);
|
|
1061
|
+
// Restore the interrupted turn's transcript, because the preamble alone
|
|
1062
|
+
// says HOW MUCH was done, not WHAT. The two callers need different
|
|
1063
|
+
// halves of it, so `session-rounds` files both (see `record`):
|
|
1064
|
+
//
|
|
1065
|
+
// · a caller who brings no conversation of its own — the queue
|
|
1066
|
+
// worker's fresh dispatch; autonomous.js never sends `messages` —
|
|
1067
|
+
// has nothing, so it gets the FULL transcript;
|
|
1068
|
+
// · an interactive chat already carries the prior turns (and the
|
|
1069
|
+
// "stopped at N rounds" note), so it gets only what THIS turn added
|
|
1070
|
+
// that the caller never saw: the tool calls and their results. Full
|
|
1071
|
+
// restore here would duplicate the caller's own text.
|
|
1072
|
+
//
|
|
1073
|
+
// The old `history.length === 0` guard did the first and not the second,
|
|
1074
|
+
// so the resume it was written for a queue task never reached the
|
|
1075
|
+
// interactive sessions this feature is actually used from.
|
|
1076
|
+
if (history.length === 0) {
|
|
1077
|
+
if (Array.isArray(resumed.messages) && resumed.messages.length) history.push(...resumed.messages);
|
|
1078
|
+
} else if (Array.isArray(resumed.added) && resumed.added.length) {
|
|
1079
|
+
history.push(...resumed.added);
|
|
1080
|
+
}
|
|
1056
1081
|
// Round 1's ask travels as `prompt`; a follow-up dispatch (and every
|
|
1057
1082
|
// turn that skips the shorthand) has to receive it through history.
|
|
1058
1083
|
if (prompt) prompt = `${preamble}\n\n${prompt}`;
|
|
@@ -1117,6 +1142,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1117
1142
|
prompt = '';
|
|
1118
1143
|
};
|
|
1119
1144
|
|
|
1145
|
+
// From here on, everything pushed into `history` is this turn's own work:
|
|
1146
|
+
// the restored transcript above (if any) belongs to the turn it came from,
|
|
1147
|
+
// and re-recording it would double the ledger on every interruption.
|
|
1148
|
+
historyStart = history.length;
|
|
1149
|
+
|
|
1120
1150
|
for (;;) {
|
|
1121
1151
|
// Stop and SAY so. A turn that reaches its horizon has usually done
|
|
1122
1152
|
// real work; ending silently would paint an empty answer over it,
|
|
@@ -1136,6 +1166,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1136
1166
|
tokens: turnUsage.total_tokens || 0,
|
|
1137
1167
|
note,
|
|
1138
1168
|
chain: resumed ? resumed.interruptions : 0,
|
|
1169
|
+
// The actual tool-call/tool-result transcript built up this turn —
|
|
1170
|
+
// everything folded/pushed into `history` by completed rounds — so
|
|
1171
|
+
// a resume can restore real memory of the work, not just a count
|
|
1172
|
+
// of it (see the `resumed.messages` rehydration above).
|
|
1173
|
+
messages: history,
|
|
1174
|
+
// …and only what this turn itself added, for the caller that
|
|
1175
|
+
// already has the rest of `history` in its own conversation.
|
|
1176
|
+
added: history.slice(historyStart),
|
|
1139
1177
|
});
|
|
1140
1178
|
if (rootOnDelta) rootOnDelta({ delta: `\n\n${note}` });
|
|
1141
1179
|
return withTurnUsage({
|
package/lib/local/queue.js
CHANGED
|
@@ -266,6 +266,10 @@ function retryTask(env, id, opts = {}) {
|
|
|
266
266
|
item.updated = opts.now || Date.now();
|
|
267
267
|
delete item.error;
|
|
268
268
|
delete item.pid;
|
|
269
|
+
// A manual retry is a fresh deliberate ask, so it gets its own full run of
|
|
270
|
+
// MAX_ROUND_STOPS chances (autonomous.js) rather than inheriting whatever
|
|
271
|
+
// count a previous, unrelated failure left behind.
|
|
272
|
+
delete item.roundStops;
|
|
269
273
|
saveQueue(env, items);
|
|
270
274
|
return item;
|
|
271
275
|
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* This makes the cap *session* state instead of turn state:
|
|
14
14
|
*
|
|
15
15
|
* - a turn that reaches its horizon records the unfinished work against the
|
|
16
|
-
* session key (rounds spent, tokens, the note the user saw
|
|
16
|
+
* session key (rounds spent, tokens, the note the user saw, and the
|
|
17
|
+
* tool-call transcript itself — see `record`'s `messages`);
|
|
17
18
|
* - the next turn in that session takes the record — consuming it, so one
|
|
18
19
|
* resume per interruption and a chain of interruptions is a chain of
|
|
19
20
|
* deliberate asks, never an automatic loop — and gets a continuation
|
|
@@ -126,8 +127,17 @@ function adopt({ now = Date.now(), maxAgeMs = 10 * 60 * 1000 } = {}) {
|
|
|
126
127
|
* File an interruption. `chain` carries the count forward from the entry this
|
|
127
128
|
* turn resumed, so `interruptions` reports how many times one job has been cut
|
|
128
129
|
* off — visible in the note and useful when deciding to raise the horizon.
|
|
130
|
+
*
|
|
131
|
+
* `messages` is the interrupted turn's own tool-call transcript (its live
|
|
132
|
+
* `history` array at the moment it hit the horizon) — real memory of what was
|
|
133
|
+
* already done, not just a rounds/tokens count of it. Without it a caller
|
|
134
|
+
* that supplies no conversation of its own (a queue task's fresh dispatch)
|
|
135
|
+
* resumes with an empty history: the model is told "continue, don't restart"
|
|
136
|
+
* with nothing to continue FROM, which is a cold start wearing a note.
|
|
137
|
+
* Stored as a defensive copy — the caller's `history` array keeps being
|
|
138
|
+
* mutated by the turn that is returning it.
|
|
129
139
|
*/
|
|
130
|
-
function record(key, { rounds, tokens, note, chain } = {}, now = Date.now()) {
|
|
140
|
+
function record(key, { rounds, tokens, note, chain, messages, added } = {}, now = Date.now()) {
|
|
131
141
|
prune(now);
|
|
132
142
|
const prior = entries.get(key);
|
|
133
143
|
const entry = {
|
|
@@ -136,6 +146,13 @@ function record(key, { rounds, tokens, note, chain } = {}, now = Date.now()) {
|
|
|
136
146
|
note: typeof note === 'string' ? note : '',
|
|
137
147
|
at: now,
|
|
138
148
|
interruptions: (Number.isFinite(chain) ? chain : prior && prior.interruptions) || 0,
|
|
149
|
+
messages: Array.isArray(messages) ? messages.slice() : [],
|
|
150
|
+
// And what the interrupted turn added on TOP of what its caller already
|
|
151
|
+
// had. A resuming caller who brings its own conversation (an interactive
|
|
152
|
+
// chat) must get this half, not `messages` — pushing the full transcript
|
|
153
|
+
// into a history that already holds its first half duplicates the user's
|
|
154
|
+
// own turns back to the model.
|
|
155
|
+
added: Array.isArray(added) ? added.slice() : [],
|
|
139
156
|
};
|
|
140
157
|
entry.interruptions += 1;
|
|
141
158
|
entries.delete(key);
|
package/lib/settings.js
CHANGED
|
@@ -54,12 +54,21 @@ const DEFAULT_QUICK_LAUNCHER_SHORTCUT = 'CmdOrCtrl+Shift+Space';
|
|
|
54
54
|
* top-level namespace — never inside a provider's `cfg[provider]` object. */
|
|
55
55
|
const CONFIRM_MODE_NAMESPACE = '__confirmMode';
|
|
56
56
|
|
|
57
|
+
/** Reserved namespace for the persisting-memory preference: `{ enabled }` —
|
|
58
|
+
* app-level, not a provider. This is the desktop's counterpart to the CLI's
|
|
59
|
+
* `memoryPersist` config key, and the gate `lib/sync/persist-gate.js` reads
|
|
60
|
+
* before any automatic cloud push. Default is ON (an absent namespace means
|
|
61
|
+
* persistence is on, which is the shipped decision); an explicit `false` is
|
|
62
|
+
* what turns it off. Nothing secret lives here, so no encryption. */
|
|
63
|
+
const MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
|
|
64
|
+
|
|
57
65
|
/** Namespaces the provider-config surface must never see or mutate. */
|
|
58
66
|
const RESERVED_NAMESPACES = Object.freeze([
|
|
59
67
|
AEGIS_KEY_NAMESPACE,
|
|
60
68
|
LEGACY_AEGIS_NAMESPACE,
|
|
61
69
|
QUICK_LAUNCHER_NAMESPACE,
|
|
62
70
|
CONFIRM_MODE_NAMESPACE,
|
|
71
|
+
MEMORY_PERSIST_NAMESPACE,
|
|
63
72
|
]);
|
|
64
73
|
|
|
65
74
|
/** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
|
|
@@ -282,6 +291,36 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
282
291
|
return getConfirmMode();
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
// --- Persisting memory: reserved namespace, plain preference -------------
|
|
295
|
+
// The desktop's half of "persisting memory for every account": whether a
|
|
296
|
+
// finished turn is pushed to cloud memory automatically. ON when unset —
|
|
297
|
+
// an install that never touched the toggle behaves like cli/, whose
|
|
298
|
+
// `memoryPersist` also defaults to on. `memoryPersistState()` reports the
|
|
299
|
+
// SOURCE as well as the value so "off" and "never set" stay distinguishable;
|
|
300
|
+
// lib/sync/persist-gate.js reads the same namespace straight off disk, and
|
|
301
|
+
// the two must agree on the default.
|
|
302
|
+
|
|
303
|
+
function getMemoryPersist() {
|
|
304
|
+
const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
|
|
305
|
+
return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `{ enabled, source }` — mirrors cli/src/cloudsync.js memoryPersistState(). */
|
|
309
|
+
function memoryPersistState() {
|
|
310
|
+
const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
|
|
311
|
+
return {
|
|
312
|
+
enabled: getMemoryPersist(),
|
|
313
|
+
source: cfg.enabled === undefined ? 'default' : 'config',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setMemoryPersist(enabled) {
|
|
318
|
+
const data = load();
|
|
319
|
+
data[MEMORY_PERSIST_NAMESPACE] = { enabled: Boolean(enabled) };
|
|
320
|
+
save(data);
|
|
321
|
+
return memoryPersistState();
|
|
322
|
+
}
|
|
323
|
+
|
|
285
324
|
return {
|
|
286
325
|
file,
|
|
287
326
|
get,
|
|
@@ -299,6 +338,9 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
299
338
|
setQuickLauncherConfig,
|
|
300
339
|
getConfirmMode,
|
|
301
340
|
setConfirmMode,
|
|
341
|
+
getMemoryPersist,
|
|
342
|
+
memoryPersistState,
|
|
343
|
+
setMemoryPersist,
|
|
302
344
|
};
|
|
303
345
|
}
|
|
304
346
|
|
|
@@ -308,6 +350,7 @@ module.exports = {
|
|
|
308
350
|
LEGACY_AEGIS_NAMESPACE,
|
|
309
351
|
QUICK_LAUNCHER_NAMESPACE,
|
|
310
352
|
CONFIRM_MODE_NAMESPACE,
|
|
353
|
+
MEMORY_PERSIST_NAMESPACE,
|
|
311
354
|
DEFAULT_QUICK_LAUNCHER_SHORTCUT,
|
|
312
355
|
RESERVED_NAMESPACES,
|
|
313
356
|
isReservedNamespace,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persist-gate.js — the desktop half of "persisting memory for every account".
|
|
5
|
+
*
|
|
6
|
+
* `cli/` flips this default in config.json (`memoryPersist`, migrated off the
|
|
7
|
+
* legacy `cloudSync` key). The desktop has no config.json, so its preference
|
|
8
|
+
* lives in the same settings.json the Settings pane already writes, under the
|
|
9
|
+
* reserved `__memoryPersist` namespace — and it is read HERE, from disk, by the
|
|
10
|
+
* main process that performs the push.
|
|
11
|
+
*
|
|
12
|
+
* Two reasons the gate is not taken from the renderer:
|
|
13
|
+
*
|
|
14
|
+
* 1. The decision has to be enforceable in the main process whatever the
|
|
15
|
+
* renderer believes. A renderer-held flag is a suggestion; the process
|
|
16
|
+
* that opens the socket decides.
|
|
17
|
+
* 2. `createSyncDispatch()` / createAutoPush() are unit-tested in plain Node,
|
|
18
|
+
* where no renderer and no Electron exist.
|
|
19
|
+
*
|
|
20
|
+
* Default is ON, matching cli/. `source` keeps "the user turned this off" and
|
|
21
|
+
* "nobody has been asked yet" tellable apart — the whole point of shipping a
|
|
22
|
+
* default is that the absence of a stored key is not the same as a stored no.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const { MEMORY_PERSIST_NAMESPACE, SETTINGS_FILE } = require('../settings.js');
|
|
29
|
+
|
|
30
|
+
/** The settings file the store writes (`createSettingsStore` uses the same
|
|
31
|
+
* `path.join(dir, SETTINGS_FILE)`, so both agree on one source of truth).
|
|
32
|
+
* A caller that already holds the file path may pass it directly. */
|
|
33
|
+
function settingsFile(dir) {
|
|
34
|
+
const p = dir || '';
|
|
35
|
+
return p.endsWith(SETTINGS_FILE) ? p : path.join(p, SETTINGS_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readSettings(dir) {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(fs.readFileSync(settingsFile(dir), 'utf8'));
|
|
41
|
+
return data && typeof data === 'object' ? data : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `{ enabled, source }`. `source` is 'config' only when the file actually
|
|
49
|
+
* carries the key — an explicit `false` survives, an absent namespace is ON.
|
|
50
|
+
*/
|
|
51
|
+
function gateState(dir) {
|
|
52
|
+
const cfg = readSettings(dir)[MEMORY_PERSIST_NAMESPACE];
|
|
53
|
+
if (!cfg || cfg.enabled === undefined) return { enabled: true, source: 'default' };
|
|
54
|
+
return { enabled: Boolean(cfg.enabled), source: 'config' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The post-turn push, gated.
|
|
59
|
+
*
|
|
60
|
+
* `push` is `createSyncDispatch`'s own drain, so this inherits the queue
|
|
61
|
+
* fallback for free: a push flushes `memory-queue.json` before it touches the
|
|
62
|
+
* session list, which is exactly the "offline now, sync later" behaviour the
|
|
63
|
+
* manual button had and the automatic path must not lose.
|
|
64
|
+
*
|
|
65
|
+
* Coalesced: a turn that ends while a drain is still in flight does not open a
|
|
66
|
+
* second one. `push()` snapshots `sessions.listPending(dir)`, so a concurrent
|
|
67
|
+
* call would race the same rows; the newest session instead goes out on the
|
|
68
|
+
* next trigger (the following turn's auto push, or "Sync now"). One turn of
|
|
69
|
+
* delay is the correct trade against two writers marking the same session
|
|
70
|
+
* synced.
|
|
71
|
+
*/
|
|
72
|
+
function createAutoPush({ push, dir, gate } = {}) {
|
|
73
|
+
if (typeof push !== 'function') throw new Error('createAutoPush requires a push()');
|
|
74
|
+
const readGate = typeof gate === 'function' ? gate : () => gateState(dir);
|
|
75
|
+
let inFlight = null;
|
|
76
|
+
|
|
77
|
+
async function run() {
|
|
78
|
+
const state = readGate();
|
|
79
|
+
if (!state.enabled) {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
skipped: true,
|
|
83
|
+
gate: state,
|
|
84
|
+
reason: 'persisting memory is off',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const result = await push();
|
|
89
|
+
return Object.assign({}, result, { skipped: false, gate: state });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A chat turn must never fail because a background push did. `push()`
|
|
92
|
+
// already resolves `{ ok:false, … }` for its expected failures; this
|
|
93
|
+
// catches the unexpected so the caller's fire-and-forget cannot become
|
|
94
|
+
// an unhandled rejection.
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
skipped: false,
|
|
98
|
+
gate: state,
|
|
99
|
+
reason: (err && err.message) || String(err),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function auto() {
|
|
105
|
+
if (inFlight) return inFlight;
|
|
106
|
+
inFlight = run().finally(() => {
|
|
107
|
+
inFlight = null;
|
|
108
|
+
});
|
|
109
|
+
return inFlight;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { auto, gate: () => readGate(), state: () => readGate() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
settingsFile,
|
|
117
|
+
readSettings,
|
|
118
|
+
gateState,
|
|
119
|
+
createAutoPush,
|
|
120
|
+
};
|