groove-dev 0.27.185 → 0.27.187

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 (33) 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/api.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +16 -0
  5. package/node_modules/@groove-dev/daemon/src/introducer.js +2 -2
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
  7. package/node_modules/@groove-dev/daemon/src/process.js +3 -1
  8. package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
  9. package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
  10. package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
  11. package/node_modules/@groove-dev/gui/dist/assets/{index-DEZwM4Hb.js → index-DyI84i9Y.js} +225 -225
  12. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +55 -4
  15. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
  16. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
  17. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/api.js +1 -0
  22. package/packages/daemon/src/innerchat-docs.js +16 -0
  23. package/packages/daemon/src/introducer.js +2 -2
  24. package/packages/daemon/src/journalist.js +83 -42
  25. package/packages/daemon/src/process.js +3 -1
  26. package/packages/daemon/src/rotator.js +6 -1
  27. package/packages/gui/dist/assets/{index-DEZwM4Hb.js → index-DyI84i9Y.js} +225 -225
  28. package/packages/gui/dist/index.html +1 -1
  29. package/packages/gui/package.json +1 -1
  30. package/packages/gui/src/components/agents/agent-feed.jsx +55 -4
  31. package/packages/gui/src/components/editor/terminal.jsx +25 -0
  32. package/packages/gui/src/lib/logpaths.js +45 -0
  33. package/packages/gui/src/stores/slices/ui-slice.js +11 -0
@@ -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-DEZwM4Hb.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DyI84i9Y.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.185",
3
+ "version": "0.27.187",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { useGrooveStore } from '../../stores/groove';
12
12
  import { cn } from '../../lib/cn';
13
13
  import { timeAgo } from '../../lib/format';
14
14
  import { api } from '../../lib/api';
15
+ import { extractLogPaths, logLabel } from '../../lib/logpaths';
15
16
  import { ThinkingIndicator } from '../ui/thinking-indicator';
16
17
  import { TableTree } from '../ui/table-tree';
17
18
 
@@ -346,8 +347,14 @@ function UserMessage({ msg }) {
346
347
  function InnerChatMessage({ msg, agent }) {
347
348
  const [collapsed, setCollapsed] = useState(msg.text?.length > 400);
348
349
  const isLong = msg.text?.length > 400;
349
- const { peer, direction, kind } = msg.innerchat || {};
350
- const outbound = direction === 'out';
350
+ const info = msg.innerchat || {};
351
+ // `peer` is the current field; `fromAgent` is the pre-`tell` schema still
352
+ // sitting in persisted chatHistory. Fall back so old bubbles never render
353
+ // "undefined".
354
+ const peer = info.peer || info.fromAgent;
355
+ const peerName = peer?.name || 'another agent';
356
+ const outbound = info.direction === 'out';
357
+ const isAnswer = info.kind === 'answer' || info.kind === 'reply';
351
358
  const me = agent?.name || 'this agent';
352
359
 
353
360
  return (
@@ -356,10 +363,10 @@ function InnerChatMessage({ msg, agent }) {
356
363
  <ArrowLeftRight size={10} className="text-indigo flex-shrink-0" />
357
364
  <span className="text-2xs font-semibold text-indigo font-sans">InnerChat</span>
358
365
  <span className="text-2xs text-text-3 font-sans truncate">
359
- {outbound ? `${me} → ${peer?.name}` : `${peer?.name} → ${me}`}
366
+ {outbound ? `${me} → ${peerName}` : `${peerName} → ${me}`}
360
367
  </span>
361
368
  <span className="text-2xs text-text-4 font-sans">
362
- {kind === 'answer' ? 'answer' : outbound ? 'asked' : 'asks'}
369
+ {isAnswer ? 'answer' : outbound ? 'asked' : 'asks'}
363
370
  </span>
364
371
  <span className="text-[10px] text-text-4 font-sans ml-auto flex-shrink-0">{timeAgo(msg.timestamp)}</span>
365
372
  </div>
@@ -378,6 +385,49 @@ function InnerChatMessage({ msg, agent }) {
378
385
  );
379
386
  }
380
387
 
388
+ // Single-quote a value for safe use inside the shell command we inject.
389
+ function shq(s) {
390
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
391
+ }
392
+
393
+ // Build a `tail -f` that actually resolves. Agents usually write a log path
394
+ // relative to wherever they ran it — often a bare filename several directories
395
+ // deep — but the terminal opens in a different cwd, so a naive `tail -f name`
396
+ // fails. Absolute paths are used as-is; anything else is resolved against the
397
+ // agent's working directory, falling back to a `find` by basename when the file
398
+ // sits below that directory.
399
+ function tailCommand(path, workdir) {
400
+ if (/^[/~]/.test(path) || !workdir) return `tail -f ${shq(path)}`;
401
+ const base = path.split('/').pop();
402
+ return `cd ${shq(workdir)} 2>/dev/null; `
403
+ + `if [ -f ${shq(path)} ]; then tail -f ${shq(path)}; `
404
+ + `else tail -f "$(find . -name ${shq(base)} -type f 2>/dev/null | head -1)"; fi`;
405
+ }
406
+
407
+ // One-click "tail" chips for any log paths the agent mentioned — saves asking
408
+ // "what's the log file?" and hand-copying it into a terminal.
409
+ function LogChips({ text, workdir }) {
410
+ const runInTerminal = useGrooveStore((s) => s.runInTerminal);
411
+ const paths = useMemo(() => extractLogPaths(text), [text]);
412
+ if (paths.length === 0) return null;
413
+
414
+ return (
415
+ <div className="ml-3.5 mt-1.5 flex flex-wrap gap-1.5">
416
+ {paths.map((path) => (
417
+ <button
418
+ key={path}
419
+ onClick={() => runInTerminal(tailCommand(path, workdir))}
420
+ title={`tail -f ${path}`}
421
+ className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/10 hover:bg-accent/20 text-accent text-[11px] font-medium font-sans cursor-pointer transition-colors max-w-full"
422
+ >
423
+ <Terminal size={11} className="flex-shrink-0" />
424
+ <span className="truncate">tail {logLabel(path)}</span>
425
+ </button>
426
+ ))}
427
+ </div>
428
+ );
429
+ }
430
+
381
431
  function AgentMessage({ msg, agent, answeredTo }) {
382
432
  const [collapsed, setCollapsed] = useState(msg.text?.length > 600);
383
433
  const isLong = msg.text?.length > 600;
@@ -399,6 +449,7 @@ function AgentMessage({ msg, agent, answeredTo }) {
399
449
  <div className={cn('pl-3.5 py-1 border-l', answeredTo ? 'border-indigo/50' : 'border-accent')}>
400
450
  <StructuredMessage text={collapsed ? msg.text.slice(0, 600) + '...' : msg.text} />
401
451
  </div>
452
+ <LogChips text={msg.text} workdir={agent?.workingDir} />
402
453
  {collapsed && (
403
454
  <button
404
455
  onClick={() => setCollapsed(false)}
@@ -31,6 +31,7 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
31
31
  const mountedRef = useRef(false);
32
32
  const visibleRef = useRef(visible);
33
33
  const lastSizeRef = useRef({ cols: 0, rows: 0 });
34
+ const outputReadyRef = useRef(false);
34
35
 
35
36
  useEffect(() => {
36
37
  registerKill?.(tabId, () => {
@@ -41,6 +42,28 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
41
42
  });
42
43
  }, [tabId, registerKill]);
43
44
 
45
+ // Run a command dropped into the store by another component (e.g. a "tail
46
+ // log" chip in chat). Only the visible tab handles it, and we poll until this
47
+ // instance's PTY is ready — a freshly opened terminal spawns asynchronously,
48
+ // and keystrokes sent before it's ready are dropped.
49
+ const pendingCommand = useGrooveStore((s) => s.terminalPendingCommand);
50
+ useEffect(() => {
51
+ if (!pendingCommand || !visible) return;
52
+ let tries = 0;
53
+ let timer = null;
54
+ const send = () => {
55
+ const ws = useGrooveStore.getState().ws;
56
+ if (ws?.readyState === WebSocket.OPEN && termIdRef.current && outputReadyRef.current) {
57
+ ws.send(JSON.stringify({ type: 'terminal:input', id: termIdRef.current, data: pendingCommand.command + '\r' }));
58
+ useGrooveStore.getState().clearTerminalPendingCommand();
59
+ return;
60
+ }
61
+ if (tries++ < 40) timer = setTimeout(send, 150); // ~6s max, covers spawn
62
+ };
63
+ send();
64
+ return () => clearTimeout(timer);
65
+ }, [pendingCommand, visible]);
66
+
44
67
  useEffect(() => {
45
68
  if (!containerRef.current || mountedRef.current) return;
46
69
  mountedRef.current = true;
@@ -111,6 +134,7 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
111
134
  // Wipe xterm so garbled output from wrong-sized PTY is never visible
112
135
  term.reset();
113
136
  outputReady = true;
137
+ outputReadyRef.current = true;
114
138
  // Ask the shell to clear screen and redraw its prompt
115
139
  const w2 = useGrooveStore.getState().ws;
116
140
  if (w2?.readyState === WebSocket.OPEN && termIdRef.current) {
@@ -121,6 +145,7 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
121
145
  if (outputReady) term.write(msg.data);
122
146
  } else if (msg.type === 'terminal:exit' && msg.id === termIdRef.current) {
123
147
  outputReady = true;
148
+ outputReadyRef.current = false; // no live shell to accept commands
124
149
  term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
125
150
  termIdRef.current = null;
126
151
  }
@@ -0,0 +1,45 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // Pull tailable log targets out of an agent's message so the chat can offer a
4
+ // one-click "tail" instead of the user asking "what's the log file?".
5
+ //
6
+ // Two signals, in priority order:
7
+ // 1. An explicit `tail -f <path>` the agent already wrote — highest confidence.
8
+ // 2. Any path-like token ending in .log.
9
+ // Both are conservative: a bare word like "changelog" is not a path and won't
10
+ // match, and results are de-duped by full path.
11
+
12
+ const TAIL_RE = /tail\s+(?:-[a-zA-Z]+\s+)*([~/]?[\w./-]+)/g;
13
+ const DOTLOG_RE = /(?:^|[\s'"`(=])([~/]?(?:[\w.-]+\/)*[\w.-]+\.log)\b/g;
14
+
15
+ function clean(p) {
16
+ return p.replace(/^['"`]+/, '').replace(/['"`.,;:)]+$/, '');
17
+ }
18
+
19
+ export function extractLogPaths(text) {
20
+ if (!text || typeof text !== 'string') return [];
21
+ const out = [];
22
+ const seen = new Set();
23
+
24
+ const add = (raw) => {
25
+ const p = clean(raw);
26
+ // Require something path-shaped: a slash, a ~, or a .log extension. Guards
27
+ // against picking up plain words that followed "tail".
28
+ if (!p || p.length < 3) return;
29
+ if (!p.endsWith('.log') && !p.includes('/') && !p.startsWith('~')) return;
30
+ if (seen.has(p)) return;
31
+ seen.add(p);
32
+ out.push(p);
33
+ };
34
+
35
+ for (const m of text.matchAll(TAIL_RE)) add(m[1]);
36
+ for (const m of text.matchAll(DOTLOG_RE)) add(m[1]);
37
+
38
+ return out;
39
+ }
40
+
41
+ // A short label for the chip — the basename, which is what the user recognizes.
42
+ export function logLabel(path) {
43
+ const base = path.replace(/\/+$/, '').split('/').pop() || path;
44
+ return base.length > 28 ? `…${base.slice(-27)}` : base;
45
+ }
@@ -106,6 +106,17 @@ export const createUiSlice = (set, get) => ({
106
106
  set(updates);
107
107
  localStorage.setItem('groove:terminalVisible', String(v));
108
108
  },
109
+
110
+ // Cross-component channel for running a command in the terminal panel. The
111
+ // terminal id lives inside the active TerminalInstance, so components can't
112
+ // send to it directly — they drop a command here, the visible instance picks
113
+ // it up (waiting for its PTY to be ready) and clears it.
114
+ terminalPendingCommand: null,
115
+ runInTerminal(command) {
116
+ get().setTerminalVisible(true);
117
+ set({ terminalPendingCommand: { command, ts: Date.now() } });
118
+ },
119
+ clearTerminalPendingCommand() { set({ terminalPendingCommand: null }); },
109
120
  setTerminalHeight(h) {
110
121
  set({ terminalHeight: h });
111
122
  localStorage.setItem('groove:terminalHeight', String(h));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.185",
3
+ "version": "0.27.187",
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.185",
3
+ "version": "0.27.187",
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.185",
3
+ "version": "0.27.187",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1441,6 +1441,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1441
1441
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1442
1442
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1443
1443
  'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1444
+ 'resumeBudgetChars',
1444
1445
  ];
1445
1446
  for (const key of Object.keys(req.body)) {
1446
1447
  if (!ALLOWED_KEYS.includes(key)) {
@@ -94,3 +94,19 @@ export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
94
94
  '- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
95
95
  ];
96
96
  }
97
+
98
+ // Log-path convention. The GUI turns log paths in a message into a one-click
99
+ // "tail" button; a bare or relative filename can't be located reliably (there
100
+ // may be several with the same name in different directories), so agents must
101
+ // give the absolute path.
102
+ export function logFileInstructions() {
103
+ return [
104
+ '## Mentioning Log Files',
105
+ '',
106
+ 'When you start a process that writes to a log the user may want to watch, state the',
107
+ "log's ABSOLUTE path — e.g. `Logging to /home/you/project/runs/train.log` — not a bare",
108
+ 'or relative filename. The GUI turns that path into a one-click "tail" button for the',
109
+ 'user; a bare name like `train.log` cannot be located reliably (several files may share',
110
+ 'it), so always resolve it to a full path first (`realpath` / `readlink -f` if unsure).',
111
+ ];
112
+ }
@@ -4,7 +4,7 @@
4
4
  import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
5
5
  import { resolve, dirname, basename } from 'path';
6
6
  import { escapeMd } from './validate.js';
7
- import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
7
+ import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
8
8
 
9
9
  const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
10
10
  const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
@@ -561,7 +561,7 @@ export class Introducer {
561
561
  // compaction — the spawn prompt alone can scroll out of a long session.
562
562
  _innerChatSection() {
563
563
  const port = this.daemon.port || 31415;
564
- return [...innerChatInstructions(port), '', ...watchInstructions(port)];
564
+ return [...innerChatInstructions(port), '', ...watchInstructions(port), '', ...logFileInstructions()];
565
565
  }
566
566
 
567
567
  writeRegistryFile(projectDir) {
@@ -10,6 +10,12 @@ import { agentLogPath } from './process.js';
10
10
  const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
11
11
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
12
12
  const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
13
+ // Conversation carried across a rotation (~37K tokens at 150K chars). This is
14
+ // deliberately generous: a rotation is the one lossy step in an infinite
15
+ // session, and re-reading the recent dialogue once per rotation is far cheaper
16
+ // than the degradation from an agent that forgot its own history. Tune via
17
+ // config resumeBudgetChars (lower for cost-sensitive fleets).
18
+ const DEFAULT_RESUME_BUDGET_CHARS = 150_000;
13
19
 
14
20
  export class Journalist {
15
21
  constructor(daemon) {
@@ -1061,7 +1067,51 @@ export class Journalist {
1061
1067
  *
1062
1068
  * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1063
1069
  */
1064
- extractConversationThread(agent, { maxChars = 60000 } = {}) {
1070
+ extractConversationThread(agent, { maxChars } = {}) {
1071
+ // Budget is configurable (resumeBudgetChars): this is the "how much memory
1072
+ // survives a rotation" dial. Bigger costs more tokens per rotation but
1073
+ // preserves more of the session; a rotation is lossy exactly here.
1074
+ if (!maxChars) maxChars = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1075
+ const merged = this._parseConversationTurns(agent);
1076
+ if (!merged || merged.length === 0) return null;
1077
+
1078
+ // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1079
+ let thread = '';
1080
+ const formatted = merged.map((t) => {
1081
+ const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1082
+ return `[${label}]:\n${t.text}`;
1083
+ });
1084
+
1085
+ // Start from the end (most recent) and work backwards to fill budget
1086
+ const recentFirst = [...formatted].reverse();
1087
+ const kept = [];
1088
+ let totalLen = 0;
1089
+
1090
+ for (const entry of recentFirst) {
1091
+ if (totalLen + entry.length > maxChars) {
1092
+ // Truncate this entry to fit remaining budget
1093
+ const remaining = maxChars - totalLen;
1094
+ if (remaining > 200) {
1095
+ kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1096
+ }
1097
+ break;
1098
+ }
1099
+ kept.push(entry);
1100
+ totalLen += entry.length;
1101
+ }
1102
+
1103
+ // Reverse back to chronological order
1104
+ kept.reverse();
1105
+ thread = kept.join('\n\n---\n\n');
1106
+
1107
+ return thread;
1108
+ }
1109
+
1110
+ /**
1111
+ * Parse the full user↔assistant turn list from an agent's stream-json log.
1112
+ * Returns merged turns in chronological order, or null if no log/turns.
1113
+ */
1114
+ _parseConversationTurns(agent) {
1065
1115
  const logPath = agentLogPath(this.daemon.grooveDir, agent);
1066
1116
  if (!existsSync(logPath)) return null;
1067
1117
 
@@ -1121,37 +1171,23 @@ export class Journalist {
1121
1171
  merged.push({ ...turn });
1122
1172
  }
1123
1173
  }
1174
+ return merged;
1175
+ }
1124
1176
 
1125
- // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1126
- let thread = '';
1127
- const formatted = merged.map((t) => {
1128
- const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1129
- return `[${label}]:\n${t.text}`;
1130
- });
1131
-
1132
- // Start from the end (most recent) and work backwards to fill budget
1133
- const recentFirst = [...formatted].reverse();
1134
- const kept = [];
1135
- let totalLen = 0;
1136
-
1137
- for (const entry of recentFirst) {
1138
- if (totalLen + entry.length > maxChars) {
1139
- // Truncate this entry to fit remaining budget
1140
- const remaining = maxChars - totalLen;
1141
- if (remaining > 200) {
1142
- kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1143
- }
1144
- break;
1145
- }
1146
- kept.push(entry);
1147
- totalLen += entry.length;
1148
- }
1149
-
1150
- // Reverse back to chronological order
1151
- kept.reverse();
1152
- thread = kept.join('\n\n---\n\n');
1153
-
1154
- return thread;
1177
+ /**
1178
+ * The agent's ORIGINAL task, from the first substantial user message in the
1179
+ * FULL log — never from the truncated resume window. When weeks of dialogue
1180
+ * exceed the resume budget, the window holds only recent work; anchoring the
1181
+ * task there makes the agent's identity drift onto whatever happened last.
1182
+ * The name-keyed log spans all rotations, so the true origin is always here.
1183
+ */
1184
+ extractOriginalTask(agent) {
1185
+ const merged = this._parseConversationTurns(agent);
1186
+ if (!merged) return '';
1187
+ const first = merged.find((t) => t.role === 'user' && t.text.trim().length >= 10);
1188
+ if (!first) return '';
1189
+ const text = first.text.trim();
1190
+ return text.length > 500 ? text.slice(0, 500) + '...' : text;
1155
1191
  }
1156
1192
 
1157
1193
  /**
@@ -1159,15 +1195,17 @@ export class Journalist {
1159
1195
  * thread so a fresh agent picks up where the previous session left off.
1160
1196
  */
1161
1197
  buildConversationResumePrompt(agent, userMessage, { isRotation = false, reason } = {}) {
1162
- const thread = this.extractConversationThread(agent);
1198
+ const budget = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1199
+ const thread = this.extractConversationThread(agent, { maxChars: budget });
1163
1200
  if (!thread) return null;
1164
1201
 
1165
1202
  const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
1166
1203
  const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000, agent.scope, agent.teamId) || '';
1167
1204
 
1168
- // Extract the user's original task from the conversation — the first substantial
1169
- // user message is almost always the task assignment. This anchors the new agent.
1170
- const originalTask = this._extractOriginalTask(thread);
1205
+ // Anchor the agent to its ORIGINAL task from the full log — the truncated
1206
+ // thread's first message may be mid-project (or another agent's task) once
1207
+ // history outgrows the budget. Fall back to the window only if the log parse fails.
1208
+ const originalTask = this.extractOriginalTask(agent) || this._extractOriginalTask(thread);
1171
1209
 
1172
1210
  // Rotation and idle-resume need very different framing. During rotation the agent
1173
1211
  // has no new user message — it must continue the exact in-progress task without
@@ -1181,6 +1219,7 @@ export class Journalist {
1181
1219
  originalTask ? `Your task: ${originalTask}` : '',
1182
1220
  ``,
1183
1221
  `Rules:`,
1222
+ `- NEVER mention the rotation, context refresh, restart, or any session discontinuity to the user — from the user's perspective you are one continuous agent and this refresh must be invisible`,
1184
1223
  `- Continue ONLY the task described in the conversation below`,
1185
1224
  `- Do NOT explore the codebase looking for other things to fix or improve`,
1186
1225
  `- Do NOT start new work that was not part of the original task`,
@@ -1210,14 +1249,16 @@ export class Journalist {
1210
1249
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1211
1250
  ``,
1212
1251
  isRotation
1213
- ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything. If the task was already completed, state that and wait for new instructions.`
1214
- : `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.`,
1252
+ ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything, and never reference this context refresh — continue as if the conversation above simply never stopped. If the task was already completed, state that and wait for new instructions.`
1253
+ : `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, and never mention the interruption or that you are resuming — to the user this is one continuous conversation.`,
1215
1254
  ].filter(Boolean).join('\n');
1216
1255
 
1217
- // Hard cap at 80K chars (~20K tokens) to leave plenty of room in context window
1218
- if (prompt.length > 80000) {
1256
+ // Cap scales with the configured budget (framing adds constraints/discoveries
1257
+ // on top of the thread) to leave room in the context window
1258
+ const promptCap = budget + 20_000;
1259
+ if (prompt.length > promptCap) {
1219
1260
  // Re-extract with smaller budget and rebuild
1220
- const smallerThread = this.extractConversationThread(agent, { maxChars: 40000 });
1261
+ const smallerThread = this.extractConversationThread(agent, { maxChars: Math.floor(budget * 2 / 3) });
1221
1262
  if (smallerThread) {
1222
1263
  prompt = [
1223
1264
  `# Session Context Resume`,
@@ -1238,8 +1279,8 @@ export class Journalist {
1238
1279
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1239
1280
  ``,
1240
1281
  isRotation
1241
- ? `Continue the in-progress task only. Do not explore or start new work. If done, state that and wait.`
1242
- : `Continue seamlessly. Do not ask the user to repeat anything.`,
1282
+ ? `Continue the in-progress task only. Do not explore or start new work. Never mention this context refresh to the user. If done, state that and wait.`
1283
+ : `Continue seamlessly. Do not ask the user to repeat anything, and never mention the interruption — to the user this is one continuous conversation.`,
1243
1284
  ].filter(Boolean).join('\n');
1244
1285
  }
1245
1286
  }
@@ -10,7 +10,7 @@ import { LocalProvider } from './providers/local.js';
10
10
  import { OllamaProvider } from './providers/ollama.js';
11
11
  import { AgentLoop } from './agent-loop.js';
12
12
  import { validateAgentConfig } from './validate.js';
13
- import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
13
+ import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
@@ -1174,6 +1174,8 @@ For normal file edits within your scope, proceed without review.
1174
1174
  ...innerChatInstructions(port, agent.name),
1175
1175
  '',
1176
1176
  ...watchInstructions(port, agent.name),
1177
+ '',
1178
+ ...logFileInstructions(),
1177
1179
  ].join('\n') + '\n\n';
1178
1180
  if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
1179
1181
  spawnConfig.prompt += '\n\n' + capabilities.trim();
@@ -18,7 +18,12 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
18
18
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
19
19
  const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
20
20
  const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
21
- const REPLAY_CEILING = 0.50; // Self-managing providers: rotate idle agents above this context usage
21
+ // Self-managing providers: rotate idle agents above this context usage.
22
+ // 0.80 favors context retention over cache-read cost — the conversation-resume
23
+ // window is a slice of the full session, so every rotation is lossy; for
24
+ // complex long-lived work that loss compounds faster than token savings pay off.
25
+ // Lower via config (replayCeiling) for cost-sensitive fleets; 0 disables.
26
+ const REPLAY_CEILING = 0.80;
22
27
  const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
23
28
  const VELOCITY_WINDOW_MS = 5 * 60_000;
24
29
  const ROLE_MULTIPLIERS = {