lazyclaw 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.mjs CHANGED
@@ -80,13 +80,15 @@ function defaultShape(name) {
80
80
  // for `lazyclaw agent reflect`; 'off' disables writes entirely.
81
81
  memoryWrite: 'auto',
82
82
  memoryMaxChars: 12 * 1024,
83
- // Phase 20 — self-improving skill synthesis trigger. 'manual'
84
- // (default) means a skill is only written when the user runs
85
- // `lazyclaw agent skill-synth`; 'auto' fires synthesis on terminal
86
- // `done` alongside reflection; 'off' disables it. Defaults to
87
- // 'manual' (unlike memoryWrite) because a synthesised SKILL.md
88
- // feeds every future agent's prompt, so we keep it opt-in.
89
- skillWrite: 'manual',
83
+ // v5 Group A (M3) — self-improving skill synthesis trigger.
84
+ // Default flipped to 'auto' so the learning loop actually closes
85
+ // end-to-end on a fresh install: every agent that finishes a task
86
+ // contributes a SKILL.md unless the operator explicitly opted out
87
+ // ('manual' waits for `lazyclaw agent skill-synth`; 'off' disables).
88
+ // The canonical post-task hook (mas/learning.mjs) also reads
89
+ // `(skillWrite ?? 'auto')`, so v4 records that pre-date this field
90
+ // get the new default without a forced migration.
91
+ skillWrite: 'auto',
90
92
  createdAt: new Date().toISOString(),
91
93
  updatedAt: new Date().toISOString(),
92
94
  };
@@ -114,7 +116,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
114
116
  if (!VALID_MEMORY_WRITE.includes(mw)) {
115
117
  throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
116
118
  }
117
- const sw = skillWrite ?? 'manual';
119
+ const sw = skillWrite ?? 'auto';
118
120
  if (!VALID_SKILL_WRITE.includes(sw)) {
119
121
  throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
120
122
  }
@@ -0,0 +1,39 @@
1
+ // Group B / M6 — chat sliding window helper.
2
+ //
3
+ // A multi-day chat session accumulates hundreds of turns; without a
4
+ // cap the prompt grows linearly and the Anthropic prompt cache
5
+ // breakpoint advances past the useful prefix. The window keeps the
6
+ // last N turns (default 20) AND honours a token budget (default ~8K
7
+ // chars-as-tokens, since `inputTokens` from the model isn't available
8
+ // pre-call). The system message at index 0 is always preserved — it's
9
+ // the agent.role + workspace + skills index that the static cache
10
+ // prefix depends on.
11
+ //
12
+ // Env overrides let operators stretch the window for long-running
13
+ // research sessions without recompiling. Lives in its own file so it
14
+ // can be imported by tests without invoking cli.mjs::main().
15
+
16
+ export const CHAT_WINDOW_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
17
+ export const CHAT_WINDOW_TOKEN_BUDGET = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
18
+
19
+ // Trim a hydrated messages[] array to fit the sliding window. Returns
20
+ // { messages, dropped } so the caller can log a one-shot "dropped N
21
+ // older turns" line at session start. The first message is preserved
22
+ // when its role is 'system' (cacheable static prefix). Token budget
23
+ // is approximated as 4 chars/token — accurate enough for a soft cap
24
+ // when the goal is "don't pay for messages the model won't use".
25
+ export function applyChatWindow(messages, { turns = CHAT_WINDOW_TURNS, tokens = CHAT_WINDOW_TOKEN_BUDGET } = {}) {
26
+ if (!Array.isArray(messages) || messages.length === 0) return { messages, dropped: 0 };
27
+ const out = [...messages];
28
+ const sys = out[0]?.role === 'system' ? out.shift() : null;
29
+ const before = out.length;
30
+ // Turn count cap: drop oldest non-system turns until length ≤ turns.
31
+ while (out.length > turns) out.shift();
32
+ // Token budget cap: estimate 4 chars / token, then trim from the
33
+ // front (oldest) until estimated tokens ≤ budget.
34
+ const estTokens = (arr) => Math.ceil(arr.reduce((n, m) => n + String(m.content || '').length, 0) / 4);
35
+ while (out.length > 1 && estTokens(out) > tokens) out.shift();
36
+ const dropped = before - out.length;
37
+ if (sys) out.unshift(sys);
38
+ return { messages: out, dropped };
39
+ }
package/cli.mjs CHANGED
@@ -8,6 +8,12 @@ import { pathToFileURL } from 'node:url';
8
8
  import { resolveSandbox, listBackends } from './sandbox/index.mjs';
9
9
  // Phase G: defaultConfigDir for personality subcommand (spec §9, decision C7).
10
10
  import { defaultConfigDir as _persDefaultCfg } from './memory.mjs';
11
+ // Group B / M6 — chat sliding window. Lives in its own module so
12
+ // tests can import the helper without invoking cli.mjs::main().
13
+ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from './chat_window.mjs';
14
+ // v5 Group C (C7) — shared chat-turn streaming closure. Single source
15
+ // of truth for both the ink REPL path and the legacy readline path.
16
+ import { makeRunTurn as _chatRunTurnFactory } from './tui/run_turn.mjs';
11
17
 
12
18
  async function loadEngine() {
13
19
  return import('./workflow/persistent.mjs');
@@ -858,6 +864,58 @@ async function cmdDoctor() {
858
864
  if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
859
865
  issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')}`);
860
866
  }
867
+ // C12 — MinGit / Windows safety net. mas/tools/git.mjs shells out to
868
+ // `git`; on a stripped Windows PATH (no Git-for-Windows installed) or
869
+ // a minimal Docker base image, that spawnSync ENOENTs and any agent
870
+ // task touching the git tool fails opaquely. Probe up-front so
871
+ // `lazyclaw doctor` surfaces a clean diagnostic and the operator can
872
+ // install the binary before they trip over it.
873
+ let gitInfo = null;
874
+ try {
875
+ const { spawnSync } = await import('node:child_process');
876
+ const gitExe = process.env.GIT_EXECUTABLE || 'git';
877
+ const probe = spawnSync(gitExe, ['--version'], { encoding: 'utf8' });
878
+ if (probe.error && probe.error.code === 'ENOENT') {
879
+ issues.push('git binary not found on PATH — `mas/tools/git.mjs` will fail. Install Git: macOS `xcode-select --install`; Linux `apt install git` / `yum install git`; Windows Git-for-Windows (https://git-scm.com/download/win). Or set the GIT_EXECUTABLE env var to an explicit path.');
880
+ gitInfo = { ok: false, code: 'ENOENT' };
881
+ } else if (probe.status !== 0) {
882
+ issues.push(`git --version exited ${probe.status} (${(probe.stderr || '').trim().slice(0, 200)})`);
883
+ gitInfo = { ok: false, status: probe.status, stderr: (probe.stderr || '').trim().slice(0, 200) };
884
+ } else {
885
+ gitInfo = { ok: true, version: (probe.stdout || '').trim() };
886
+ }
887
+ } catch (e) {
888
+ gitInfo = { ok: false, error: e?.message || String(e) };
889
+ }
890
+ // m11 — stale index probe. mas/index_db.mjs write-through hooks
891
+ // log failures to <configDir>/index-failures.jsonl; surface a recent
892
+ // count so the operator notices a silently-degraded index. Best-effort:
893
+ // missing file → 0 failures (the common case).
894
+ let indexInfo = null;
895
+ try {
896
+ const cfgDir = path.dirname(configPath());
897
+ const auditFile = path.join(cfgDir, 'index-failures.jsonl');
898
+ if (fs.existsSync(auditFile)) {
899
+ const raw = fs.readFileSync(auditFile, 'utf8');
900
+ const lines = raw.split('\n').filter(Boolean);
901
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
902
+ let recent = 0;
903
+ for (const line of lines) {
904
+ try {
905
+ const entry = JSON.parse(line);
906
+ if (entry?.ts && new Date(entry.ts).getTime() >= cutoff) recent++;
907
+ } catch { /* skip malformed */ }
908
+ }
909
+ indexInfo = { failuresLast24h: recent, totalFailures: lines.length };
910
+ if (recent > 0) {
911
+ issues.push(`${recent} index write failure(s) in the last 24h — run \`lazyclaw index rebuild\` to recover.`);
912
+ }
913
+ } else {
914
+ indexInfo = { failuresLast24h: 0, totalFailures: 0 };
915
+ }
916
+ } catch (e) {
917
+ indexInfo = { error: e?.message || String(e) };
918
+ }
861
919
  // Workflow state health — informational counters that show whether
862
920
  // the user has any failed or stuck workflow runs to attend to. We
863
921
  // don't push these to `issues` (a stuck workflow doesn't break the
@@ -901,6 +959,8 @@ async function cmdDoctor() {
901
959
  issues,
902
960
  knownProviders: Object.keys(_registryMod.PROVIDERS),
903
961
  workflows,
962
+ git: gitInfo,
963
+ index: indexInfo,
904
964
  timestamp: new Date().toISOString(),
905
965
  };
906
966
  console.log(JSON.stringify(out, null, 2));
@@ -1694,6 +1754,7 @@ function _printChatBanner(activeProvName, activeModel, version) {
1694
1754
  process.stdout.write(lines.join('\n') + '\n');
1695
1755
  }
1696
1756
 
1757
+
1697
1758
  // Interactive provider/model picker. Used on first run (no config) or
1698
1759
  // when the user passes --pick. Falls back to plain stdin reads when
1699
1760
  // stdout isn't a TTY (CI/script callers should pass --non-interactive
@@ -2469,9 +2530,113 @@ async function cmdChat(flags = {}) {
2469
2530
  skills: skillGroups,
2470
2531
  };
2471
2532
  void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
2533
+
2534
+ // C7 — minimal chat-session state for the ink path so runTurn can
2535
+ // talk to the provider (the legacy readline path below sets up the
2536
+ // same shape — kept duplicated here intentionally so the ink branch
2537
+ // remains self-contained and the legacy path stays byte-identical).
2538
+ // Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
2539
+ // until then, system-prompt composition / --session resume happen
2540
+ // identically to the legacy path.
2541
+ let _inkSandboxSpec = null;
2542
+ if (flags.sandbox) {
2543
+ const sb = await import('./sandbox.mjs');
2544
+ try { _inkSandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
2545
+ catch (err) { console.error(`error: ${err.message}`); process.exit(2); }
2546
+ }
2547
+ let _inkSessionId = flags.session || null;
2548
+ const _inkCfgDir = path.dirname(configPath());
2549
+ let _inkMessages = _inkSessionId
2550
+ ? sessionsMod.loadTurns(_inkSessionId, _inkCfgDir).map((t) => ({ role: t.role, content: t.content }))
2551
+ : [];
2552
+ if (_inkMessages.length > 0) {
2553
+ const cfgChat = cfg.chat || {};
2554
+ const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
2555
+ const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
2556
+ const { messages: trimmed } = _applyChatWindow(_inkMessages, { turns: winTurns, tokens: winTokens });
2557
+ _inkMessages = trimmed;
2558
+ }
2559
+ // System prompt composition — mirrors the legacy path's sysParts logic.
2560
+ const _inkSkillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
2561
+ .split(',').map((s) => s.trim()).filter(Boolean);
2562
+ const _inkWorkspaceName = flags.workspace || cfg.workspace || '';
2563
+ const _inkSysParts = [];
2564
+ try {
2565
+ const { composePromptStack } = await import('./mas/prompt_stack.mjs');
2566
+ const stacked = composePromptStack({
2567
+ cfgDir: _inkCfgDir,
2568
+ agent: { name: 'chat', role: '' },
2569
+ workspace: _inkWorkspaceName,
2570
+ });
2571
+ if (stacked && stacked.trim()) _inkSysParts.push(stacked);
2572
+ } catch { /* never block chat start on stack composition */ }
2573
+ if (_inkWorkspaceName && !_inkMessages.some((m) => m.role === 'system')) {
2574
+ try {
2575
+ const ws = await import('./workspace.mjs');
2576
+ const wsPrompt = ws.composeWorkspacePrompt(_inkCfgDir, _inkWorkspaceName);
2577
+ if (wsPrompt) _inkSysParts.push(wsPrompt);
2578
+ } catch (err) { console.error(`workspace error: ${err.message}`); process.exit(2); }
2579
+ }
2580
+ if (_inkSkillNames.length > 0 && !_inkMessages.some((m) => m.role === 'system')) {
2581
+ try {
2582
+ const sys = skillsMod.composeSystemPrompt(_inkSkillNames, _inkCfgDir);
2583
+ if (sys) _inkSysParts.push(sys);
2584
+ } catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
2585
+ }
2586
+ if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
2587
+ const merged = _inkSysParts.join('\n\n---\n\n');
2588
+ _inkMessages.unshift({ role: 'system', content: merged });
2589
+ if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
2590
+ }
2591
+ let _inkRunningUsage = null;
2592
+ const _inkAccumulateUsage = (u) => {
2593
+ if (!u) return;
2594
+ if (!_inkRunningUsage) _inkRunningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
2595
+ _inkRunningUsage.inputTokens += Number(u.inputTokens) || 0;
2596
+ _inkRunningUsage.outputTokens += Number(u.outputTokens) || 0;
2597
+ _inkRunningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
2598
+ _inkRunningUsage.turnsWithUsage += 1;
2599
+ };
2600
+ const _inkChatStartedAt = Date.now();
2601
+ const _inkSyntheticChatSessionId = `chat-${process.pid}-${_inkChatStartedAt}`;
2602
+ const _inkPersistTurn = (role, content) => {
2603
+ if (_inkSessionId) {
2604
+ sessionsMod.appendTurn(_inkSessionId, role, content, _inkCfgDir);
2605
+ return;
2606
+ }
2607
+ try {
2608
+ import('./memory.mjs').then((m) => {
2609
+ try { m.appendRecent(_inkSyntheticChatSessionId, role, content, _inkCfgDir); }
2610
+ catch { /* swallow */ }
2611
+ }).catch(() => {});
2612
+ } catch { /* swallow */ }
2613
+ };
2614
+ const _inkCtx = {
2615
+ cfg,
2616
+ cfgDir: _inkCfgDir,
2617
+ sandboxSpec: _inkSandboxSpec,
2618
+ syntheticChatSessionId: _inkSyntheticChatSessionId,
2619
+ getMessages: () => _inkMessages,
2620
+ getProv: () => prov,
2621
+ getActiveProvName: () => activeProvName,
2622
+ getActiveModel: () => activeModel,
2623
+ getSessionId: () => _inkSessionId,
2624
+ persistTurn: _inkPersistTurn,
2625
+ accumulateUsage: _inkAccumulateUsage,
2626
+ resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
2627
+ };
2628
+ // v5.0.10: write streamed chunks straight to process.stdout. Ink
2629
+ // owns the screen, so interleaved stdout writes can produce some
2630
+ // visual jank — accepted trade for unblocking the chat loop. v5.1
2631
+ // TODO: route through a ref'd scrollback <Static/> region in
2632
+ // ReplApp so Ink owns all output.
2633
+ const _inkRunTurn = _chatRunTurnFactory({
2634
+ ctx: _inkCtx,
2635
+ writeFn: (chunk) => process.stdout.write(chunk),
2636
+ });
2472
2637
  const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2473
2638
  splashProps,
2474
- runTurn: async (_text, _signal) => { void _text; void _signal; },
2639
+ runTurn: _inkRunTurn,
2475
2640
  }));
2476
2641
  await ink.waitUntilExit();
2477
2642
  return;
@@ -2529,6 +2694,24 @@ async function cmdChat(flags = {}) {
2529
2694
  ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
2530
2695
  : [];
2531
2696
 
2697
+ // M6 — apply sliding window at session start. Long-running sessions
2698
+ // (50+ turns) used to ship every prior turn to the provider every
2699
+ // request; we now keep at most CHAT_WINDOW_TURNS turns (default 20)
2700
+ // plus the system message. Operators can override via env. The
2701
+ // per-session log on disk is untouched — only the in-memory prompt
2702
+ // window is trimmed. We log to stderr once at session start so the
2703
+ // user knows context was dropped.
2704
+ if (messages.length > 0) {
2705
+ const cfgChat = cfg.chat || {};
2706
+ const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
2707
+ const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
2708
+ const { messages: trimmed, dropped } = _applyChatWindow(messages, { turns: winTurns, tokens: winTokens });
2709
+ if (dropped > 0) {
2710
+ process.stderr.write(`[chat] sliding window: dropped ${dropped} older turn(s), ${trimmed.length} kept\n`);
2711
+ }
2712
+ messages = trimmed;
2713
+ }
2714
+
2532
2715
  // --skill (comma-separated names) composes into a system message at the
2533
2716
  // head of the conversation. Same shape as `agent --skill`. Defaults from
2534
2717
  // config.skills array when --skill not passed. We only inject if no
@@ -2542,6 +2725,21 @@ async function cmdChat(flags = {}) {
2542
2725
  // a faithful preview.
2543
2726
  const workspaceName = flags.workspace || cfg.workspace || '';
2544
2727
  const sysParts = [];
2728
+ // v5 (canonical decision C5) — prepend the 8-layer composePromptStack
2729
+ // output. Falls back silently to no-op when the configDir has none of
2730
+ // the source files present (fresh install) so chat-start stays
2731
+ // byte-identical to the v4 shape until a user authors USER.md or a
2732
+ // personality. Wrapped in try/catch — chat start must never break on
2733
+ // a stack composition error.
2734
+ try {
2735
+ const { composePromptStack } = await import('./mas/prompt_stack.mjs');
2736
+ const stacked = composePromptStack({
2737
+ cfgDir,
2738
+ agent: { name: 'chat', role: '' },
2739
+ workspace: workspaceName,
2740
+ });
2741
+ if (stacked && stacked.trim()) sysParts.push(stacked);
2742
+ } catch { /* never block chat start on stack composition */ }
2545
2743
  if (workspaceName && !messages.some(m => m.role === 'system')) {
2546
2744
  try {
2547
2745
  const ws = await import('./workspace.mjs');
@@ -2582,11 +2780,56 @@ async function cmdChat(flags = {}) {
2582
2780
  runningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
2583
2781
  runningUsage.turnsWithUsage += 1;
2584
2782
  };
2783
+ // v5 Group A (M2): always-on synthetic session id so an unsessioned
2784
+ // chat still populates memory/recent.jsonl. Without this, the nudge
2785
+ // loop never saw repeated prompts in chat sessions that didn't pass
2786
+ // --session, and `nudge.suggest_skill` clusters silently lost ~95%
2787
+ // of their evidence. The `chat-<pid>-<startTs>` prefix keeps the
2788
+ // synthetic id distinguishable from real session ids on disk.
2789
+ const chatStartedAt = Date.now();
2790
+ const _syntheticChatSessionId = `chat-${process.pid}-${chatStartedAt}`;
2585
2791
  const persistTurn = (role, content) => {
2586
- if (!sessionId) return;
2587
- sessionsMod.appendTurn(sessionId, role, content, cfgDir);
2792
+ if (sessionId) {
2793
+ sessionsMod.appendTurn(sessionId, role, content, cfgDir);
2794
+ return;
2795
+ }
2796
+ // No --session: don't touch sessions/<id>.jsonl, but DO append to
2797
+ // memory/recent.jsonl directly so the nudge loop can cluster on
2798
+ // unsessioned chats. Best-effort — a broken memory module must not
2799
+ // break a chat turn.
2800
+ try {
2801
+ import('./memory.mjs').then((m) => {
2802
+ try { m.appendRecent(_syntheticChatSessionId, role, content, cfgDir); }
2803
+ catch { /* swallow */ }
2804
+ }).catch(() => {});
2805
+ } catch { /* swallow */ }
2588
2806
  };
2589
2807
 
2808
+ // C7 — shared runTurn closure for the legacy path. The same factory
2809
+ // backs the ink path above; both call sites get one set of bugs.
2810
+ // Getters close over the *current* binding of sessionId, prov,
2811
+ // activeProvName, activeModel — so a mid-session /provider switch
2812
+ // takes effect on the very next turn.
2813
+ const _legacyCtx = {
2814
+ cfg,
2815
+ cfgDir,
2816
+ sandboxSpec,
2817
+ syntheticChatSessionId: _syntheticChatSessionId,
2818
+ getMessages: () => messages,
2819
+ getProv: () => prov,
2820
+ getActiveProvName: () => activeProvName,
2821
+ getActiveModel: () => activeModel,
2822
+ getSessionId: () => sessionId,
2823
+ persistTurn,
2824
+ accumulateUsage,
2825
+ resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
2826
+ onCharsSent: (n) => { charsSent += n; },
2827
+ };
2828
+ const runTurn = _chatRunTurnFactory({
2829
+ ctx: _legacyCtx,
2830
+ writeFn: (chunk) => process.stdout.write(chunk),
2831
+ });
2832
+
2590
2833
  const handleSlash = async (line) => {
2591
2834
  const cmd = line.split(/\s+/)[0];
2592
2835
  switch (cmd) {
@@ -3172,6 +3415,35 @@ async function cmdChat(flags = {}) {
3172
3415
  return true;
3173
3416
  }
3174
3417
  case '/exit': {
3418
+ // v5 Group A (C4): fire one updateUserModel call before exit so
3419
+ // the Honcho-style USER.md captures the durable facts surfaced
3420
+ // in this session. Wrapped in a 3-second timeout so a slow
3421
+ // trainer never makes /exit hang. Best-effort: failure logs are
3422
+ // suppressed so we don't disturb the clean shutdown.
3423
+ try {
3424
+ const turns = sessionId
3425
+ ? sessionsMod.loadTurns(sessionId, cfgDir)
3426
+ : messages.map((t) => ({ role: t.role, content: t.content }));
3427
+ if (turns && turns.length) {
3428
+ const trainer = (typeof _registryMod?.resolveTrainer === 'function')
3429
+ ? _registryMod.resolveTrainer(cfg)
3430
+ : { provider: activeProvName, model: activeModel };
3431
+ const userModelPromise = import('./mas/user_modeler.mjs').then((m) =>
3432
+ m.updateUserModel({
3433
+ sessionTurns: turns,
3434
+ provider: trainer.provider,
3435
+ model: trainer.model,
3436
+ apiKey: _resolveAuthKey(cfg, trainer.provider),
3437
+ baseUrl: _resolveBaseUrl(trainer.provider),
3438
+ configDir: cfgDir,
3439
+ }),
3440
+ ).catch(() => null);
3441
+ await Promise.race([
3442
+ userModelPromise,
3443
+ new Promise((resolve) => setTimeout(resolve, 3000)),
3444
+ ]);
3445
+ }
3446
+ } catch { /* /exit must never hang or throw */ }
3175
3447
  return 'EXIT';
3176
3448
  }
3177
3449
  default:
@@ -3189,10 +3461,6 @@ async function cmdChat(flags = {}) {
3189
3461
  if (useTerminal) rl.prompt();
3190
3462
  continue;
3191
3463
  }
3192
- messages.push({ role: 'user', content: text });
3193
- charsSent += text.length;
3194
- persistTurn('user', text);
3195
- let acc = '';
3196
3464
  // Per-turn AbortController. Ctrl+C during a stream aborts THIS turn
3197
3465
  // and returns to the prompt instead of killing the process. Outside
3198
3466
  // a stream, Ctrl+C still terminates (we restore the default handler
@@ -3210,55 +3478,12 @@ async function cmdChat(flags = {}) {
3210
3478
  // gaps between CJK characters (visible in user-reported screen
3211
3479
  // captures of Korean replies).
3212
3480
  if (useTerminal) _ghost.suspend();
3213
- // Buffered writer — coalesce single-character streaming chunks
3214
- // into ~30 ms windows. Two reasons:
3215
- // 1. Korean / Japanese / Chinese tokens often arrive as one
3216
- // character per chunk. Each individual `process.stdout.write`
3217
- // can race against terminal redraw on a wide-cell character,
3218
- // producing the same "visible space between every character"
3219
- // symptom the suspend above also addresses.
3220
- // 2. Far fewer syscalls. A 200-char Korean reply was ~200
3221
- // separate writes; this collapses to ~7-10.
3222
- let _writeBuf = '';
3223
- let _writeTimer = null;
3224
- const _flush = () => {
3225
- if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
3226
- _writeTimer = null;
3227
- };
3228
- const _writeChunk = (s) => {
3229
- _writeBuf += s;
3230
- if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
3231
- };
3232
3481
  try {
3233
- for await (const chunk of prov.sendMessage(messages, {
3234
- apiKey: _resolveAuthKey(cfg, activeProvName),
3235
- model: activeModel,
3236
- sandbox: sandboxSpec,
3237
- signal: turnAc.signal,
3238
- onUsage: accumulateUsage,
3239
- })) {
3240
- _writeChunk(chunk);
3241
- acc += chunk;
3242
- }
3243
- // Drain anything still buffered before the trailing newline so
3244
- // the prompt lands on its own line cleanly.
3245
- if (_writeTimer) clearTimeout(_writeTimer);
3246
- _flush();
3247
- process.stdout.write('\n');
3248
- messages.push({ role: 'assistant', content: acc });
3249
- persistTurn('assistant', acc);
3250
- } catch (err) {
3251
- // Drain pending buffer so partial reply stays on screen even
3252
- // when the stream errors mid-flight.
3253
- if (_writeTimer) clearTimeout(_writeTimer);
3254
- _flush();
3255
- // ABORT errors are user-initiated; partial assistant output is
3256
- // discarded (we don't append a half-reply to the message history
3257
- // because the next turn would treat it as a complete reply and
3258
- // give odd context to the model).
3259
- if (err?.code !== 'ABORT' && !turnAc.signal.aborted) {
3260
- process.stdout.write(`error: ${err?.message || String(err)}\n`);
3261
- }
3482
+ // C7 single source of truth for the streaming + persist +
3483
+ // post-task learning loop. The factory handles the user-msg push,
3484
+ // 30 ms buffered writer (CJK-safe), assistant-msg push,
3485
+ // persistTurn for both turns, and the post-task learning hook.
3486
+ await runTurn(text, turnAc.signal);
3262
3487
  } finally {
3263
3488
  process.off('SIGINT', onSigint);
3264
3489
  if (useTerminal) _ghost.resume();
package/daemon.mjs CHANGED
@@ -339,6 +339,22 @@ export function makeHandler(ctx) {
339
339
  // having to subscribe to the SSE bus.
340
340
  const SUGG_RING_MAX = 50;
341
341
  const nudgeSuggestionsRing = [];
342
+ // v5 Group A (M2): when the operator opts in via
343
+ // cfg.orchestra.learning.autoSynthOnNudge, every emitted cluster
344
+ // also triggers runLearning('nudge', { cluster }) so the canonical
345
+ // funnel can distill the repeated prompt into a SKILL.md. The SSE
346
+ // broadcast and ring buffer still fire unconditionally so the
347
+ // dashboard sees suggestions even when auto-synth is disabled
348
+ // (the conservative default — we never want a noisy chat to bloat
349
+ // the skills/ dir without the operator's blessing).
350
+ let _learningHub = null;
351
+ const _readAutoSynthOnNudge = () => {
352
+ try {
353
+ const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
354
+ const orch = cfg?.orchestra || cfg?.orchestrator || {};
355
+ return !!(orch.learning && orch.learning.autoSynthOnNudge);
356
+ } catch { return false; }
357
+ };
342
358
  const _nudgeLoop = nudge.startNudgeLoop({
343
359
  configDir: gwConfigDir,
344
360
  emit: (event) => {
@@ -350,6 +366,26 @@ export function makeHandler(ctx) {
350
366
  nudgeSuggestionsRing.length = SUGG_RING_MAX;
351
367
  }
352
368
  } catch { /* ignore */ }
369
+ if (_readAutoSynthOnNudge()) {
370
+ (async () => {
371
+ try {
372
+ if (!_learningHub) _learningHub = await import('./mas/learning.mjs');
373
+ const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
374
+ // Wrap the single cluster in the items[] shape runLearning('nudge')
375
+ // expects so the representative task is the cluster's sample
376
+ // prompt.
377
+ const itemTask = { id: `nudge-${event.ts || Date.now()}`, title: 'nudge cluster', turns: [{ agent: 'user', text: event.cluster?.sample || '' }] };
378
+ await _learningHub.runLearning('nudge', {
379
+ cluster: { items: [itemTask] },
380
+ configDir: gwConfigDir,
381
+ cfg,
382
+ });
383
+ } catch (err) {
384
+ try { logger?.warn?.('nudge_learning_failed', { err: err.message }); }
385
+ catch { /* ignore */ }
386
+ }
387
+ })();
388
+ }
353
389
  },
354
390
  logger,
355
391
  });
@@ -510,11 +546,13 @@ export function makeHandler(ctx) {
510
546
  return writeJson(res, 200, result);
511
547
  }
512
548
  case route === 'GET /health':
549
+ case route === 'GET /healthz':
513
550
  // Conventional liveness check — always 200 if the process
514
551
  // is alive enough to hit the route. No config inspection
515
552
  // (use /doctor for readiness), no provider probing — this
516
553
  // is the "is the daemon up?" probe that load balancers
517
- // and watchdog scripts expect at this path.
554
+ // and watchdog scripts expect at this path. m15: K8s
555
+ // readiness probes default to /healthz so we alias.
518
556
  return writeJson(res, 200, {
519
557
  ok: true,
520
558
  status: 'alive',
@@ -1550,7 +1588,10 @@ export function makeHandler(ctx) {
1550
1588
  // 404 when the file is missing so the caller can branch.
1551
1589
  // 400 when the name fails skillPath validation (path traversal,
1552
1590
  // dotfile, etc.) — same protections as the CLI.
1553
- const name = skillMatch[1];
1591
+ // m13 decodeURIComponent before validation (see PUT below).
1592
+ let name;
1593
+ try { name = decodeURIComponent(skillMatch[1]); }
1594
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1554
1595
  try {
1555
1596
  const cfgDir = ctx.sessionsDirGetter();
1556
1597
  const file = skillPath(name, cfgDir);
@@ -1570,7 +1611,13 @@ export function makeHandler(ctx) {
1570
1611
  // 201 on first write, 200 on overwrite (caller can branch on
1571
1612
  // the status if they care about idempotency vs newness).
1572
1613
  // 400 on invalid name (skillPath validation) or oversize body.
1573
- const name = skillMatch[1];
1614
+ // m13 decodeURIComponent the segment before validation so
1615
+ // a request like `PUT /skills/foo%2Fbar` is rejected as a path-
1616
+ // separator (slash) rather than letting the literal-percent
1617
+ // filename slip through.
1618
+ let name;
1619
+ try { name = decodeURIComponent(skillMatch[1]); }
1620
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1574
1621
  const cfgDir = ctx.sessionsDirGetter();
1575
1622
  let priorExists = false;
1576
1623
  try {
@@ -1598,7 +1645,10 @@ export function makeHandler(ctx) {
1598
1645
  // existed or not, mirroring DELETE /sessions/<id>. The body
1599
1646
  // reports `removed: true|false` so callers can branch when
1600
1647
  // they care.
1601
- const name = skillMatch[1];
1648
+ // m13 decodeURIComponent before validation (see PUT below).
1649
+ let name;
1650
+ try { name = decodeURIComponent(skillMatch[1]); }
1651
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1602
1652
  const cfgDir = ctx.sessionsDirGetter();
1603
1653
  try {
1604
1654
  const file = skillPath(name, cfgDir);
@@ -1612,11 +1662,22 @@ export function makeHandler(ctx) {
1612
1662
  case req.method === 'DELETE' && !!sessionMatch: {
1613
1663
  // DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
1614
1664
  // "didn't exist" so callers can use it as a reset without checking
1615
- // first.
1665
+ // first. m16: include `removed: <bool>` for shape parity with
1666
+ // sibling DELETEs (/skills, /workflows).
1616
1667
  const id = sessionMatch[1];
1617
1668
  try {
1618
- ctx.sessionsMod.clearSession(id, ctx.sessionsDirGetter());
1619
- return writeJson(res, 200, { ok: true, id });
1669
+ // Use the sessions module's path resolver to check existence
1670
+ // BEFORE clearSession (which is unconditional unlink-if-exists).
1671
+ const sessDir = ctx.sessionsDirGetter();
1672
+ let existedBefore = false;
1673
+ try {
1674
+ const sessPath = ctx.sessionsMod.sessionPath
1675
+ ? ctx.sessionsMod.sessionPath(id, sessDir)
1676
+ : null;
1677
+ if (sessPath) existedBefore = fs.existsSync(sessPath);
1678
+ } catch { /* sessionPath unavailable → leave as unknown */ }
1679
+ ctx.sessionsMod.clearSession(id, sessDir);
1680
+ return writeJson(res, 200, { ok: true, id, removed: existedBefore });
1620
1681
  } catch (err) {
1621
1682
  return writeJson(res, 400, { error: err?.message || String(err) });
1622
1683
  }
@@ -1949,14 +2010,29 @@ export function makeHandler(ctx) {
1949
2010
  }
1950
2011
  }
1951
2012
  case req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
2013
+ // M13 — 404 when the agent is not registered. The historical
2014
+ // behaviour silently returned an empty body, which made
2015
+ // typos indistinguishable from "no memory yet" and let the
2016
+ // dashboard render a stub for a non-existent agent.
1952
2017
  const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2018
+ const agentsMod = await import('./agents.mjs');
2019
+ if (!agentsMod.getAgent(name)) {
2020
+ return writeJson(res, 404, { error: `no agent "${name}"`, name });
2021
+ }
1953
2022
  const memMod = await import('./mas/agent_memory.mjs');
1954
2023
  const text = memMod.readMemory(name);
1955
2024
  res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
1956
2025
  return res.end(text);
1957
2026
  }
1958
2027
  case req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
2028
+ // M13 — 404 when the agent is not registered. Without this
2029
+ // check, writeRaw happily created memory.md for a misspelled
2030
+ // agent name and the orphan file lived forever.
1959
2031
  const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2032
+ const agentsMod = await import('./agents.mjs');
2033
+ if (!agentsMod.getAgent(name)) {
2034
+ return writeJson(res, 404, { error: `no agent "${name}"`, name });
2035
+ }
1960
2036
  const memMod = await import('./mas/agent_memory.mjs');
1961
2037
  // Read raw text body — content-type defaults to text/markdown
1962
2038
  // but JSON {"text": "..."} is also accepted for tooling that
@@ -1979,6 +2055,10 @@ export function makeHandler(ctx) {
1979
2055
  }
1980
2056
  case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
1981
2057
  const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2058
+ const agentsMod = await import('./agents.mjs');
2059
+ if (!agentsMod.getAgent(name)) {
2060
+ return writeJson(res, 404, { error: `no agent "${name}"`, name });
2061
+ }
1982
2062
  const memMod = await import('./mas/agent_memory.mjs');
1983
2063
  const removed = memMod.clear(name);
1984
2064
  return writeJson(res, 200, { name, cleared: removed });