groove-dev 0.27.205 → 0.27.207

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.
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-vrFP3bO0.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-OzNfp6-Y.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-26L3JoZv.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-DoBZjiHE.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BYKpdS2W.js">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.205",
3
+ "version": "0.27.207",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.205",
3
+ "version": "0.27.207",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.205",
3
+ "version": "0.27.207",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.205",
3
+ "version": "0.27.207",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -9,14 +9,21 @@ const MAX_PER_AGENT = 200;
9
9
  const SAVE_DEBOUNCE_MS = 1500;
10
10
 
11
11
  /**
12
- * Server-side chat history.
12
+ * Server-side chat history, keyed by AGENT NAME.
13
13
  *
14
- * The GUI used to keep chat history only in the browser's localStorage, which
15
- * is scoped per origin (scheme://host:PORT). For a remote GUI reached over an
16
- * SSH tunnel the local port changes across reconnects, so the origin — and thus
17
- * the entire chat store changed out from under the user, stranding history on
18
- * old ports. Keeping it on the daemon makes it independent of port, origin, and
19
- * even which machine connects: reconnect from anywhere and the chats are there.
14
+ * Two hard-won identity lessons live here:
15
+ *
16
+ * 1. localStorage is per-origin (scheme://host:PORT), and a tunnelled GUI's
17
+ * port changes across reconnectsso history kept only in the browser gets
18
+ * stranded on dead origins. Hence a server-side store at all.
19
+ *
20
+ * 2. Agent IDs are NOT stable: every rotation/resume mints a new id. A store
21
+ * keyed by id fragments on every rotation — the full history stays under
22
+ * the dead id while the new id starts near-empty, and a reconnect then
23
+ * "restores" that near-empty stub over the user's real history. The agent
24
+ * NAME survives rotation (the Watch system keys by name for the same
25
+ * reason), so name is the identity here. IDs are accepted at the API edge
26
+ * and resolved immediately.
20
27
  */
21
28
  export class ChatStore {
22
29
  constructor(daemon) {
@@ -36,6 +43,50 @@ export class ChatStore {
36
43
  return {};
37
44
  }
38
45
 
46
+ // Resolve an id-or-name to the stable storage key (the agent's name).
47
+ // Unknown refs are used verbatim so nothing is ever dropped — an orphaned
48
+ // id key re-merges at boot once its agent is known, or stays parked.
49
+ _keyFor(ref) {
50
+ if (!ref) return null;
51
+ const byId = this.daemon.registry?.get?.(ref);
52
+ if (byId?.name) return byId.name;
53
+ return String(ref);
54
+ }
55
+
56
+ /**
57
+ * Fold any id-keyed entries into their agent's name key. Runs at boot (after
58
+ * registry restore) so stores written by the old id-keyed code reattach to
59
+ * the agent wherever the id is still resolvable.
60
+ */
61
+ migrate() {
62
+ const agents = this.daemon.registry?.getAll?.() || [];
63
+ const byId = new Map(agents.map((a) => [a.id, a]));
64
+ let moved = 0;
65
+ for (const key of Object.keys(this.history)) {
66
+ const agent = byId.get(key);
67
+ if (!agent || agent.name === key) continue;
68
+ this.history[agent.name] = mergeMessages(this.history[agent.name], this.history[key]);
69
+ delete this.history[key];
70
+ moved += 1;
71
+ }
72
+ if (moved) this._scheduleSave();
73
+ return moved;
74
+ }
75
+
76
+ /**
77
+ * Rotation-time hook: an entry parked under a dead id (written by the old
78
+ * id-keyed code, or by an old GUI posting under ids) follows the agent to
79
+ * its new identity.
80
+ */
81
+ remap(oldRef, newRef) {
82
+ const oldKey = String(oldRef);
83
+ const newKey = this._keyFor(newRef);
84
+ if (!newKey || oldKey === newKey || !this.history[oldKey]) return;
85
+ this.history[newKey] = mergeMessages(this.history[newKey], this.history[oldKey]);
86
+ delete this.history[oldKey];
87
+ this._scheduleSave();
88
+ }
89
+
39
90
  _scheduleSave() {
40
91
  if (this._saveTimer) return;
41
92
  this._saveTimer = setTimeout(() => {
@@ -64,35 +115,59 @@ export class ChatStore {
64
115
  return out;
65
116
  }
66
117
 
67
- append(agentId, message) {
68
- if (!agentId) return;
118
+ append(ref, message) {
119
+ const key = this._keyFor(ref);
120
+ if (!key) return;
69
121
  const clean = this._clean(message);
70
122
  if (!clean) return;
71
- const arr = this.history[agentId] || [];
123
+ const arr = this.history[key] || [];
72
124
  arr.push(clean);
73
- this.history[agentId] = arr.slice(-MAX_PER_AGENT);
125
+ this.history[key] = arr.slice(-MAX_PER_AGENT);
74
126
  this._scheduleSave();
75
127
  }
76
128
 
77
- // Replace an agent's whole history — used when the GUI syncs a batch (e.g.
78
- // messages it recorded while briefly disconnected).
79
- replace(agentId, messages) {
80
- if (!agentId || !Array.isArray(messages)) return;
81
- this.history[agentId] = messages.map((m) => this._clean(m)).filter(Boolean).slice(-MAX_PER_AGENT);
129
+ /**
130
+ * Merge a batch from a client. This is a UNION by (timestamp, from, text)
131
+ * never a replace — so a browser holding messages the server missed adds
132
+ * them, and a browser with less than the server can never truncate it.
133
+ * A "restore" must be incapable of destroying what it restores from.
134
+ */
135
+ merge(ref, messages) {
136
+ const key = this._keyFor(ref);
137
+ if (!key || !Array.isArray(messages)) return;
138
+ const incoming = messages.map((m) => this._clean(m)).filter(Boolean);
139
+ this.history[key] = mergeMessages(this.history[key], incoming);
82
140
  this._scheduleSave();
83
141
  }
84
142
 
143
+ /**
144
+ * History for the GUI: live agents keyed by their CURRENT id (what the GUI
145
+ * looks up by), everything else under its stored name key so an agent
146
+ * respawned under the same name picks its history back up.
147
+ */
148
+ view() {
149
+ const out = {};
150
+ const agents = this.daemon.registry?.getAll?.() || [];
151
+ const liveNames = new Map(agents.map((a) => [a.name, a.id]));
152
+ for (const [key, msgs] of Object.entries(this.history)) {
153
+ if (!Array.isArray(msgs) || !msgs.length) continue;
154
+ out[liveNames.get(key) || key] = msgs;
155
+ }
156
+ return out;
157
+ }
158
+
85
159
  getAll() {
86
160
  return this.history;
87
161
  }
88
162
 
89
- get(agentId) {
90
- return this.history[agentId] || [];
163
+ get(ref) {
164
+ return this.history[this._keyFor(ref)] || [];
91
165
  }
92
166
 
93
- remove(agentId) {
94
- if (this.history[agentId]) {
95
- delete this.history[agentId];
167
+ remove(ref) {
168
+ const key = this._keyFor(ref);
169
+ if (key && this.history[key]) {
170
+ delete this.history[key];
96
171
  this._scheduleSave();
97
172
  }
98
173
  }
@@ -102,3 +177,19 @@ export class ChatStore {
102
177
  this._saveNow();
103
178
  }
104
179
  }
180
+
181
+ // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
+ // capped. Exported for tests and the migration path.
183
+ export function mergeMessages(a, b) {
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const m of [...(a || []), ...(b || [])]) {
187
+ if (!m || typeof m !== 'object') continue;
188
+ const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
189
+ if (seen.has(sig)) continue;
190
+ seen.add(sig);
191
+ out.push(m);
192
+ }
193
+ out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
+ return out.slice(-MAX_PER_AGENT);
195
+ }
@@ -438,6 +438,12 @@ export class Daemon {
438
438
  }
439
439
 
440
440
  broadcast(message) {
441
+ // Every rotation/resume path announces itself here — the one chokepoint
442
+ // for "this agent has a new id". Chat history parked under the dead id
443
+ // (stale client posts, pre-migration data) follows the agent.
444
+ if (message.type === 'rotation:complete' && message.oldAgentId && message.agentId) {
445
+ try { this.chatStore.remap(message.oldAgentId, message.agentId); } catch { /* best effort */ }
446
+ }
441
447
  if (!this.wss) return;
442
448
  const payload = JSON.stringify(message);
443
449
  for (const client of this.wss.clients) {
@@ -640,6 +646,13 @@ export class Daemon {
640
646
  this.federation.initialize();
641
647
  this._startGarbageCollector();
642
648
 
649
+ // Fold id-keyed chat history into name keys now that the registry is
650
+ // restored — reattaches histories written by the old id-keyed store.
651
+ try {
652
+ const moved = this.chatStore.migrate();
653
+ if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
654
+ } catch { /* best effort */ }
655
+
643
656
  // Regenerate the on-disk registry files once on boot. They otherwise
644
657
  // only refresh on a registry change, so a daemon upgraded with new
645
658
  // agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
@@ -89,6 +89,10 @@ export function registerAgentRoutes(app, daemon) {
89
89
  // Killed/completed agents stay visible so the user can review output.
90
90
  const purge = req.query.purge === 'true';
91
91
  if (purge) {
92
+ // Clear chat history while the id still resolves to a name — the
93
+ // store is name-keyed, and a purged name's history must not haunt a
94
+ // future agent spawned under the same name.
95
+ try { daemon.chatStore.remove(agent.name); } catch { /* best effort */ }
92
96
  daemon.registry.remove(req.params.id);
93
97
  }
94
98
 
@@ -4,7 +4,9 @@ export function registerChatHistoryRoutes(app, daemon) {
4
4
  // Full history for all agents — the GUI loads this on connect so chats are
5
5
  // present regardless of which origin/port the tunnel came up on.
6
6
  app.get('/api/chat-history', (req, res) => {
7
- res.json({ history: daemon.chatStore.getAll() });
7
+ // view() keys live agents by their CURRENT id — ids churn on rotation, so
8
+ // the store itself is name-keyed and this translates at the edge.
9
+ res.json({ history: daemon.chatStore.view() });
8
10
  });
9
11
 
10
12
  // Append a single message for an agent.
@@ -17,13 +19,14 @@ export function registerChatHistoryRoutes(app, daemon) {
17
19
  res.json({ ok: true });
18
20
  });
19
21
 
20
- // Replace an agent's whole history (batch sync).
22
+ // Batch sync from a client. A UNION, deliberately not a replace — a client
23
+ // can add messages the server missed but can never truncate server history.
21
24
  app.put('/api/chat-history/:agentId', (req, res) => {
22
25
  const { messages } = req.body || {};
23
26
  if (!Array.isArray(messages)) {
24
27
  return res.status(400).json({ error: 'messages array required' });
25
28
  }
26
- daemon.chatStore.replace(req.params.agentId, messages);
29
+ daemon.chatStore.merge(req.params.agentId, messages);
27
30
  res.json({ ok: true });
28
31
  });
29
32