groove-dev 0.27.153 → 0.27.154

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 (37) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/journalist.js +52 -21
  4. package/node_modules/@groove-dev/daemon/src/keeper.js +37 -2
  5. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +16 -0
  6. package/node_modules/@groove-dev/daemon/src/routes/files.js +71 -2
  7. package/node_modules/@groove-dev/gui/dist/assets/index-BTLb6zTD.js +1015 -0
  8. package/node_modules/@groove-dev/gui/dist/assets/index-Diw6wDPU.css +1 -0
  9. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  10. package/node_modules/@groove-dev/gui/package.json +1 -1
  11. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +252 -44
  12. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +51 -3
  13. package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +40 -3
  14. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  15. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +24 -5
  16. package/node_modules/@groove-dev/gui/src/views/memory.jsx +87 -44
  17. package/package.json +1 -1
  18. package/packages/cli/package.json +1 -1
  19. package/packages/daemon/package.json +1 -1
  20. package/packages/daemon/src/journalist.js +52 -21
  21. package/packages/daemon/src/keeper.js +37 -2
  22. package/packages/daemon/src/routes/coordination.js +16 -0
  23. package/packages/daemon/src/routes/files.js +71 -2
  24. package/packages/gui/dist/assets/index-BTLb6zTD.js +1015 -0
  25. package/packages/gui/dist/assets/index-Diw6wDPU.css +1 -0
  26. package/packages/gui/dist/index.html +2 -2
  27. package/packages/gui/package.json +1 -1
  28. package/packages/gui/src/components/agents/agent-feed.jsx +252 -44
  29. package/packages/gui/src/components/agents/agent-file-tree.jsx +51 -3
  30. package/packages/gui/src/components/editor/file-tree.jsx +40 -3
  31. package/packages/gui/src/stores/groove.js +9 -1
  32. package/packages/gui/src/stores/slices/agents-slice.js +24 -5
  33. package/packages/gui/src/views/memory.jsx +87 -44
  34. package/node_modules/@groove-dev/gui/dist/assets/index-BU_YTEZo.js +0 -1011
  35. package/node_modules/@groove-dev/gui/dist/assets/index-ChfYTsyc.css +0 -1
  36. package/packages/gui/dist/assets/index-BU_YTEZo.js +0 -1011
  37. package/packages/gui/dist/assets/index-ChfYTsyc.css +0 -1
@@ -95,12 +95,19 @@ export const createAgentsSlice = (set, get) => ({
95
95
 
96
96
  // ── Chat ──────────────────────────────────────────────────
97
97
 
98
- addChatMessage(agentId, from, text, isQuery = false) {
98
+ addChatMessage(agentId, from, text, isQuery = false, attachments = undefined) {
99
99
  set((s) => {
100
100
  const history = { ...s.chatHistory };
101
101
  if (!history[agentId]) history[agentId] = [];
102
- history[agentId] = [...history[agentId].slice(-100), { from, text, timestamp: Date.now(), isQuery }];
103
- persistJSON('groove:chatHistory', history);
102
+ const msg = { from, text, timestamp: Date.now(), isQuery };
103
+ if (attachments?.length) msg.attachments = attachments;
104
+ history[agentId] = [...history[agentId].slice(-100), msg];
105
+ const forStorage = { ...history };
106
+ forStorage[agentId] = forStorage[agentId].map((m) => {
107
+ if (!m.attachments?.length) return m;
108
+ return { ...m, attachments: m.attachments.map(({ dataUrl, ...rest }) => rest) };
109
+ });
110
+ persistJSON('groove:chatHistory', forStorage);
104
111
  return { chatHistory: history };
105
112
  });
106
113
  },
@@ -120,7 +127,7 @@ export const createAgentsSlice = (set, get) => ({
120
127
  }
121
128
  },
122
129
 
123
- async instructAgent(id, message) {
130
+ async instructAgent(id, message, attachments = undefined) {
124
131
  // ── Keeper command interception ─────────────────────────
125
132
  const keeperCmd = message.match(/\[(save|append|update|delete|view|doc|link|read|instruct)\]/i);
126
133
  if (keeperCmd) {
@@ -131,7 +138,7 @@ export const createAgentsSlice = (set, get) => ({
131
138
  }
132
139
  }
133
140
 
134
- get().addChatMessage(id, 'user', message, false);
141
+ get().addChatMessage(id, 'user', message, false, attachments);
135
142
  set((s) => ({ thinkingAgents: new Set([...s.thinkingAgents, id]) }));
136
143
 
137
144
  // Auto-attach active file context when in workspace mode
@@ -332,6 +339,18 @@ export const createAgentsSlice = (set, get) => ({
332
339
  }
333
340
  },
334
341
 
342
+ async moveKeeperItem(oldTag, newTag) {
343
+ try {
344
+ const item = await api.post('/keeper/move', { oldTag, newTag });
345
+ get().fetchKeeperItems();
346
+ get().addToast('success', `Moved #${oldTag} → #${item.tag}`);
347
+ return item;
348
+ } catch (err) {
349
+ get().addToast('error', 'Failed to move memory', err.message);
350
+ throw err;
351
+ }
352
+ },
353
+
335
354
  async getKeeperItem(tag) {
336
355
  try {
337
356
  return await api.get(`/keeper/${tag}`);
@@ -1,10 +1,10 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useEffect, useRef } from 'react';
2
+ import { useState, useEffect, useRef, useCallback } from 'react';
3
3
  import { useGrooveStore } from '../stores/groove';
4
4
  import { Button } from '../components/ui/button';
5
5
  import { ScrollArea } from '../components/ui/scroll-area';
6
6
  import { Dialog, DialogContent } from '../components/ui/dialog';
7
- import { BookOpen, Plus, Search, Trash2, Pencil, ChevronRight, Hash, FolderOpen, Clock, Save, Link2, FileText, Sparkles, HelpCircle } from 'lucide-react';
7
+ import { BookOpen, Plus, Search, Trash2, Pencil, ChevronRight, Hash, FolderOpen, Clock, Save, Link2, FileText, Sparkles, HelpCircle, GripVertical } from 'lucide-react';
8
8
 
9
9
  const COMMANDS = [
10
10
  { cmd: 'save', args: '#tag', desc: 'Save the message and send it to the agent' },
@@ -249,38 +249,71 @@ function InstructModal({ open, onOpenChange }) {
249
249
  );
250
250
  }
251
251
 
252
- function TreeGroup({ node, onSelect }) {
252
+ function TreeItem({ tag, label, isDoc, indent, isDragOver, onSelect, onDragStart, onDragOver, onDragLeave, onDrop }) {
253
+ return (
254
+ <div
255
+ draggable
256
+ onDragStart={(e) => { e.dataTransfer.setData('text/plain', tag); e.dataTransfer.effectAllowed = 'move'; onDragStart?.(tag); }}
257
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; onDragOver?.(tag); }}
258
+ onDragLeave={() => onDragLeave?.()}
259
+ onDrop={(e) => { e.preventDefault(); onDrop?.(e.dataTransfer.getData('text/plain'), tag); }}
260
+ onClick={() => onSelect({ tag })}
261
+ className={`flex items-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs transition-colors cursor-pointer group ${isDragOver ? 'bg-accent/15 border border-accent/30 border-dashed' : 'hover:bg-surface-2'}`}
262
+ style={indent ? { paddingLeft: `${8 + indent * 16}px` } : undefined}
263
+ >
264
+ <GripVertical size={10} className="text-text-4 opacity-0 group-hover:opacity-50 flex-shrink-0 cursor-grab" />
265
+ <Hash size={11} className="text-text-4 flex-shrink-0" />
266
+ <span className="font-medium text-text-2 truncate">{label}</span>
267
+ {isDoc && <Sparkles size={9} className="text-purple flex-shrink-0" />}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ function TreeGroup({ node, onSelect, dragOverTag, onDragStart, onDragOver, onDragLeave, onDrop }) {
253
273
  const [expanded, setExpanded] = useState(true);
254
274
  const hasChildren = node.children && node.children.length > 0;
255
275
 
276
+ if (!hasChildren) {
277
+ return (
278
+ <TreeItem
279
+ tag={node.tag} label={node.tag} isDoc={node.type === 'doc'}
280
+ isDragOver={dragOverTag === node.tag}
281
+ onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
282
+ />
283
+ );
284
+ }
285
+
256
286
  return (
257
- <div>
287
+ <div
288
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; onDragOver?.(node.tag); }}
289
+ onDragLeave={() => onDragLeave?.()}
290
+ onDrop={(e) => { e.preventDefault(); onDrop?.(e.dataTransfer.getData('text/plain'), node.tag); }}
291
+ >
258
292
  <button
259
- onClick={() => hasChildren ? setExpanded(!expanded) : onSelect(node)}
260
- className="flex items-center gap-1.5 w-full px-2 py-1 rounded-md text-xs text-text-2 hover:bg-surface-2 transition-colors cursor-pointer"
293
+ onClick={() => setExpanded(!expanded)}
294
+ className={`flex items-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs transition-colors cursor-pointer ${dragOverTag === node.tag ? 'bg-accent/15 border border-accent/30 border-dashed' : 'hover:bg-surface-2'}`}
261
295
  >
262
- {hasChildren ? (
263
- <ChevronRight size={12} className={`transition-transform ${expanded ? 'rotate-90' : ''}`} />
264
- ) : (
265
- <Hash size={12} className="text-text-4" />
266
- )}
267
- <FolderOpen size={12} className={hasChildren ? 'text-accent' : 'text-text-4'} />
268
- <span className="font-medium">{node.tag}</span>
269
- {hasChildren && (
270
- <span className="text-2xs text-text-4 ml-auto">{node.children.length}</span>
271
- )}
296
+ <ChevronRight size={12} className={`transition-transform text-text-4 ${expanded ? 'rotate-90' : ''}`} />
297
+ <FolderOpen size={12} className="text-accent" />
298
+ <span className="font-medium text-text-1">{node.tag}</span>
299
+ {!node.virtual && node.type === 'doc' && <Sparkles size={9} className="text-purple" />}
300
+ <span className="text-2xs text-text-4 ml-auto">{node.children.length}</span>
272
301
  </button>
273
- {expanded && hasChildren && (
274
- <div className="ml-4 mt-0.5 space-y-0.5">
302
+ {expanded && (
303
+ <div className="mt-0.5 space-y-0.5">
304
+ {!node.virtual && (
305
+ <TreeItem
306
+ tag={node.tag} label={node.tag} isDoc={node.type === 'doc'} indent={1}
307
+ isDragOver={false}
308
+ onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
309
+ />
310
+ )}
275
311
  {node.children.map((child) => (
276
- <button
277
- key={child.tag}
278
- onClick={() => onSelect(child)}
279
- className="flex items-center gap-1.5 w-full px-2 py-1 rounded-md text-xs text-text-3 hover:text-text-1 hover:bg-surface-2 transition-colors cursor-pointer"
280
- >
281
- <Hash size={10} className="text-text-4" />
282
- <span>{child.tag.split('/').pop()}</span>
283
- </button>
312
+ <TreeItem
313
+ key={child.tag} tag={child.tag} label={child.tag.split('/').pop()} isDoc={child.type === 'doc'} indent={1}
314
+ isDragOver={dragOverTag === child.tag}
315
+ onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
316
+ />
284
317
  ))}
285
318
  </div>
286
319
  )}
@@ -297,12 +330,15 @@ export default function MemoryView() {
297
330
  const saveKeeperItem = useGrooveStore((s) => s.saveKeeperItem);
298
331
  const updateKeeperItem = useGrooveStore((s) => s.updateKeeperItem);
299
332
  const deleteKeeperItem = useGrooveStore((s) => s.deleteKeeperItem);
333
+ const moveKeeperItem = useGrooveStore((s) => s.moveKeeperItem);
300
334
  const getKeeperItem = useGrooveStore((s) => s.getKeeperItem);
301
335
  const setKeeperEditing = useGrooveStore((s) => s.setKeeperEditing);
302
336
 
303
337
  const [search, setSearch] = useState('');
304
338
  const [viewMode, setViewMode] = useState('list');
305
339
  const [editorOpen, setEditorOpen] = useState(false);
340
+ const [dragOverTag, setDragOverTag] = useState(null);
341
+ const [draggingTag, setDraggingTag] = useState(null);
306
342
 
307
343
  useEffect(() => { fetchKeeperItems(); }, []);
308
344
 
@@ -344,8 +380,22 @@ export default function MemoryView() {
344
380
  await handleEdit(node);
345
381
  };
346
382
 
383
+ const handleDrop = useCallback(async (sourceTag, targetTag) => {
384
+ setDragOverTag(null);
385
+ setDraggingTag(null);
386
+ if (!sourceTag || !targetTag || sourceTag === targetTag) return;
387
+ // Don't drop onto self or own children
388
+ if (targetTag.startsWith(sourceTag + '/')) return;
389
+ const sourceName = sourceTag.split('/').pop();
390
+ const newTag = targetTag + '/' + sourceName;
391
+ if (sourceTag === newTag) return;
392
+ try {
393
+ await moveKeeperItem(sourceTag, newTag);
394
+ } catch { /* toast handles */ }
395
+ }, [moveKeeperItem]);
396
+
347
397
  return (
348
- <div className="flex-1 flex flex-col overflow-hidden">
398
+ <div className="flex flex-col h-full overflow-hidden">
349
399
  {/* Header */}
350
400
  <div className="flex-shrink-0 px-4 py-3 border-b border-border">
351
401
  <div className="flex items-center justify-between gap-3 mb-3">
@@ -394,7 +444,7 @@ export default function MemoryView() {
394
444
  </div>
395
445
 
396
446
  {/* Content */}
397
- <ScrollArea className="flex-1">
447
+ <ScrollArea className="flex-1 min-h-0">
398
448
  {keeperItems.length === 0 ? (
399
449
  <div className="flex flex-col items-center justify-center h-64 gap-3">
400
450
  <BookOpen size={32} className="text-text-4" />
@@ -414,24 +464,17 @@ export default function MemoryView() {
414
464
  </div>
415
465
  </div>
416
466
  ) : viewMode === 'tree' ? (
417
- <div className="p-3 space-y-1">
467
+ <div className="p-3 space-y-0.5" onDragOver={(e) => e.preventDefault()}>
418
468
  {keeperTree.map((node) => (
419
- <TreeGroup key={node.tag} node={node} onSelect={handleTreeSelect} />
469
+ <TreeGroup
470
+ key={node.tag} node={node} onSelect={handleTreeSelect}
471
+ dragOverTag={dragOverTag}
472
+ onDragStart={(tag) => setDraggingTag(tag)}
473
+ onDragOver={(tag) => { if (tag !== draggingTag) setDragOverTag(tag); }}
474
+ onDragLeave={() => setDragOverTag(null)}
475
+ onDrop={handleDrop}
476
+ />
420
477
  ))}
421
- {keeperItems
422
- .filter((item) => !item.tag.includes('/') && !keeperTree.some((t) => t.tag === item.tag && t.children?.length))
423
- .map((item) => (
424
- <button
425
- key={item.tag}
426
- onClick={() => handleEdit(item)}
427
- className="flex items-center gap-1.5 w-full px-2 py-1 rounded-md text-xs text-text-2 hover:bg-surface-2 transition-colors cursor-pointer"
428
- >
429
- <Hash size={12} className="text-text-4" />
430
- <span className="font-medium">{item.tag}</span>
431
- {item.type === 'doc' && <Sparkles size={10} className="text-purple" />}
432
- <span className="text-2xs text-text-4 ml-auto">{formatRelative(item.updatedAt)}</span>
433
- </button>
434
- ))}
435
478
  </div>
436
479
  ) : (
437
480
  <div className="p-3 space-y-2">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.153",
3
+ "version": "0.27.154",
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.153",
3
+ "version": "0.27.154",
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.153",
3
+ "version": "0.27.154",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -644,20 +644,14 @@ export class Journalist {
644
644
  proc.stdin.write(stdinData);
645
645
  proc.stdin.end();
646
646
  proc.stdout.on('data', (d) => { stdout += d.toString(); });
647
- const timer = setTimeout(() => { proc.kill(); reject(new Error('Headless timeout')); }, 60_000);
647
+ const timer = setTimeout(() => { proc.kill(); reject(new Error('Headless timeout')); }, 120_000);
648
648
  proc.on('exit', (code) => {
649
649
  clearTimeout(timer);
650
650
  if (code !== 0) return reject(new Error(`Headless exited with code ${code}`));
651
651
  this._recordHeadlessUsage(stdout, trackAs, modelId);
652
- const lines = stdout.split('\n');
653
- for (const line of lines) {
654
- try {
655
- const json = JSON.parse(line);
656
- if (json.result) return resolve(json.result);
657
- if (json.content?.[0]?.text) return resolve(json.content[0].text);
658
- } catch { /* not json */ }
659
- }
660
- resolve(stdout.trim());
652
+ const extracted = this._parseHeadlessOutput(stdout);
653
+ if (extracted) return resolve(extracted);
654
+ reject(new Error('Headless produced no usable output'));
661
655
  });
662
656
  return;
663
657
  }
@@ -666,24 +660,61 @@ export class Journalist {
666
660
  env: { ...process.env, ...env },
667
661
  cwd: this.daemon.projectDir,
668
662
  maxBuffer: 1024 * 1024 * 5,
669
- timeout: 60_000,
663
+ timeout: 120_000,
670
664
  }, (err, stdout, stderr) => {
671
665
  if (err) return reject(err);
672
666
  this._recordHeadlessUsage(stdout, trackAs, modelId);
673
- const lines = stdout.split('\n');
674
- for (const line of lines) {
675
- try {
676
- const data = JSON.parse(line);
677
- if (data.type === 'result' && data.result) {
678
- return resolve(data.result);
679
- }
680
- } catch { /* skip */ }
681
- }
682
- resolve(stdout);
667
+ const extracted = this._parseHeadlessOutput(stdout);
668
+ if (extracted) return resolve(extracted);
669
+ reject(new Error('Headless produced no usable output'));
683
670
  });
684
671
  });
685
672
  }
686
673
 
674
+ _parseHeadlessOutput(stdout) {
675
+ const lines = stdout.split('\n');
676
+ let resultText = '';
677
+ let assistantText = '';
678
+ let codexText = '';
679
+ let networkText = '';
680
+
681
+ for (const line of lines) {
682
+ try {
683
+ const json = JSON.parse(line);
684
+
685
+ // Claude/Gemini stream-json: {"type":"result","result":"..."}
686
+ if (typeof json.result === 'string' && json.result.trim()) {
687
+ resultText = json.result;
688
+ }
689
+
690
+ // Claude/Gemini assistant message: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
691
+ const msgContent = json.message?.content?.[0]?.text || json.content?.[0]?.text;
692
+ if (msgContent && msgContent.trim()) {
693
+ assistantText = msgContent;
694
+ }
695
+
696
+ // Codex --json: {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
697
+ if (json.type === 'item.completed' && json.item?.type === 'agent_message' && json.item.text?.trim()) {
698
+ codexText = json.item.text;
699
+ }
700
+
701
+ // Groove Network: {"type":"done|complete|result","text":"..."}
702
+ if ((json.type === 'done' || json.type === 'complete') && typeof json.text === 'string' && json.text.trim()) {
703
+ networkText = json.text;
704
+ }
705
+ } catch { /* not json */ }
706
+ }
707
+
708
+ if (resultText) return resultText;
709
+ if (codexText) return codexText;
710
+ if (networkText) return networkText;
711
+ if (assistantText) return assistantText;
712
+ // Ollama / plain-text providers: raw text output (no JSON)
713
+ const plain = stdout.trim();
714
+ if (plain && !plain.startsWith('{') && plain.length > 20) return plain;
715
+ return null;
716
+ }
717
+
687
718
  parseSynthesisResult(text, agents) {
688
719
  // Parse the structured output from AI
689
720
  const sections = {
@@ -58,7 +58,7 @@ export class Keeper {
58
58
 
59
59
  save(tag, content) {
60
60
  if (!tag || typeof tag !== 'string') throw new Error('Tag is required');
61
- if (content === undefined || content === null) throw new Error('Content is required');
61
+ if (content === undefined || content === null || !String(content).trim()) throw new Error('Content is required');
62
62
  const normalized = this._normalize(tag);
63
63
  if (!normalized) throw new Error('Tag is required');
64
64
  const filePath = this._tagToPath(normalized);
@@ -129,7 +129,7 @@ export class Keeper {
129
129
  update(tag, content) {
130
130
  const normalized = this._normalize(tag);
131
131
  if (!normalized) throw new Error('Tag is required');
132
- if (content === undefined || content === null) throw new Error('Content is required');
132
+ if (content === undefined || content === null || !String(content).trim()) throw new Error('Content is required');
133
133
  const filePath = this._tagToPath(normalized);
134
134
  if (!existsSync(filePath)) throw new Error(`Memory #${normalized} does not exist`);
135
135
  writeFileSync(filePath, String(content));
@@ -153,6 +153,41 @@ export class Keeper {
153
153
  return true;
154
154
  }
155
155
 
156
+ move(oldTag, newTag) {
157
+ const oldNorm = this._normalize(oldTag);
158
+ const newNorm = this._normalize(newTag);
159
+ if (!oldNorm || !newNorm) throw new Error('Both old and new tags are required');
160
+ if (oldNorm === newNorm) return this._index[oldNorm];
161
+ const oldPath = this._tagToPath(oldNorm);
162
+ if (!existsSync(oldPath)) throw new Error(`Memory #${oldNorm} does not exist`);
163
+ if (this._index[newNorm]) throw new Error(`Memory #${newNorm} already exists`);
164
+ const content = readFileSync(oldPath, 'utf8');
165
+ const newPath = this._tagToPath(newNorm);
166
+ this._ensureParentDir(newPath);
167
+ writeFileSync(newPath, content);
168
+ unlinkSync(oldPath);
169
+ this._index[newNorm] = { ...this._index[oldNorm], tag: newNorm, updatedAt: new Date().toISOString() };
170
+ delete this._index[oldNorm];
171
+ // Move children too (e.g. moving "a" to "b/a" also moves "a/child" to "b/a/child")
172
+ const prefix = oldNorm + '/';
173
+ for (const tag of Object.keys(this._index)) {
174
+ if (tag.startsWith(prefix)) {
175
+ const childSuffix = tag.slice(prefix.length);
176
+ const childNewTag = newNorm + '/' + childSuffix;
177
+ const childOldPath = this._tagToPath(tag);
178
+ const childNewPath = this._tagToPath(childNewTag);
179
+ const childContent = readFileSync(childOldPath, 'utf8');
180
+ this._ensureParentDir(childNewPath);
181
+ writeFileSync(childNewPath, childContent);
182
+ unlinkSync(childOldPath);
183
+ this._index[childNewTag] = { ...this._index[tag], tag: childNewTag, updatedAt: new Date().toISOString() };
184
+ delete this._index[tag];
185
+ }
186
+ }
187
+ this._saveIndex();
188
+ return { tag: newNorm, ...this._index[newNorm] };
189
+ }
190
+
156
191
  // ── Doc (AI-generated) ───────────────────────────────────
157
192
 
158
193
  saveDoc(tag, content) {
@@ -249,6 +249,19 @@ export function registerCoordinationRoutes(app, daemon) {
249
249
  }
250
250
  });
251
251
 
252
+ app.post('/api/keeper/move', (req, res) => {
253
+ try {
254
+ const { oldTag, newTag } = req.body || {};
255
+ if (!oldTag || !newTag) return res.status(400).json({ error: 'oldTag and newTag are required' });
256
+ const item = daemon.keeper.move(oldTag, newTag);
257
+ daemon.audit.log('keeper.move', { oldTag, newTag: item.tag });
258
+ daemon.broadcast({ type: 'keeper:moved', oldTag, item });
259
+ res.json(item);
260
+ } catch (err) {
261
+ res.status(err.message.includes('does not exist') ? 404 : 400).json({ error: err.message });
262
+ }
263
+ });
264
+
252
265
  app.delete('/api/keeper/link/:tag(*)', (req, res) => {
253
266
  try {
254
267
  const { docPath } = req.body || {};
@@ -289,6 +302,9 @@ export function registerCoordinationRoutes(app, daemon) {
289
302
  } else {
290
303
  doc = `# ${tag}\n\n*Auto-generated document from conversation*\n\n${transcript.slice(0, 5000)}`;
291
304
  }
305
+ if (!doc || !doc.trim()) {
306
+ return res.status(502).json({ error: 'AI synthesis returned empty content — try again' });
307
+ }
292
308
  const item = daemon.keeper.saveDoc(tag, doc);
293
309
  daemon.audit.log('keeper.doc', { tag: item.tag, agentId });
294
310
  daemon.broadcast({ type: 'keeper:saved', item });
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { resolve, sep, isAbsolute } from 'path';
2
+ import { resolve, sep, isAbsolute, basename } from 'path';
3
3
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, realpathSync } from 'fs';
4
4
  import { execFile, execFileSync } from 'child_process';
5
5
  import { homedir } from 'os';
@@ -331,6 +331,53 @@ export function registerFileRoutes(app, daemon) {
331
331
  }
332
332
  });
333
333
 
334
+ // Download a file (serves raw with Content-Disposition)
335
+ app.get('/api/files/download', (req, res) => {
336
+ const relPath = req.query.path;
337
+ const result = validateFilePath(relPath, getEditorRoot(daemon));
338
+ if (result.error) return res.status(400).json({ error: result.error });
339
+ if (!existsSync(result.fullPath)) return res.status(404).json({ error: 'File not found' });
340
+
341
+ const stat = statSync(result.fullPath);
342
+ if (stat.isDirectory()) return res.status(400).json({ error: 'Cannot download a directory' });
343
+
344
+ const name = basename(result.fullPath);
345
+ const mime = mimeLookup(name) || 'application/octet-stream';
346
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"`);
347
+ res.setHeader('Content-Type', mime);
348
+ res.setHeader('Content-Length', stat.size);
349
+ createReadStream(result.fullPath).pipe(res);
350
+ });
351
+
352
+ // Upload files (base64-encoded) to a target directory
353
+ app.post('/api/files/upload', (req, res) => {
354
+ const { dir = '', files } = req.body;
355
+ const rootDir = getEditorRoot(daemon);
356
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
357
+ if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] required' });
358
+ if (files.length > 50) return res.status(400).json({ error: 'Max 50 files per upload' });
359
+
360
+ const uploaded = [];
361
+ for (const file of files) {
362
+ if (!file.name || !file.content) continue;
363
+ const safeName = String(file.name).replace(/\.\./g, '').replace(/\//g, '_');
364
+ if (!safeName) continue;
365
+ const relPath = dir ? `${dir}/${safeName}` : safeName;
366
+ const result = validateFilePath(relPath, rootDir);
367
+ if (result.error) continue;
368
+
369
+ try {
370
+ const parentDir = resolve(result.fullPath, '..');
371
+ mkdirSync(parentDir, { recursive: true });
372
+ const buf = Buffer.from(file.content, 'base64');
373
+ writeFileSync(result.fullPath, buf);
374
+ daemon.audit.log('file.upload', { path: relPath, size: buf.length });
375
+ uploaded.push({ path: relPath, size: buf.length });
376
+ } catch { /* skip failed files */ }
377
+ }
378
+ res.json({ uploaded, total: uploaded.length });
379
+ });
380
+
334
381
  // Create a new file
335
382
  app.post('/api/files/create', (req, res) => {
336
383
  const { path: relPath, content = '' } = req.body;
@@ -595,9 +642,31 @@ export function registerFileRoutes(app, daemon) {
595
642
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
596
643
  const rawFiles = daemon.registry.getFilesTouched(req.params.id);
597
644
  const rootDir = agent.workingDir || daemon.projectDir;
645
+
646
+ // Build git diff numstat for line-level +/- counts
647
+ let numstatMap = {};
648
+ const writtenPaths = rawFiles.filter(f => f.writes > 0).map(f => f.path);
649
+ if (writtenPaths.length > 0) {
650
+ try {
651
+ const out = execFileSync('git', ['diff', '--numstat', '--', ...writtenPaths], {
652
+ cwd: rootDir, timeout: 10000, maxBuffer: 2 * 1024 * 1024,
653
+ }).toString();
654
+ for (const line of out.split('\n')) {
655
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
656
+ if (m) {
657
+ numstatMap[m[3]] = {
658
+ additions: m[1] === '-' ? 0 : Number(m[1]),
659
+ deletions: m[2] === '-' ? 0 : Number(m[2]),
660
+ };
661
+ }
662
+ }
663
+ } catch { /* git not available or not a repo */ }
664
+ }
665
+
598
666
  const files = rawFiles.map(f => {
599
667
  const fullPath = isAbsolute(f.path) ? f.path : resolve(rootDir, f.path);
600
- return { ...f, exists: existsSync(fullPath) };
668
+ const stats = numstatMap[f.path] || null;
669
+ return { ...f, exists: existsSync(fullPath), additions: stats?.additions ?? null, deletions: stats?.deletions ?? null };
601
670
  });
602
671
  res.json({ files, total: files.length });
603
672
  });