groove-dev 0.27.140 → 0.27.141

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 (64) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/integrations-registry.json +12 -44
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +82 -16
  5. package/node_modules/@groove-dev/daemon/src/integrations.js +10 -0
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +169 -0
  7. package/node_modules/@groove-dev/daemon/src/keeper.js +3 -3
  8. package/node_modules/@groove-dev/daemon/src/model-lab.js +11 -0
  9. package/node_modules/@groove-dev/daemon/src/process.js +76 -0
  10. package/node_modules/@groove-dev/daemon/src/validate.js +8 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-A4e1gIDh.css +1 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-P1hsM27-.js +8696 -0
  13. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +3 -3
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +7 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +5 -4
  18. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +160 -12
  19. package/node_modules/@groove-dev/gui/src/components/editor/ai-panel.jsx +77 -6
  20. package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +2 -49
  21. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +15 -4
  22. package/node_modules/@groove-dev/gui/src/components/keeper/global-modals.jsx +10 -10
  23. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +152 -3
  24. package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +223 -18
  25. package/node_modules/@groove-dev/gui/src/stores/groove.js +110 -32
  26. package/node_modules/@groove-dev/gui/src/views/agents.jsx +114 -56
  27. package/node_modules/@groove-dev/gui/src/views/memory.jsx +9 -9
  28. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +1 -6
  29. package/node_modules/@groove-dev/gui/src/views/models.jsx +658 -565
  30. package/package.json +1 -1
  31. package/packages/cli/package.json +1 -1
  32. package/packages/daemon/integrations-registry.json +12 -44
  33. package/packages/daemon/package.json +1 -1
  34. package/packages/daemon/src/api.js +82 -16
  35. package/packages/daemon/src/integrations.js +10 -0
  36. package/packages/daemon/src/journalist.js +169 -0
  37. package/packages/daemon/src/keeper.js +3 -3
  38. package/packages/daemon/src/model-lab.js +11 -0
  39. package/packages/daemon/src/process.js +76 -0
  40. package/packages/daemon/src/validate.js +8 -0
  41. package/packages/gui/dist/assets/index-A4e1gIDh.css +1 -0
  42. package/packages/gui/dist/assets/index-P1hsM27-.js +8696 -0
  43. package/packages/gui/dist/index.html +2 -2
  44. package/packages/gui/package.json +1 -1
  45. package/packages/gui/src/components/agents/agent-chat.jsx +3 -3
  46. package/packages/gui/src/components/agents/agent-file-tree.jsx +7 -2
  47. package/packages/gui/src/components/agents/code-review.jsx +5 -4
  48. package/packages/gui/src/components/agents/workspace-mode.jsx +160 -12
  49. package/packages/gui/src/components/editor/ai-panel.jsx +77 -6
  50. package/packages/gui/src/components/editor/file-tree.jsx +2 -49
  51. package/packages/gui/src/components/editor/terminal.jsx +15 -4
  52. package/packages/gui/src/components/keeper/global-modals.jsx +10 -10
  53. package/packages/gui/src/components/layout/terminal-panel.jsx +152 -3
  54. package/packages/gui/src/components/marketplace/integration-wizard.jsx +223 -18
  55. package/packages/gui/src/stores/groove.js +110 -32
  56. package/packages/gui/src/views/agents.jsx +114 -56
  57. package/packages/gui/src/views/memory.jsx +9 -9
  58. package/packages/gui/src/views/model-lab.jsx +1 -6
  59. package/packages/gui/src/views/models.jsx +658 -565
  60. package/plan_files/keeper-manual.md +53 -42
  61. package/node_modules/@groove-dev/gui/dist/assets/index-BV9CAiw1.css +0 -1
  62. package/node_modules/@groove-dev/gui/dist/assets/index-DK6UIz0n.js +0 -8698
  63. package/packages/gui/dist/assets/index-BV9CAiw1.css +0 -1
  64. package/packages/gui/dist/assets/index-DK6UIz0n.js +0 -8698
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.140",
3
+ "version": "0.27.141",
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",
@@ -432,10 +432,10 @@
432
432
  "icon": "elevenlabs",
433
433
  "tags": ["voice", "tts", "audio", "speech"],
434
434
  "roles": ["creative", "cmo", "support"],
435
- "npmPackage": "elevenlabs-mcp",
435
+ "npmPackage": "@angelogiacco/elevenlabs-mcp-server",
436
436
  "transport": "stdio",
437
437
  "command": "npx",
438
- "args": ["-y", "elevenlabs-mcp"],
438
+ "args": ["-y", "@angelogiacco/elevenlabs-mcp-server"],
439
439
  "authType": "api-key",
440
440
  "envKeys": [
441
441
  { "key": "ELEVENLABS_API_KEY", "label": "API Key", "required": true }
@@ -491,10 +491,10 @@
491
491
  "icon": "hubspot",
492
492
  "tags": ["crm", "sales", "contacts", "marketing"],
493
493
  "roles": ["cmo", "analyst", "support"],
494
- "npmPackage": "hubspot-mcp",
494
+ "npmPackage": "@hubspot/mcp-server",
495
495
  "transport": "stdio",
496
496
  "command": "npx",
497
- "args": ["-y", "hubspot-mcp"],
497
+ "args": ["-y", "@hubspot/mcp-server"],
498
498
  "authType": "api-key",
499
499
  "envKeys": [
500
500
  { "key": "HUBSPOT_ACCESS_TOKEN", "label": "Private App Token", "required": true }
@@ -701,10 +701,10 @@
701
701
  "icon": "mixpanel",
702
702
  "tags": ["analytics", "events", "funnels", "users"],
703
703
  "roles": ["analyst", "cmo", "cfo"],
704
- "npmPackage": "mixpanel-mcp",
704
+ "npmPackage": "@mercuryml/mcp-mixpanel",
705
705
  "transport": "stdio",
706
706
  "command": "npx",
707
- "args": ["-y", "mixpanel-mcp"],
707
+ "args": ["-y", "@mercuryml/mcp-mixpanel"],
708
708
  "authType": "api-key",
709
709
  "envKeys": [
710
710
  { "key": "MIXPANEL_API_SECRET", "label": "API Secret", "required": true }
@@ -761,10 +761,10 @@
761
761
  "icon": "airtable",
762
762
  "tags": ["database", "spreadsheet", "content", "crm"],
763
763
  "roles": ["ea", "cmo", "analyst"],
764
- "npmPackage": "airtable-mcp",
764
+ "npmPackage": "airtable-mcp-server",
765
765
  "transport": "stdio",
766
766
  "command": "npx",
767
- "args": ["-y", "airtable-mcp"],
767
+ "args": ["-y", "airtable-mcp-server"],
768
768
  "authType": "api-key",
769
769
  "envKeys": [
770
770
  { "key": "AIRTABLE_API_KEY", "label": "Personal Access Token", "required": true }
@@ -823,10 +823,10 @@
823
823
  "icon": "intercom",
824
824
  "tags": ["chat", "support", "messaging", "customers"],
825
825
  "roles": ["support", "cmo"],
826
- "npmPackage": "intercom-mcp",
826
+ "npmPackage": "@pipeworx/mcp-intercom",
827
827
  "transport": "stdio",
828
828
  "command": "npx",
829
- "args": ["-y", "intercom-mcp"],
829
+ "args": ["-y", "@pipeworx/mcp-intercom"],
830
830
  "authType": "api-key",
831
831
  "envKeys": [
832
832
  { "key": "INTERCOM_ACCESS_TOKEN", "label": "Access Token", "required": true }
@@ -853,10 +853,10 @@
853
853
  "icon": "twilio",
854
854
  "tags": ["sms", "voice", "phone", "whatsapp"],
855
855
  "roles": ["ea", "support", "cmo"],
856
- "npmPackage": "twilio-mcp",
856
+ "npmPackage": "@twilio-alpha/mcp",
857
857
  "transport": "stdio",
858
858
  "command": "npx",
859
- "args": ["-y", "twilio-mcp"],
859
+ "args": ["-y", "@twilio-alpha/mcp"],
860
860
  "authType": "api-key",
861
861
  "envKeys": [
862
862
  { "key": "TWILIO_ACCOUNT_SID", "label": "Account SID", "placeholder": "AC...", "required": true },
@@ -939,37 +939,5 @@
939
939
  "rating": 0,
940
940
  "ratingCount": 0,
941
941
  "verified": "community"
942
- },
943
- {
944
- "id": "plaid",
945
- "name": "Plaid",
946
- "description": "Bank connections and financial data",
947
- "category": "finance",
948
- "icon": "plaid",
949
- "tags": ["banking", "transactions", "finance", "accounts"],
950
- "roles": ["cfo", "analyst"],
951
- "npmPackage": "plaid-mcp",
952
- "transport": "stdio",
953
- "command": "npx",
954
- "args": ["-y", "plaid-mcp"],
955
- "authType": "api-key",
956
- "envKeys": [
957
- { "key": "PLAID_CLIENT_ID", "label": "Client ID", "required": true },
958
- { "key": "PLAID_SECRET", "label": "Secret", "required": true },
959
- { "key": "PLAID_ENV", "label": "Environment", "placeholder": "sandbox", "required": false }
960
- ],
961
- "setupUrl": "https://dashboard.plaid.com/team/keys",
962
- "setupSteps": [
963
- "Click the link below to open Plaid team keys",
964
- "Copy your Client ID and Secret",
965
- "Use 'sandbox' environment for testing"
966
- ],
967
- "requiresApproval": [],
968
- "agentInstructions": "## Plaid Integration\n\nYou have Plaid connected via GROOVE.\n\n### API\n`POST http://localhost:31415/api/integrations/plaid/exec`\nBody: `{\"tool\": \"<tool>\", \"params\": {...}}`\n\n### Common Tools\n- `get_accounts` — params: {access_token}\n- `get_transactions` — params: {access_token, start_date, end_date}\n- `get_balance` — params: {access_token}\n\n### Rules\n- Use sandbox environment for testing\n- Never expose access_tokens in output",
969
- "featured": false,
970
- "downloads": 0,
971
- "rating": 0,
972
- "ratingCount": 0,
973
- "verified": "community"
974
942
  }
975
943
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.140",
3
+ "version": "0.27.141",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -3231,8 +3231,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3231
3231
  const raw = readdirSync(fullPath, { withFileTypes: true });
3232
3232
  const entries = [];
3233
3233
 
3234
+ const HIDDEN_DIRS = new Set(['.git', 'node_modules', '.groove', '.next', '.nuxt', '__pycache__', '.venv', 'dist', '.cache']);
3235
+ const HIDDEN_FILES = new Set(['.DS_Store']);
3236
+
3234
3237
  const dirs = raw.filter((e) => {
3235
- if (e.name === '.DS_Store') return false;
3238
+ if (HIDDEN_FILES.has(e.name) || HIDDEN_DIRS.has(e.name)) return false;
3236
3239
  if (e.isDirectory()) return true;
3237
3240
  if (e.isSymbolicLink()) {
3238
3241
  try { return statSync(resolve(fullPath, e.name)).isDirectory(); }
@@ -3241,7 +3244,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3241
3244
  return false;
3242
3245
  }).sort((a, b) => a.name.localeCompare(b.name));
3243
3246
  const files = raw.filter((e) => {
3244
- if (e.name === '.DS_Store') return false;
3247
+ if (HIDDEN_FILES.has(e.name)) return false;
3245
3248
  if (e.isFile()) return true;
3246
3249
  if (e.isSymbolicLink()) {
3247
3250
  try { return statSync(resolve(fullPath, e.name)).isFile(); }
@@ -6986,30 +6989,93 @@ Keep responses concise. Help them think, don't lecture them about the system the
6986
6989
  app.post('/api/lab/inference', async (req, res) => {
6987
6990
  try {
6988
6991
  const params = validateLabInferenceParams(req.body);
6992
+ const rt = daemon.modelLab.getRuntime(params.runtimeId);
6993
+ if (!rt) throw new Error('Runtime not found');
6994
+
6995
+ const url = new URL(`${rt.endpoint}/v1/chat/completions`);
6996
+ const reqHeaders = { 'Content-Type': 'application/json' };
6997
+ if (rt.apiKey) reqHeaders['Authorization'] = `Bearer ${rt.apiKey}`;
6998
+
6999
+ const body = {
7000
+ model: params.model,
7001
+ messages: params.messages,
7002
+ stream: true,
7003
+ };
7004
+ const pb = params.parameters || {};
7005
+ if (pb.temperature !== undefined) body.temperature = pb.temperature;
7006
+ if (pb.top_p !== undefined) body.top_p = pb.top_p;
7007
+ if (pb.top_k !== undefined) body.top_k = pb.top_k;
7008
+ if (pb.repeat_penalty !== undefined) body.repeat_penalty = pb.repeat_penalty;
7009
+ if (pb.max_tokens !== undefined) body.max_tokens = pb.max_tokens;
7010
+ if (pb.stop !== undefined) body.stop = pb.stop;
7011
+ if (pb.frequency_penalty !== undefined) body.frequency_penalty = pb.frequency_penalty;
7012
+ if (pb.presence_penalty !== undefined) body.presence_penalty = pb.presence_penalty;
7013
+
7014
+ const payload = JSON.stringify(body);
7015
+
7016
+ // Use Node http module directly — Electron's fetch has stream issues
7017
+ const { request: httpRequest } = await import('http');
7018
+ const upstream = await new Promise((resolve, reject) => {
7019
+ const r = httpRequest({
7020
+ hostname: url.hostname,
7021
+ port: url.port,
7022
+ path: url.pathname,
7023
+ method: 'POST',
7024
+ headers: { ...reqHeaders, 'Content-Length': Buffer.byteLength(payload) },
7025
+ timeout: 300000,
7026
+ }, resolve);
7027
+ r.on('error', reject);
7028
+ r.on('timeout', () => { r.destroy(); reject(new Error('Upstream timeout')); });
7029
+ r.write(payload);
7030
+ r.end();
7031
+ });
7032
+
7033
+ if (upstream.statusCode !== 200) {
7034
+ let errMsg = `HTTP ${upstream.statusCode}`;
7035
+ try {
7036
+ const chunks = [];
7037
+ for await (const c of upstream) chunks.push(c);
7038
+ const data = JSON.parse(Buffer.concat(chunks).toString());
7039
+ errMsg = data.error?.message || errMsg;
7040
+ } catch { /* ignore */ }
7041
+ throw new Error(errMsg);
7042
+ }
6989
7043
 
7044
+ // Pipe raw OpenAI-compatible SSE straight to client
6990
7045
  res.setHeader('Content-Type', 'text/event-stream');
6991
7046
  res.setHeader('Cache-Control', 'no-cache');
6992
7047
  res.setHeader('Connection', 'keep-alive');
6993
7048
  res.setHeader('X-Accel-Buffering', 'no');
6994
- res.flushHeaders();
6995
-
6996
- let closed = false;
6997
- req.on('close', () => { closed = true; });
6998
-
6999
- await daemon.modelLab.streamInference(params, (event) => {
7000
- if (!closed) res.write(`data: ${JSON.stringify(event)}\n\n`);
7001
- });
7002
-
7003
- if (!closed) {
7004
- res.write('data: [DONE]\n\n');
7005
- res.end();
7049
+ upstream.pipe(res);
7050
+
7051
+ // Collect content for session persistence
7052
+ if (params.sessionId) {
7053
+ let full = '';
7054
+ upstream.on('data', (chunk) => {
7055
+ const text = chunk.toString('utf8');
7056
+ for (const line of text.split('\n')) {
7057
+ const trimmed = line.trim();
7058
+ if (!trimmed.startsWith('data: ')) continue;
7059
+ const d = trimmed.slice(6);
7060
+ if (d === '[DONE]') continue;
7061
+ try {
7062
+ const p = JSON.parse(d);
7063
+ const c = p.choices?.[0]?.delta?.content;
7064
+ if (c) full += c;
7065
+ } catch { /* skip */ }
7066
+ }
7067
+ });
7068
+ upstream.on('end', () => {
7069
+ if (full) daemon.modelLab._appendToSession(params.sessionId, params.messages, { role: 'assistant', content: full });
7070
+ });
7006
7071
  }
7072
+
7073
+ req.on('close', () => { upstream.destroy(); });
7007
7074
  } catch (err) {
7008
7075
  if (!res.headersSent) {
7009
7076
  res.status(400).json({ error: err.message });
7010
7077
  } else {
7011
- res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`);
7012
- res.end();
7078
+ try { res.end(); } catch { /* ignore */ }
7013
7079
  }
7014
7080
  }
7015
7081
  });
@@ -127,6 +127,16 @@ export class IntegrationStore {
127
127
  if (this._isInstalled(integrationId)) throw new Error(`Integration already installed: ${integrationId}`);
128
128
 
129
129
  if (entry.npmPackage) {
130
+ // Pre-validate: check the package exists on npm before attempting install
131
+ try {
132
+ execFileSync('npm', ['view', entry.npmPackage, 'version'], {
133
+ stdio: 'pipe',
134
+ timeout: 10_000,
135
+ });
136
+ } catch {
137
+ throw new Error(`Package ${entry.npmPackage} is not available on npm. Use the agent-assisted install flow instead.`);
138
+ }
139
+
130
140
  try {
131
141
  execFileSync('npm', ['install', '--legacy-peer-deps', entry.npmPackage], {
132
142
  cwd: this.integrationsDir,
@@ -994,6 +994,175 @@ export class Journalist {
994
994
  return brief;
995
995
  }
996
996
 
997
+ // --- Conversation Thread Extraction (for idle resume) ---
998
+
999
+ /**
1000
+ * Extract the actual user↔assistant conversation from stream-json logs.
1001
+ * Returns the dialogue in chronological order — user messages interleaved
1002
+ * with Claude's text responses. This preserves the "why" context that
1003
+ * handoff briefs lose through summarization.
1004
+ *
1005
+ * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1006
+ */
1007
+ extractConversationThread(agent, { maxChars = 60000 } = {}) {
1008
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
1009
+ if (!existsSync(logPath)) return null;
1010
+
1011
+ let content;
1012
+ try {
1013
+ content = readFileSync(logPath, 'utf8');
1014
+ } catch { return null; }
1015
+
1016
+ const lines = content.split('\n');
1017
+ const turns = []; // { role: 'user'|'assistant', text, timestamp }
1018
+
1019
+ for (const line of lines) {
1020
+ if (!line.trim() || line.startsWith('[')) continue;
1021
+ try {
1022
+ const data = JSON.parse(line);
1023
+
1024
+ // User messages
1025
+ if (data.type === 'user' && data.message?.content) {
1026
+ const msgContent = data.message.content;
1027
+ let text = '';
1028
+ if (typeof msgContent === 'string') {
1029
+ text = msgContent;
1030
+ } else if (Array.isArray(msgContent)) {
1031
+ // Extract text blocks, skip tool_result blocks (noise)
1032
+ text = msgContent
1033
+ .filter((b) => b.type === 'text' && b.text)
1034
+ .map((b) => b.text)
1035
+ .join('\n');
1036
+ }
1037
+ if (text.trim().length > 5) {
1038
+ turns.push({ role: 'user', text: text.trim(), timestamp: data.timestamp });
1039
+ }
1040
+ }
1041
+
1042
+ // Assistant text responses (what Claude said — the reasoning/explanations)
1043
+ if (data.type === 'assistant' && data.message?.content) {
1044
+ const blocks = Array.isArray(data.message.content) ? data.message.content : [];
1045
+ const textParts = blocks
1046
+ .filter((b) => b.type === 'text' && b.text && b.text.trim().length > 20)
1047
+ .map((b) => b.text.trim());
1048
+ if (textParts.length > 0) {
1049
+ turns.push({ role: 'assistant', text: textParts.join('\n'), timestamp: data.timestamp });
1050
+ }
1051
+ }
1052
+ } catch { /* skip non-JSON */ }
1053
+ }
1054
+
1055
+ if (turns.length === 0) return null;
1056
+
1057
+ // Deduplicate consecutive same-role turns (merge them)
1058
+ const merged = [];
1059
+ for (const turn of turns) {
1060
+ const last = merged[merged.length - 1];
1061
+ if (last && last.role === turn.role) {
1062
+ last.text += '\n' + turn.text;
1063
+ } else {
1064
+ merged.push({ ...turn });
1065
+ }
1066
+ }
1067
+
1068
+ // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1069
+ let thread = '';
1070
+ const formatted = merged.map((t) => {
1071
+ const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1072
+ return `[${label}]:\n${t.text}`;
1073
+ });
1074
+
1075
+ // Start from the end (most recent) and work backwards to fill budget
1076
+ const recentFirst = [...formatted].reverse();
1077
+ const kept = [];
1078
+ let totalLen = 0;
1079
+
1080
+ for (const entry of recentFirst) {
1081
+ if (totalLen + entry.length > maxChars) {
1082
+ // Truncate this entry to fit remaining budget
1083
+ const remaining = maxChars - totalLen;
1084
+ if (remaining > 200) {
1085
+ kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1086
+ }
1087
+ break;
1088
+ }
1089
+ kept.push(entry);
1090
+ totalLen += entry.length;
1091
+ }
1092
+
1093
+ // Reverse back to chronological order
1094
+ kept.reverse();
1095
+ thread = kept.join('\n\n---\n\n');
1096
+
1097
+ return thread;
1098
+ }
1099
+
1100
+ /**
1101
+ * Build a full context-resume prompt that preserves the conversation
1102
+ * thread so a fresh agent picks up where the previous session left off.
1103
+ */
1104
+ buildConversationResumePrompt(agent, userMessage) {
1105
+ const thread = this.extractConversationThread(agent);
1106
+ if (!thread) return null;
1107
+
1108
+ const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
1109
+ const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000) || '';
1110
+
1111
+ let prompt = [
1112
+ `# Session Context Resume`,
1113
+ ``,
1114
+ `You are continuing a session that went idle. Below is the full conversation`,
1115
+ `from your previous session — your actual exchanges with the user. Pick up`,
1116
+ `exactly where you left off. The user's new message follows at the end.`,
1117
+ ``,
1118
+ `Role: ${agent.role} | Provider: ${agent.provider} | Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
1119
+ agent.workingDir ? `Working directory: ${agent.workingDir}` : '',
1120
+ ``,
1121
+ constraints ? `## Project Constraints\n\n${constraints}\n` : '',
1122
+ discoveries ? `## Known Issues & Fixes\n\n${discoveries}\n` : '',
1123
+ `## Previous Conversation\n\n${thread}`,
1124
+ ``,
1125
+ `---`,
1126
+ ``,
1127
+ `## New Message From User`,
1128
+ ``,
1129
+ userMessage,
1130
+ ``,
1131
+ `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
1132
+ ].filter(Boolean).join('\n');
1133
+
1134
+ // Hard cap at 80K chars (~20K tokens) to leave plenty of room in context window
1135
+ if (prompt.length > 80000) {
1136
+ // Re-extract with smaller budget and rebuild
1137
+ const smallerThread = this.extractConversationThread(agent, { maxChars: 40000 });
1138
+ if (smallerThread) {
1139
+ prompt = [
1140
+ `# Session Context Resume`,
1141
+ ``,
1142
+ `You are continuing a session that went idle. Below is the conversation`,
1143
+ `from your previous session (older turns summarized to fit). Pick up`,
1144
+ `exactly where you left off.`,
1145
+ ``,
1146
+ `Role: ${agent.role} | Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
1147
+ agent.workingDir ? `Working directory: ${agent.workingDir}` : '',
1148
+ ``,
1149
+ constraints ? `## Project Constraints\n\n${constraints}\n` : '',
1150
+ `## Previous Conversation\n\n${smallerThread}`,
1151
+ ``,
1152
+ `---`,
1153
+ ``,
1154
+ `## New Message From User`,
1155
+ ``,
1156
+ userMessage,
1157
+ ``,
1158
+ `Continue seamlessly. Do not ask the user to repeat anything.`,
1159
+ ].filter(Boolean).join('\n');
1160
+ }
1161
+ }
1162
+
1163
+ return prompt;
1164
+ }
1165
+
997
1166
  // --- Workspace Grouping ---
998
1167
 
999
1168
  /**
@@ -254,10 +254,10 @@ export class Keeper {
254
254
  // ── Command parser ────────────────────────────────────────
255
255
 
256
256
  static parseCommand(text) {
257
- const cmdMatch = text.match(/^\s*\[(save|append|update|delete|view|doc|link|read|instruct)\]\s*/i);
257
+ const cmdMatch = text.match(/\[(save|append|update|delete|view|doc|link|read|instruct)\]|\b(save|append|update|delete|view|doc|link|read)\b(?=\s+#[\w/.-])/i);
258
258
  if (!cmdMatch) return null;
259
- const command = cmdMatch[1].toLowerCase();
260
- const rest = text.slice(cmdMatch[0].length).trim();
259
+ const command = (cmdMatch[1] || cmdMatch[2]).toLowerCase();
260
+ const rest = text.slice(cmdMatch.index + cmdMatch[0].length).trim();
261
261
 
262
262
  if (command === 'instruct') {
263
263
  return { command, tags: [], extra: null };
@@ -95,6 +95,17 @@ export class ModelLab {
95
95
  removeRuntime(id) {
96
96
  const rt = this.runtimes.get(id);
97
97
  if (!rt) return null;
98
+
99
+ // Stop the llama-server process if this is a local model runtime
100
+ if (rt._localModelId) {
101
+ const mm = this.daemon.modelManager;
102
+ const ls = this.daemon.llamaServer;
103
+ if (mm && ls) {
104
+ const modelPath = mm.getModelPath(rt._localModelId);
105
+ if (modelPath) ls.stopServer(modelPath).catch(() => {});
106
+ }
107
+ }
108
+
98
109
  this.runtimes.delete(id);
99
110
  this._saveRuntimes();
100
111
  this.daemon.broadcast({ type: 'lab:runtime:removed', data: { id } });
@@ -1976,6 +1976,10 @@ For normal file edits within your scope, proceed without review.
1976
1976
  * Resume a completed agent's session with a new message.
1977
1977
  * Uses --resume SESSION_ID for zero cold-start continuation.
1978
1978
  * Falls back to full spawn if no session ID available.
1979
+ *
1980
+ * If the agent has been idle for longer than IDLE_CONTEXT_THRESHOLD,
1981
+ * spawns fresh with the full conversation thread instead of --resume.
1982
+ * This avoids degraded context from internal compaction.
1979
1983
  */
1980
1984
  async resume(agentId, message) {
1981
1985
  const { registry, locks } = this.daemon;
@@ -1996,6 +2000,23 @@ For normal file edits within your scope, proceed without review.
1996
2000
  return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
1997
2001
  }
1998
2002
 
2003
+ // Context-aware idle resume: if the agent has been idle long enough for
2004
+ // internal compaction to degrade context, spawn fresh with the full
2005
+ // conversation thread instead of resuming the compacted session.
2006
+ const IDLE_CONTEXT_THRESHOLD_MS = 5 * 60_000; // 5 minutes (matches prompt cache TTL)
2007
+ const idleMs = agent.lastActivity
2008
+ ? Date.now() - new Date(agent.lastActivity).getTime()
2009
+ : Infinity;
2010
+
2011
+ if (idleMs > IDLE_CONTEXT_THRESHOLD_MS && this.daemon.journalist) {
2012
+ const resumePrompt = this.daemon.journalist.buildConversationResumePrompt(agent, message);
2013
+ if (resumePrompt) {
2014
+ console.log(`[Groove] Agent ${agent.name} idle ${Math.round(idleMs / 60000)}min — using conversation-thread resume`);
2015
+ // Use rotation machinery but with our richer prompt instead of handoff brief
2016
+ return this._conversationResume(agentId, agent, resumePrompt);
2017
+ }
2018
+ }
2019
+
1999
2020
  const provider = getProvider(agent.provider || 'claude-code');
2000
2021
  if (!provider?.buildResumeCommand) {
2001
2022
  return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
@@ -2125,6 +2146,61 @@ For normal file edits within your scope, proceed without review.
2125
2146
  return newAgent;
2126
2147
  }
2127
2148
 
2149
+ /**
2150
+ * Conversation-thread resume: spawns a fresh agent with the full
2151
+ * user↔assistant conversation as context instead of using --resume
2152
+ * on a potentially compacted session. Used when idle > threshold.
2153
+ */
2154
+ async _conversationResume(agentId, agent, resumePrompt) {
2155
+ const { registry, locks } = this.daemon;
2156
+ const config = { ...agent };
2157
+
2158
+ if (this.handles.has(agentId)) {
2159
+ await this.kill(agentId);
2160
+ }
2161
+ registry.remove(agentId);
2162
+ locks.release(agentId);
2163
+
2164
+ const newAgent = await this.spawn({
2165
+ role: config.role,
2166
+ scope: config.scope,
2167
+ provider: config.provider,
2168
+ model: config.model,
2169
+ prompt: resumePrompt,
2170
+ permission: config.permission || 'full',
2171
+ workingDir: config.workingDir,
2172
+ name: config.name,
2173
+ teamId: config.teamId,
2174
+ isRotation: true,
2175
+ });
2176
+
2177
+ // Carry cumulative token count for tracking
2178
+ if (config.tokensUsed > 0) {
2179
+ registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
2180
+ }
2181
+
2182
+ if (this.daemon.timeline) {
2183
+ this.daemon.timeline.recordEvent('conversation_resume', {
2184
+ agentId: newAgent.id,
2185
+ oldAgentId: agentId,
2186
+ agentName: newAgent.name,
2187
+ role: config.role,
2188
+ idleMinutes: Math.round((Date.now() - new Date(config.lastActivity).getTime()) / 60000),
2189
+ });
2190
+ }
2191
+
2192
+ this.daemon.broadcast({
2193
+ type: 'rotation:complete',
2194
+ agentId: newAgent.id,
2195
+ agentName: newAgent.name,
2196
+ oldAgentId: agentId,
2197
+ reason: 'conversation_resume',
2198
+ tokensSaved: 0,
2199
+ });
2200
+
2201
+ return newAgent;
2202
+ }
2203
+
2128
2204
  async _resumeAgentLoop(agentId, agent, message, provider) {
2129
2205
  const { registry, locks } = this.daemon;
2130
2206
  const config = { ...agent };
@@ -112,6 +112,12 @@ export function validateAgentConfig(config) {
112
112
  if (!isNaN(v) && v >= 0 && v <= 100) verbosity = Math.round(v);
113
113
  }
114
114
 
115
+ const validEffort = ['min', 'low', 'default', 'high', 'max'];
116
+ const effort = validEffort.includes(config.effort) ? config.effort : undefined;
117
+
118
+ const validRouting = ['fixed', 'auto', 'auto-floor'];
119
+ const routingMode = validRouting.includes(config.routingMode) ? config.routingMode : undefined;
120
+
115
121
  // Return sanitized config (only known fields)
116
122
  return {
117
123
  role: config.role,
@@ -131,6 +137,8 @@ export function validateAgentConfig(config) {
131
137
  reasoningEffort: numericReasoningEffort ?? reasoningEffort,
132
138
  temperature,
133
139
  verbosity,
140
+ effort,
141
+ routingMode,
134
142
  labPresetId: (typeof config.labPresetId === 'string' && config.labPresetId.length <= 64) ? config.labPresetId : undefined,
135
143
  keeperTags: Array.isArray(config.keeperTags) ? config.keeperTags.filter(t => typeof t === 'string').slice(0, 20) : undefined,
136
144
  };