@lore-co/cli 0.2.22 → 0.2.24

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 (73) hide show
  1. package/README.md +109 -13
  2. package/dist/approach-guidance.js +2 -2
  3. package/dist/approach-guidance.js.map +1 -1
  4. package/dist/approach.d.ts +1 -1
  5. package/dist/approach.d.ts.map +1 -1
  6. package/dist/approach.js +7 -0
  7. package/dist/approach.js.map +1 -1
  8. package/dist/ask.d.ts +2 -6
  9. package/dist/ask.d.ts.map +1 -1
  10. package/dist/ask.js +4 -21
  11. package/dist/ask.js.map +1 -1
  12. package/dist/chat.d.ts +15 -0
  13. package/dist/chat.d.ts.map +1 -0
  14. package/dist/chat.js +286 -0
  15. package/dist/chat.js.map +1 -0
  16. package/dist/cli.d.ts +80 -7
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +626 -142
  19. package/dist/cli.js.map +1 -1
  20. package/dist/command-help.d.ts +4 -1
  21. package/dist/command-help.d.ts.map +1 -1
  22. package/dist/command-help.js +9 -0
  23. package/dist/command-help.js.map +1 -1
  24. package/dist/devin-capture.d.ts +6 -0
  25. package/dist/devin-capture.d.ts.map +1 -1
  26. package/dist/devin-capture.js +34 -3
  27. package/dist/devin-capture.js.map +1 -1
  28. package/dist/devin-cli.d.ts +7 -2
  29. package/dist/devin-cli.d.ts.map +1 -1
  30. package/dist/devin-cli.js +81 -8
  31. package/dist/devin-cli.js.map +1 -1
  32. package/dist/generated-assets.d.ts +8 -6
  33. package/dist/generated-assets.d.ts.map +1 -1
  34. package/dist/generated-assets.js +8 -6
  35. package/dist/generated-assets.js.map +1 -1
  36. package/dist/generic-mcp.d.ts +35 -0
  37. package/dist/generic-mcp.d.ts.map +1 -0
  38. package/dist/generic-mcp.js +83 -0
  39. package/dist/generic-mcp.js.map +1 -0
  40. package/dist/learnings.d.ts +20 -0
  41. package/dist/learnings.d.ts.map +1 -1
  42. package/dist/learnings.js +25 -16
  43. package/dist/learnings.js.map +1 -1
  44. package/dist/lore-agent.d.ts +50 -0
  45. package/dist/lore-agent.d.ts.map +1 -0
  46. package/dist/lore-agent.js +299 -0
  47. package/dist/lore-agent.js.map +1 -0
  48. package/dist/mcp-config.d.ts +46 -0
  49. package/dist/mcp-config.d.ts.map +1 -0
  50. package/dist/mcp-config.js +196 -0
  51. package/dist/mcp-config.js.map +1 -0
  52. package/dist/owned-files.d.ts +21 -0
  53. package/dist/owned-files.d.ts.map +1 -0
  54. package/dist/owned-files.js +88 -0
  55. package/dist/owned-files.js.map +1 -0
  56. package/dist/recovery-cloud.d.ts +1 -1
  57. package/dist/review.d.ts +30 -0
  58. package/dist/review.d.ts.map +1 -0
  59. package/dist/review.js +276 -0
  60. package/dist/review.js.map +1 -0
  61. package/dist/runtime.d.ts.map +1 -1
  62. package/dist/runtime.js +50 -19
  63. package/dist/runtime.js.map +1 -1
  64. package/dist/self-host.d.ts +1 -1
  65. package/dist/self-host.js +2 -2
  66. package/dist/skills.d.ts +2 -0
  67. package/dist/skills.d.ts.map +1 -1
  68. package/dist/skills.js +46 -97
  69. package/dist/skills.js.map +1 -1
  70. package/dist/update.d.ts.map +1 -1
  71. package/dist/update.js +39 -4
  72. package/dist/update.js.map +1 -1
  73. package/package.json +7 -4
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { parseDirectoryRules, checkDirectory, checkLoreAccess } from "./directory-policy.js";
2
+ import { parseDirectoryRules, checkDirectory, checkLoreAccess, capturePauseMessage } from "./directory-policy.js";
3
3
  import { reportCommand, runTelemetryCommand, telemetryEnabled, trackedCommand, TELEMETRY_HELP } from "./telemetry.js";
4
4
  import { secureWindowsDirectory, windowsPermissionsArePrivate } from "./windows-permissions.js";
5
5
  import { COMMAND_GUIDES, ROOT_HELP, helpTopic } from "./command-help.js";
@@ -8,15 +8,16 @@ import { resolveConnectProject, validateConnectProjectOptions } from "./connect-
8
8
  import { captureDirectories } from "./capture-scope.js";
9
9
  import { prepareConnection, persistConnection, listConnections } from "./connections.js";
10
10
  import { connectionReferences, resolveLocalConnection } from "./directory-policy.js";
11
- import { withDirectoryLock } from "./reliability-store.js";
11
+ import { atomicWriteJson, withDirectoryLock } from "./reliability-store.js";
12
12
  import { runConfigCommand } from "./config-command.js";
13
13
  import { codexApprovalDetail, inspectCodexHookApproval } from "./codex-hook-approval.js";
14
- import { claudeOnlyCommand, disableDevinCli, runDevinCliHook } from "./devin-cli.js";
14
+ import { claudeOnlyCommand, disableDevinCli, refreshDevinHooks, runDevinCliHook } from "./devin-cli.js";
15
+ import { LORE_MCP_SERVER_NAME, MCP_HOST_AGENTS, inspectCodexMcp, isLoreMcpServer, isLoreOpenCodeMcp, loreMcpServerEntry, loreVsCodeMcpServer, mergeLoreCodexMcp, mergeLoreMcpServers, mergeLoreOpenCodeMcp, mergeLoreVsCodeMcp, openCodeMcpEntry, removeLoreCodexMcp, removeLoreMcpServers, removeLoreOpenCodeMcp, removeLoreVsCodeMcp, } from "./mcp-config.js";
15
16
  import { access, chmod, copyFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
16
17
  import { constants as fsConstants, realpathSync } from "node:fs";
17
18
  import { spawn, isWindows, windowsCommand, commandText, findExecutable, openBrowser } from "./platform.js";
18
19
  import { createHash, randomUUID } from "node:crypto";
19
- import { dirname, resolve } from "node:path";
20
+ import { basename, dirname, resolve } from "node:path";
20
21
  import { homedir, hostname, platform } from "node:os";
21
22
  import { setTimeout as delay } from "node:timers/promises";
22
23
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -24,10 +25,14 @@ import { CLAUDE_RELIABILITY_INTEGRATION, CODEX_RELIABILITY_INTEGRATION, COPILOT_
24
25
  import { runHook, } from "./runtime.js";
25
26
  import { readInvocationHealthWriteInput, runInvocationHealthWrite, } from "./invocation-health-writer.js";
26
27
  import { runAskCommand } from "./ask.js";
28
+ import { runChatCommand } from "./chat.js";
27
29
  import { runLearningsCommand } from "./learnings.js";
30
+ import { runReviewCommand } from "./review.js";
28
31
  import { runEmbeddingsCommand } from "./embeddings.js";
29
32
  import { runRememberCommand } from "./remember.js";
30
33
  import { installLoreSkill, removeLoreSkill } from "./skills.js";
34
+ import { GENERIC_AGENT, genericInstructions, genericMcpServers, parseGenericAgentId, readGenericAgents, recordGenericAgent, removeGenericAgents } from "./generic-mcp.js";
35
+ import { installLoreAgent, removeLoreAgent } from "./lore-agent.js";
31
36
  import { runApproachCommand } from "./approach.js";
32
37
  import { runSkillCommand } from "./skill-command.js";
33
38
  import { runGuardCommand } from "./guard.js";
@@ -58,6 +63,20 @@ const HOOK_EVENTS = [
58
63
  "Stop",
59
64
  "SessionEnd",
60
65
  ];
66
+ /**
67
+ * Hook events that exist only to record turns. Retrieval-only mode leaves them
68
+ * uninstalled, so the agent never hands a response to Lore at all.
69
+ */
70
+ const CAPTURE_HOOK_EVENTS = new Set([
71
+ "Stop", "SessionEnd", "afterAgentResponse", "sessionEnd", "agentStop", "post_model_turn",
72
+ ]);
73
+ function installedEvents(events, capture) {
74
+ return capture ? [...events] : events.filter((event) => !CAPTURE_HOOK_EVENTS.has(event));
75
+ }
76
+ /** Capture mode of a connection: absent means on. */
77
+ export function captureEnabled(config) {
78
+ return config?.capture !== "off";
79
+ }
61
80
  /** Tools the Context Guard PreToolUse hook cares about (regex matcher). */
62
81
  const GUARD_TOOL_MATCHER = "Edit|Write|MultiEdit|NotebookEdit|ApplyPatch|apply_patch|edit|write|patch|str_replace_editor|Bash|bash|Shell|shell|exec_command|shell_command|run_terminal_cmd";
63
82
  const CURSOR_HOOK_EVENTS = [
@@ -110,12 +129,16 @@ Options:
110
129
  --repository <repo> Verify the selected folder matches this GitHub repository
111
130
  --folder <path> Record only in this folder and subfolders (repeatable)
112
131
  --all-projects Record in all projects allowed by your workspace
132
+ --no-capture Retrieval-only mode: context, guard, and manual saves, no turn recording
133
+ --capture Turn automatic turn recording back on
113
134
  --url <url> Lore API base URL (default: https://api.uselore.co)
114
135
  --dashboard-url <url> Lore dashboard URL (default: https://uselore.co for hosted Lore)
115
136
  --login Approve access securely in the Lore dashboard
116
137
  --no-open Print the approval URL without opening a browser
117
138
  --token <token> Workspace bearer token (or LORE_WORKSPACE_TOKEN/LORE_TOKEN)
118
- --agent <name> claude, codex, copilot, copilot-cli, copilot-vscode, cowork, cursor, devin, opencode, polytoken, or t3code
139
+ --agent <name> claude, codex, copilot, copilot-cli, copilot-vscode, cowork, cursor, devin, generic, opencode, polytoken, or t3code
140
+ --agent-id <id> With --agent generic: credit MCP saves to this agent (default: generic)
141
+ --write With --agent generic: let the agent save through MCP (saves stay proposals)
119
142
  --timeout-ms <ms> Hook request timeout, 250-10000 (default: 2500)
120
143
  --fallback <agent> claude, codex, opencode, devin, cursor, copilot-cli, polytoken
121
144
  --no-recovery Disable automatic handoff setup (otherwise enabled with two installed clients)
@@ -123,11 +146,14 @@ Options:
123
146
  --help Show this command's help
124
147
 
125
148
  New connections stay paused until you choose a project, a folder, or --all-projects.
126
- Reconnecting without a capture option keeps your saved folder rules.
127
- Folder choices control capture; learning scope controls where knowledge applies.
149
+ Reconnecting without a capture option keeps your saved folder rules and capture mode.
150
+ Folder choices control where Lore runs; learning scope controls where knowledge applies.
151
+ With --no-capture, Lore installs no Stop or SessionEnd hooks and sends no turns.
152
+ Context injection, Guard, lore ask, and lore remember keep working. See lore capture.
128
153
 
129
154
  Examples:
130
155
  lore connect --login --agent claude --folder .
156
+ lore connect --login --agent claude --folder . --no-capture
131
157
  lore connect --login --agent cowork
132
158
  lore connect --login --agent devin --folder .
133
159
  lore connect --token "$LORE_WORKSPACE_TOKEN" --agent claude
@@ -154,6 +180,24 @@ Examples:
154
180
  lore doctor
155
181
  lore doctor --json
156
182
  `;
183
+ const CAPTURE_HELP = `lore capture
184
+ Choose whether this machine records agent turns automatically.
185
+
186
+ Usage:
187
+ lore capture status [--json]
188
+ lore capture off [--json]
189
+ lore capture on [--json]
190
+
191
+ With capture off, Lore installs no Stop or SessionEnd hooks and never sends a
192
+ prompt or response as a turn. Context injection, Guard, lore ask, lore remember,
193
+ and the lore skill's "remember this session" keep working. Folder rules still
194
+ decide where Lore runs at all.
195
+
196
+ Examples:
197
+ lore capture off
198
+ lore capture status
199
+ lore capture on
200
+ `;
157
201
  const DISCONNECT_HELP = `lore disconnect
158
202
  Remove Lore-owned hooks/plugin, credentials, runtime, and transient state.
159
203
  Durable captures, dead letters, verified snapshots, unrelated agent settings,
@@ -222,6 +266,7 @@ function isConnectAgent(value) {
222
266
  return (isConfiguredAgent(value) ||
223
267
  value === "copilot" ||
224
268
  isCredentialOnlyAgent(value) ||
269
+ value === "generic" ||
225
270
  value === "t3code");
226
271
  }
227
272
  function isCredentialOnlyAgent(value) {
@@ -291,24 +336,29 @@ function eventHandler(agent, event, paths) {
291
336
  : {}),
292
337
  };
293
338
  }
294
- export function mergeLoreHooks(input, agent, paths) {
339
+ export function mergeLoreHooks(input, agent, paths, capture = true) {
295
340
  const result = cloneObject(input);
296
341
  if (result.hooks !== undefined && !isObject(result.hooks)) {
297
342
  throw new Error("Agent configuration field \"hooks\" must be a JSON object");
298
343
  }
299
344
  const hooks = isObject(result.hooks) ? { ...result.hooks } : {};
345
+ const installed = installedEvents(HOOK_EVENTS, capture);
300
346
  for (const event of HOOK_EVENTS) {
301
347
  if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
302
348
  throw new Error(`Agent hook event "${event}" must be a JSON array`);
303
349
  }
304
- hooks[event] = [
350
+ const groups = [
305
351
  ...stripLoreFromEvent(hooks[event]),
306
- {
307
- // Guard checks run only for mutation-shaped tools.
308
- ...(event === "PreToolUse" ? { matcher: GUARD_TOOL_MATCHER } : {}),
309
- hooks: [eventHandler(agent, event, paths)],
310
- },
352
+ ...(installed.includes(event) ? [{
353
+ // Guard checks run only for mutation-shaped tools.
354
+ ...(event === "PreToolUse" ? { matcher: GUARD_TOOL_MATCHER } : {}),
355
+ hooks: [eventHandler(agent, event, paths)],
356
+ }] : []),
311
357
  ];
358
+ if (groups.length === 0)
359
+ delete hooks[event];
360
+ else
361
+ hooks[event] = groups;
312
362
  }
313
363
  result.hooks = hooks;
314
364
  return result;
@@ -398,10 +448,53 @@ export function removeLoreCursorMcp(input) {
398
448
  const { [CURSOR_MCP_NAME]: _lore, ...rest } = config.mcpServers;
399
449
  return { ...config, mcpServers: rest };
400
450
  }
451
+ function isMcpHostAgent(agent) {
452
+ return MCP_HOST_AGENTS.includes(agent);
453
+ }
454
+ /** The full Lore MCP server, run by this CLI so it shares the hooks' credentials and version. */
455
+ export function loreMcpServer(paths, agent) {
456
+ return loreMcpServerEntry(loreMcpInvocation(), paths.home, agent);
457
+ }
458
+ /** This CLI's executable plus its script path (none for the standalone binary). */
459
+ function loreMcpInvocation() {
460
+ return [process.execPath, ...(IS_STANDALONE_BINARY ? [] : [fileURLToPath(import.meta.url)])];
461
+ }
462
+ /** Where each agent lists MCP servers, located from the hooks file Lore already manages. */
463
+ export function mcpConfigPath(agent, hooksPath) {
464
+ const directory = dirname(hooksPath);
465
+ switch (agent) {
466
+ case "claude":
467
+ // ~/.claude/settings.json pairs with ~/.claude.json; a CLAUDE_CONFIG_DIR keeps both inside it.
468
+ return basename(directory) === ".claude"
469
+ ? resolve(directory, "..", ".claude.json")
470
+ : resolve(directory, ".claude.json");
471
+ case "codex":
472
+ return resolve(directory, "config.toml");
473
+ case "cursor":
474
+ return resolve(directory, "mcp.json");
475
+ case "copilot-cli":
476
+ // $COPILOT_HOME/hooks/lore.json pairs with $COPILOT_HOME/mcp-config.json.
477
+ return resolve(directory, "..", "mcp-config.json");
478
+ case "copilot-vscode":
479
+ // <repo>/.github/hooks/lore-vscode.json pairs with <repo>/.vscode/mcp.json.
480
+ return resolve(directory, "..", "..", ".vscode", "mcp.json");
481
+ }
482
+ }
483
+ export function mergeLoreMcp(input, agent, paths) {
484
+ const config = objectConfiguration(input, agent);
485
+ // VS Code keeps its servers under `servers`; the other JSON hosts use `mcpServers`.
486
+ return agent === "copilot-vscode"
487
+ ? mergeLoreVsCodeMcp(config, loreMcpServer(paths, agent))
488
+ : mergeLoreMcpServers(config, loreMcpServer(paths, agent), agent);
489
+ }
490
+ export function removeLoreMcp(input, agent) {
491
+ const config = objectConfiguration(input, agent);
492
+ return agent === "copilot-vscode" ? removeLoreVsCodeMcp(config) : removeLoreMcpServers(config);
493
+ }
401
494
  function stripLoreFromCursorEvent(value) {
402
495
  return Array.isArray(value) ? value.filter((hook) => !isLoreHook(hook)) : [];
403
496
  }
404
- export function mergeLoreCursorHooks(input, paths) {
497
+ export function mergeLoreCursorHooks(input, paths, capture = true) {
405
498
  const result = cloneObject(input);
406
499
  if (result.version !== undefined && result.version !== 1) {
407
500
  throw new Error('Cursor configuration field "version" must be 1');
@@ -410,14 +503,19 @@ export function mergeLoreCursorHooks(input, paths) {
410
503
  throw new Error('Cursor configuration field "hooks" must be a JSON object');
411
504
  }
412
505
  const hooks = isObject(result.hooks) ? { ...result.hooks } : {};
506
+ const installed = installedEvents(CURSOR_HOOK_EVENTS, capture);
413
507
  for (const event of CURSOR_HOOK_EVENTS) {
414
508
  if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
415
509
  throw new Error(`Cursor hook event "${event}" must be a JSON array`);
416
510
  }
417
- hooks[event] = [
511
+ const entries = [
418
512
  ...stripLoreFromCursorEvent(hooks[event]),
419
- cursorEventHandler(event, paths),
513
+ ...(installed.includes(event) ? [cursorEventHandler(event, paths)] : []),
420
514
  ];
515
+ if (entries.length === 0)
516
+ delete hooks[event];
517
+ else
518
+ hooks[event] = entries;
421
519
  }
422
520
  result.version = 1;
423
521
  result.hooks = hooks;
@@ -492,7 +590,7 @@ function stripLoreFromCopilotEvent(value) {
492
590
  ? value.filter((hook) => !isLoreCopilotHook(hook))
493
591
  : [];
494
592
  }
495
- export function mergeLoreCopilotCliHooks(input, paths) {
593
+ export function mergeLoreCopilotCliHooks(input, paths, capture = true) {
496
594
  const result = cloneObject(input);
497
595
  if (result.version !== undefined && result.version !== 1) {
498
596
  throw new Error('Copilot hook configuration field "version" must be 1');
@@ -501,14 +599,19 @@ export function mergeLoreCopilotCliHooks(input, paths) {
501
599
  throw new Error('Copilot hook configuration field "hooks" must be an object');
502
600
  }
503
601
  const hooks = isObject(result.hooks) ? { ...result.hooks } : {};
602
+ const installed = installedEvents(COPILOT_CLI_HOOK_EVENTS, capture);
504
603
  for (const event of COPILOT_CLI_HOOK_EVENTS) {
505
604
  if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
506
605
  throw new Error(`Copilot hook event "${event}" must be an array`);
507
606
  }
508
- hooks[event] = [
607
+ const entries = [
509
608
  ...stripLoreFromCopilotEvent(hooks[event]),
510
- copilotCliEventHandler(event, paths),
609
+ ...(installed.includes(event) ? [copilotCliEventHandler(event, paths)] : []),
511
610
  ];
611
+ if (entries.length === 0)
612
+ delete hooks[event];
613
+ else
614
+ hooks[event] = entries;
512
615
  }
513
616
  result.version = 1;
514
617
  result.hooks = hooks;
@@ -565,7 +668,7 @@ function copilotVscodeEventHandler(event, paths) {
565
668
  : 25,
566
669
  };
567
670
  }
568
- export function mergeLoreCopilotVscodeHooks(input, paths) {
671
+ export function mergeLoreCopilotVscodeHooks(input, paths, capture = true) {
569
672
  const result = cloneObject(input);
570
673
  if (result.version !== undefined && result.version !== 1) {
571
674
  throw new Error('VS Code Copilot hook field "version" must be 1');
@@ -574,14 +677,19 @@ export function mergeLoreCopilotVscodeHooks(input, paths) {
574
677
  throw new Error('VS Code Copilot hook field "hooks" must be an object');
575
678
  }
576
679
  const hooks = isObject(result.hooks) ? { ...result.hooks } : {};
680
+ const installed = installedEvents(HOOK_EVENTS, capture);
577
681
  for (const event of HOOK_EVENTS) {
578
682
  if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
579
683
  throw new Error(`VS Code Copilot hook event "${event}" must be an array`);
580
684
  }
581
- hooks[event] = [
685
+ const entries = [
582
686
  ...stripLoreFromCursorEvent(hooks[event]),
583
- copilotVscodeEventHandler(event, paths),
687
+ ...(installed.includes(event) ? [copilotVscodeEventHandler(event, paths)] : []),
584
688
  ];
689
+ if (entries.length === 0)
690
+ delete hooks[event];
691
+ else
692
+ hooks[event] = entries;
585
693
  }
586
694
  result.version = 1;
587
695
  result.hooks = hooks;
@@ -641,14 +749,14 @@ function isLorePolytokenHook(value) {
641
749
  (/(?:^|\s)--owner(?:=|\s+)lore(?:\s|$)/u.test(command) ||
642
750
  command.includes("/.lore/bin/lore-hook.mjs")));
643
751
  }
644
- export function mergeLorePolytokenHooks(input, paths) {
752
+ export function mergeLorePolytokenHooks(input, paths, capture = true) {
645
753
  if (!Array.isArray(input)) {
646
754
  throw new Error("Polytoken hooks must contain a JSON array");
647
755
  }
648
756
  const retained = input.filter((hook) => !isLorePolytokenHook(hook));
649
757
  return [
650
758
  ...structuredClone(retained),
651
- ...POLYTOKEN_HOOK_EVENTS.map((event) => polytokenEventHandler(event, paths)),
759
+ ...installedEvents(POLYTOKEN_HOOK_EVENTS, capture).map((event) => polytokenEventHandler(event, paths)),
652
760
  ];
653
761
  }
654
762
  export function removeLorePolytokenHooks(input) {
@@ -697,6 +805,9 @@ export function countLoreOpenCodePlugins(input) {
697
805
  ? input.plugin.filter(isLoreOpenCodePlugin).length
698
806
  : 0;
699
807
  }
808
+ function countLoreOpenCodeMcp(input) {
809
+ return isObject(input.mcp) && isLoreOpenCodeMcp(input.mcp[LORE_MCP_SERVER_NAME]) ? 1 : 0;
810
+ }
700
811
  function installationUnitId(event, index, total) {
701
812
  return total === 1 ? event : `installation-${index + 1}/${event}`;
702
813
  }
@@ -712,13 +823,13 @@ function installationUnitState(ownedCount, exactCount) {
712
823
  function exactConfiguration(left, right) {
713
824
  return canonicalJson(left) === canonicalJson(right);
714
825
  }
715
- function commandHookInstallationUnits(input, agent, paths, index, total) {
826
+ function commandHookInstallationUnits(input, agent, paths, index, total, capture) {
716
827
  const configuration = objectConfiguration(input, agent);
717
828
  if (configuration.hooks !== undefined && !isObject(configuration.hooks)) {
718
829
  throw new Error(`${agent} configuration field "hooks" must be an object`);
719
830
  }
720
831
  const hooks = isObject(configuration.hooks) ? configuration.hooks : {};
721
- return HOOK_EVENTS.map((event) => {
832
+ return installedEvents(HOOK_EVENTS, capture).map((event) => {
722
833
  const groups = hooks[event];
723
834
  if (groups !== undefined && !Array.isArray(groups)) {
724
835
  throw new Error(`${agent} hook event "${event}" must be an array`);
@@ -741,7 +852,7 @@ function commandHookInstallationUnits(input, agent, paths, index, total) {
741
852
  };
742
853
  });
743
854
  }
744
- function cursorInstallationUnits(input, paths, index, total) {
855
+ function cursorInstallationUnits(input, paths, index, total, capture) {
745
856
  const configuration = objectConfiguration(input, "cursor");
746
857
  if (configuration.version !== undefined && configuration.version !== 1) {
747
858
  throw new Error('Cursor configuration field "version" must be 1');
@@ -750,7 +861,7 @@ function cursorInstallationUnits(input, paths, index, total) {
750
861
  throw new Error('Cursor configuration field "hooks" must be an object');
751
862
  }
752
863
  const hooks = isObject(configuration.hooks) ? configuration.hooks : {};
753
- return CURSOR_HOOK_EVENTS.map((event) => {
864
+ return installedEvents(CURSOR_HOOK_EVENTS, capture).map((event) => {
754
865
  const values = hooks[event];
755
866
  if (values !== undefined && !Array.isArray(values)) {
756
867
  throw new Error(`Cursor hook event "${event}" must be an array`);
@@ -765,7 +876,7 @@ function cursorInstallationUnits(input, paths, index, total) {
765
876
  };
766
877
  });
767
878
  }
768
- function copilotCliInstallationUnits(input, paths, index, total) {
879
+ function copilotCliInstallationUnits(input, paths, index, total, capture) {
769
880
  const configuration = objectConfiguration(input, "copilot-cli");
770
881
  if (configuration.version !== undefined && configuration.version !== 1) {
771
882
  throw new Error('Copilot hook configuration field "version" must be 1');
@@ -774,7 +885,7 @@ function copilotCliInstallationUnits(input, paths, index, total) {
774
885
  throw new Error('Copilot hook configuration field "hooks" must be an object');
775
886
  }
776
887
  const hooks = isObject(configuration.hooks) ? configuration.hooks : {};
777
- return COPILOT_CLI_HOOK_EVENTS.map((event) => {
888
+ return installedEvents(COPILOT_CLI_HOOK_EVENTS, capture).map((event) => {
778
889
  const values = hooks[event];
779
890
  if (values !== undefined && !Array.isArray(values)) {
780
891
  throw new Error(`Copilot hook event "${event}" must be an array`);
@@ -789,7 +900,7 @@ function copilotCliInstallationUnits(input, paths, index, total) {
789
900
  };
790
901
  });
791
902
  }
792
- function copilotVscodeInstallationUnits(input, paths, index, total) {
903
+ function copilotVscodeInstallationUnits(input, paths, index, total, capture) {
793
904
  const configuration = objectConfiguration(input, "copilot-vscode");
794
905
  if (configuration.version !== undefined && configuration.version !== 1) {
795
906
  throw new Error('VS Code Copilot hook field "version" must be 1');
@@ -798,7 +909,7 @@ function copilotVscodeInstallationUnits(input, paths, index, total) {
798
909
  throw new Error('VS Code Copilot hook field "hooks" must be an object');
799
910
  }
800
911
  const hooks = isObject(configuration.hooks) ? configuration.hooks : {};
801
- return HOOK_EVENTS.map((event) => {
912
+ return installedEvents(HOOK_EVENTS, capture).map((event) => {
802
913
  const values = hooks[event];
803
914
  if (values !== undefined && !Array.isArray(values)) {
804
915
  throw new Error(`VS Code Copilot hook event "${event}" must be an array`);
@@ -813,11 +924,11 @@ function copilotVscodeInstallationUnits(input, paths, index, total) {
813
924
  };
814
925
  });
815
926
  }
816
- function polytokenInstallationUnits(input, paths, index, total) {
927
+ function polytokenInstallationUnits(input, paths, index, total, capture) {
817
928
  if (!Array.isArray(input)) {
818
929
  throw new Error("Polytoken hooks must contain a JSON array");
819
930
  }
820
- return POLYTOKEN_HOOK_EVENTS.map((event) => {
931
+ return installedEvents(POLYTOKEN_HOOK_EVENTS, capture).map((event) => {
821
932
  const expected = polytokenEventHandler(event, paths);
822
933
  const owned = input.filter((candidate) => isLorePolytokenHook(candidate) &&
823
934
  (!isObject(candidate) || candidate.event === event));
@@ -828,7 +939,7 @@ function polytokenInstallationUnits(input, paths, index, total) {
828
939
  };
829
940
  });
830
941
  }
831
- function openCodeInstallationUnits(input, index, total) {
942
+ function openCodeInstallationUnits(input, paths, index, total) {
832
943
  const configuration = objectConfiguration(input, "opencode");
833
944
  if (configuration.plugin !== undefined &&
834
945
  !Array.isArray(configuration.plugin)) {
@@ -839,28 +950,37 @@ function openCodeInstallationUnits(input, index, total) {
839
950
  : [];
840
951
  const owned = plugins.filter(isLoreOpenCodePlugin);
841
952
  const exactCount = owned.filter((candidate) => candidate === LORE_OPENCODE_PLUGIN).length;
953
+ if (configuration.mcp !== undefined && !isObject(configuration.mcp)) {
954
+ throw new Error('OpenCode configuration field "mcp" must be a JSON object');
955
+ }
956
+ const server = isObject(configuration.mcp) ? configuration.mcp[LORE_MCP_SERVER_NAME] : undefined;
957
+ const ownedServer = Number(isLoreOpenCodeMcp(server));
842
958
  return [
843
959
  {
844
960
  id: installationUnitId("plugin", index, total),
845
961
  state: installationUnitState(owned.length, exactCount),
846
962
  },
963
+ {
964
+ id: installationUnitId("mcp-tools", index, total),
965
+ state: installationUnitState(ownedServer, Number(exactConfiguration(server, openCodeMcpEntry(loreMcpServer(paths, "opencode"))))),
966
+ },
847
967
  ];
848
968
  }
849
- function inspectInstallationUnits(input, agent, paths, index, total) {
969
+ function inspectInstallationUnits(input, agent, paths, index, total, capture) {
850
970
  switch (agent) {
851
971
  case "claude":
852
972
  case "codex":
853
- return commandHookInstallationUnits(input, agent, paths, index, total);
973
+ return commandHookInstallationUnits(input, agent, paths, index, total, capture);
854
974
  case "copilot-cli":
855
- return copilotCliInstallationUnits(input, paths, index, total);
975
+ return copilotCliInstallationUnits(input, paths, index, total, capture);
856
976
  case "copilot-vscode":
857
- return copilotVscodeInstallationUnits(input, paths, index, total);
977
+ return copilotVscodeInstallationUnits(input, paths, index, total, capture);
858
978
  case "cursor":
859
- return cursorInstallationUnits(input, paths, index, total);
979
+ return cursorInstallationUnits(input, paths, index, total, capture);
860
980
  case "polytoken":
861
- return polytokenInstallationUnits(input, paths, index, total);
981
+ return polytokenInstallationUnits(input, paths, index, total, capture);
862
982
  case "opencode":
863
- return openCodeInstallationUnits(input, index, total);
983
+ return openCodeInstallationUnits(input, paths, index, total);
864
984
  }
865
985
  }
866
986
  async function readJsonDocument(path) {
@@ -920,6 +1040,39 @@ async function writeMergedJson(path, document, value, now) {
920
1040
  ...(backup === undefined ? {} : { backup }),
921
1041
  };
922
1042
  }
1043
+ async function readTextDocument(path) {
1044
+ try {
1045
+ const [value, metadata] = await Promise.all([
1046
+ readFile(path, "utf8"),
1047
+ stat(path),
1048
+ ]);
1049
+ return { exists: true, value, mode: metadata.mode & 0o777 };
1050
+ }
1051
+ catch (error) {
1052
+ const code = typeof error === "object" && error !== null && "code" in error
1053
+ ? error.code
1054
+ : undefined;
1055
+ if (code === "ENOENT") {
1056
+ return { exists: false, value: "", mode: 0o600 };
1057
+ }
1058
+ throw error;
1059
+ }
1060
+ }
1061
+ async function writeMergedText(path, document, value, now) {
1062
+ if (document.exists && document.value === value) {
1063
+ return { changed: false };
1064
+ }
1065
+ let backup;
1066
+ if (document.exists) {
1067
+ backup = `${path}.lore-backup-${backupTimestamp(now)}-${randomUUID().slice(0, 8)}`;
1068
+ await copyFile(path, backup, fsConstants.COPYFILE_EXCL);
1069
+ }
1070
+ await atomicWrite(path, value, document.mode);
1071
+ return {
1072
+ changed: true,
1073
+ ...(backup === undefined ? {} : { backup }),
1074
+ };
1075
+ }
923
1076
  function parseConnectorConfig(value) {
924
1077
  if (!isObject(value) ||
925
1078
  (value.version !== 1 && value.version !== 2) ||
@@ -979,6 +1132,7 @@ function parseConnectorConfig(value) {
979
1132
  Object.keys(agentConfigPaths).length === 0
980
1133
  ? {}
981
1134
  : { agentConfigPaths }),
1135
+ ...(value.capture === "off" ? { capture: "off" } : {}),
982
1136
  connectedAt: value.connectedAt,
983
1137
  timeoutMs,
984
1138
  };
@@ -1145,79 +1299,84 @@ function objectConfiguration(input, agent) {
1145
1299
  const AGENT_TARGETS = {
1146
1300
  claude: {
1147
1301
  integration: "hooks",
1148
- expected: HOOK_EVENTS.length,
1302
+ // Native hooks plus the Lore MCP server entry.
1303
+ expected: (capture) => installedEvents(HOOK_EVENTS, capture).length + 1,
1149
1304
  runtimeRequired: true,
1150
1305
  emptyValue: () => ({}),
1151
1306
  configPath: (paths) => paths.claudeSettings,
1152
1307
  detect: async () => commandExists("claude"),
1153
1308
  executable: async () => commandExists("claude"),
1154
- merge: (input, paths) => mergeLoreHooks(objectConfiguration(input, "claude"), "claude", paths),
1309
+ merge: (input, paths, capture) => mergeLoreHooks(objectConfiguration(input, "claude"), "claude", paths, capture),
1155
1310
  remove: (input) => removeLoreHooks(objectConfiguration(input, "claude")),
1156
1311
  count: (input) => countLoreHooks(objectConfiguration(input, "claude")),
1157
1312
  },
1158
1313
  codex: {
1159
1314
  integration: "hooks",
1160
- expected: HOOK_EVENTS.length,
1315
+ expected: (capture) => installedEvents(HOOK_EVENTS, capture).length + 1,
1161
1316
  runtimeRequired: true,
1162
1317
  emptyValue: () => ({}),
1163
1318
  configPath: (paths) => paths.codexHooks,
1164
1319
  detect: async () => commandExists("codex"),
1165
1320
  executable: async () => commandExists("codex"),
1166
- merge: (input, paths) => mergeLoreHooks(objectConfiguration(input, "codex"), "codex", paths),
1321
+ merge: (input, paths, capture) => mergeLoreHooks(objectConfiguration(input, "codex"), "codex", paths, capture),
1167
1322
  remove: (input) => removeLoreHooks(objectConfiguration(input, "codex")),
1168
1323
  count: (input) => countLoreHooks(objectConfiguration(input, "codex")),
1169
1324
  },
1170
1325
  "copilot-cli": {
1171
1326
  integration: "hooks",
1172
- expected: COPILOT_CLI_HOOK_EVENTS.length,
1327
+ // Native hooks plus the Lore MCP server entry.
1328
+ expected: (capture) => installedEvents(COPILOT_CLI_HOOK_EVENTS, capture).length + 1,
1173
1329
  runtimeRequired: true,
1174
1330
  emptyValue: () => ({}),
1175
1331
  configPath: (paths) => paths.copilotCliHooks,
1176
1332
  detect: async (paths) => (await commandExists("copilot")) || pathExists(paths.copilotCliHooks),
1177
1333
  executable: async () => commandExists("copilot"),
1178
- merge: (input, paths) => mergeLoreCopilotCliHooks(objectConfiguration(input, "copilot-cli"), paths),
1334
+ merge: (input, paths, capture) => mergeLoreCopilotCliHooks(objectConfiguration(input, "copilot-cli"), paths, capture),
1179
1335
  remove: (input) => removeLoreCopilotCliHooks(objectConfiguration(input, "copilot-cli")),
1180
1336
  count: (input) => countLoreCopilotCliHooks(objectConfiguration(input, "copilot-cli")),
1181
1337
  },
1182
1338
  "copilot-vscode": {
1183
1339
  integration: "hooks",
1184
- expected: HOOK_EVENTS.length,
1340
+ // Repository hooks plus the Lore MCP server entry in .vscode/mcp.json.
1341
+ expected: (capture) => installedEvents(HOOK_EVENTS, capture).length + 1,
1185
1342
  runtimeRequired: true,
1186
1343
  emptyValue: () => ({}),
1187
1344
  configPath: (paths) => paths.copilotVscodeHooks,
1188
1345
  detect: async (paths) => pathExists(paths.copilotVscodeHooks),
1189
1346
  executable: async () => commandExists("code"),
1190
- merge: (input, paths) => mergeLoreCopilotVscodeHooks(objectConfiguration(input, "copilot-vscode"), paths),
1347
+ merge: (input, paths, capture) => mergeLoreCopilotVscodeHooks(objectConfiguration(input, "copilot-vscode"), paths, capture),
1191
1348
  remove: (input) => removeLoreCopilotVscodeHooks(objectConfiguration(input, "copilot-vscode")),
1192
1349
  count: (input) => countLoreCopilotVscodeHooks(objectConfiguration(input, "copilot-vscode")),
1193
1350
  },
1194
1351
  cursor: {
1195
1352
  integration: "hooks",
1196
- expected: CURSOR_HOOK_EVENTS.length + 1,
1353
+ // Hooks, the capture bridge, and the Lore MCP server entry.
1354
+ expected: (capture) => installedEvents(CURSOR_HOOK_EVENTS, capture).length + 2,
1197
1355
  runtimeRequired: true,
1198
1356
  emptyValue: () => ({}),
1199
1357
  configPath: (paths) => paths.cursorHooks,
1200
1358
  detect: async (paths) => (await cursorAvailable(paths)) || pathExists(paths.cursorHooks),
1201
1359
  executable: cursorAvailable,
1202
- merge: (input, paths) => mergeLoreCursorHooks(objectConfiguration(input, "cursor"), paths),
1360
+ merge: (input, paths, capture) => mergeLoreCursorHooks(objectConfiguration(input, "cursor"), paths, capture),
1203
1361
  remove: (input) => removeLoreCursorHooks(objectConfiguration(input, "cursor")),
1204
1362
  count: (input) => countLoreCursorHooks(objectConfiguration(input, "cursor")),
1205
1363
  },
1206
1364
  opencode: {
1207
1365
  integration: "plugin",
1208
- expected: 1,
1366
+ // The plugin entry plus the Lore MCP server entry, both in opencode.json.
1367
+ expected: () => 2,
1209
1368
  runtimeRequired: false,
1210
1369
  emptyValue: () => ({}),
1211
1370
  configPath: (paths) => paths.openCodeConfig,
1212
1371
  detect: async (paths) => (await commandExists("opencode")) || pathExists(paths.openCodeConfig),
1213
1372
  executable: async () => commandExists("opencode"),
1214
- merge: (input) => mergeLoreOpenCodePlugin(objectConfiguration(input, "opencode")),
1215
- remove: (input) => removeLoreOpenCodePlugin(objectConfiguration(input, "opencode")),
1216
- count: (input) => countLoreOpenCodePlugins(objectConfiguration(input, "opencode")),
1373
+ merge: (input, paths) => mergeLoreOpenCodeMcp(mergeLoreOpenCodePlugin(objectConfiguration(input, "opencode")), loreMcpServer(paths, "opencode")),
1374
+ remove: (input) => removeLoreOpenCodeMcp(removeLoreOpenCodePlugin(objectConfiguration(input, "opencode"))),
1375
+ count: (input) => countLoreOpenCodePlugins(objectConfiguration(input, "opencode")) + countLoreOpenCodeMcp(objectConfiguration(input, "opencode")),
1217
1376
  },
1218
1377
  polytoken: {
1219
1378
  integration: "hooks",
1220
- expected: POLYTOKEN_HOOK_EVENTS.length,
1379
+ expected: (capture) => installedEvents(POLYTOKEN_HOOK_EVENTS, capture).length,
1221
1380
  runtimeRequired: true,
1222
1381
  emptyValue: () => [],
1223
1382
  configPath: (paths) => paths.polytokenHooks,
@@ -1400,7 +1559,7 @@ async function readT3CodeProviderInstallations(paths) {
1400
1559
  }
1401
1560
  async function expandConnectInstallations(requested, paths) {
1402
1561
  const direct = requested.flatMap((agent) => {
1403
- if (isCredentialOnlyAgent(agent) || agent === "t3code") {
1562
+ if (isCredentialOnlyAgent(agent) || agent === "t3code" || agent === GENERIC_AGENT) {
1404
1563
  return [];
1405
1564
  }
1406
1565
  return [
@@ -1433,6 +1592,7 @@ async function installRuntime(paths) {
1433
1592
  rm(paths.runtime, { force: true }),
1434
1593
  rm(paths.runtimeRepository, { force: true }),
1435
1594
  rm(resolve(dirname(paths.runtime), "repository-address.js"), { force: true }),
1595
+ rm(resolve(dirname(paths.runtime), "capture-policy.js"), { force: true }),
1436
1596
  rm(resolve(dirname(paths.runtime), "directory-policy.js"), { force: true }),
1437
1597
  rm(paths.runtimeReliabilityStore, { force: true }),
1438
1598
  rm(resolve(dirname(paths.runtime), "windows-permissions.js"), { force: true }),
@@ -1491,6 +1651,8 @@ async function installRuntime(paths) {
1491
1651
  "skill-guidance.js": await readFile(resolve(sourceDirectory, "skill-guidance.js"), "utf8"),
1492
1652
  "repository.js": repository,
1493
1653
  "repository-address.js": await readFile(resolve(dirname(fileURLToPath(import.meta.resolve("@lore-co/core/repository-location"))), "repository-address.js"), "utf8"),
1654
+ // directory-policy.js reads the live capture switches through this dependency-free module.
1655
+ "capture-policy.js": await readFile(resolve(dirname(fileURLToPath(import.meta.resolve("@lore-co/core/local-scope"))), "capture-policy.js"), "utf8"),
1494
1656
  "directory-policy.js": (await readFile(fileURLToPath(import.meta.resolve("@lore-co/core/local-scope")), "utf8")).replaceAll("./repository-location.js", "./repository.js"),
1495
1657
  "reliability-store.js": reliabilityStore,
1496
1658
  "windows-permissions.js": await readFile(resolve(sourceDirectory, "windows-permissions.js"), "utf8"),
@@ -1524,6 +1686,7 @@ async function installRuntime(paths) {
1524
1686
  atomicWrite(resolve(dirname(paths.runtime), "skill-guidance.js"), runtimeFiles["skill-guidance.js"], 0o600),
1525
1687
  atomicWrite(paths.runtimeRepository, repository, 0o600),
1526
1688
  atomicWrite(resolve(dirname(paths.runtime), "repository-address.js"), runtimeFiles["repository-address.js"], 0o600),
1689
+ atomicWrite(resolve(dirname(paths.runtime), "capture-policy.js"), runtimeFiles["capture-policy.js"], 0o600),
1527
1690
  atomicWrite(resolve(dirname(paths.runtime), "directory-policy.js"), runtimeFiles["directory-policy.js"], 0o600),
1528
1691
  atomicWrite(paths.runtimeReliabilityStore, reliabilityStore, 0o600),
1529
1692
  atomicWrite(resolve(dirname(paths.runtime), "windows-permissions.js"), runtimeFiles["windows-permissions.js"], 0o600),
@@ -1541,13 +1704,109 @@ async function installRuntime(paths) {
1541
1704
  function agentConfigPath(agent, paths) {
1542
1705
  return targetFor(agent).configPath(paths);
1543
1706
  }
1544
- function mergeAgentConfig(input, agent, paths) {
1707
+ function mergeAgentConfig(input, agent, paths, capture) {
1545
1708
  const target = targetFor(agent);
1546
- return target.merge(input === undefined ? target.emptyValue() : input, paths);
1709
+ return target.merge(input === undefined ? target.emptyValue() : input, paths, capture);
1547
1710
  }
1548
1711
  function removeAgentConfig(input, agent) {
1549
1712
  return targetFor(agent).remove(input);
1550
1713
  }
1714
+ /**
1715
+ * Read each installation's hooks file and, for MCP hosts, its MCP config, and
1716
+ * merge the Lore entries this CLI version writes. Nothing touches disk yet.
1717
+ */
1718
+ async function prepareAgentEntries(installations, paths, capture) {
1719
+ const documents = new Map();
1720
+ const mergedDocuments = new Map();
1721
+ const mcpDocuments = new Map();
1722
+ for (const installation of installations) {
1723
+ const key = installationKey(installation);
1724
+ const document = await readJsonDocument(installation.configPath);
1725
+ documents.set(key, document);
1726
+ mergedDocuments.set(key, mergeAgentConfig(document.value, installation.agent, paths, capture));
1727
+ if (isMcpHostAgent(installation.agent)) {
1728
+ const mcpPath = mcpConfigPath(installation.agent, installation.configPath);
1729
+ if (installation.agent === "codex") {
1730
+ const text = await readTextDocument(mcpPath);
1731
+ mcpDocuments.set(mcpPath, { kind: "text", document: text, merged: mergeLoreCodexMcp(text.value, loreMcpServer(paths, "codex")) });
1732
+ }
1733
+ else {
1734
+ const mcp = await readJsonDocument(mcpPath);
1735
+ const base = mcp.exists ? mcp.value : {};
1736
+ // Cursor keeps its capture bridge next to the shared tool server.
1737
+ const withBridge = installation.agent === "cursor" ? mergeLoreCursorMcp(base, paths) : base;
1738
+ mcpDocuments.set(mcpPath, { kind: "json", document: mcp, merged: mergeLoreMcp(withBridge, installation.agent, paths) });
1739
+ }
1740
+ }
1741
+ }
1742
+ return { installations: [...installations], documents, mergedDocuments, mcpDocuments };
1743
+ }
1744
+ /**
1745
+ * Install the hook runtime when an installation needs it, then write the
1746
+ * merged files. Unchanged files are left alone; changed ones get a backup.
1747
+ */
1748
+ async function writeAgentEntries(entries, paths, now) {
1749
+ if (entries.installations.some((installation) => targetFor(installation.agent).runtimeRequired)) {
1750
+ await installRuntime(paths);
1751
+ }
1752
+ const changedFiles = [];
1753
+ const backups = [];
1754
+ for (const [path, entry] of entries.mcpDocuments) {
1755
+ const result = entry.kind === "json"
1756
+ ? await writeMergedJson(path, entry.document, entry.merged, now)
1757
+ : await writeMergedText(path, entry.document, entry.merged, now);
1758
+ if (result.changed)
1759
+ changedFiles.push(path);
1760
+ if (result.backup)
1761
+ backups.push(result.backup);
1762
+ }
1763
+ for (const installation of entries.installations) {
1764
+ const key = installationKey(installation);
1765
+ const document = entries.documents.get(key);
1766
+ const merged = entries.mergedDocuments.get(key);
1767
+ if (document === undefined || merged === undefined) {
1768
+ continue;
1769
+ }
1770
+ const result = await writeMergedJson(installation.configPath, document, merged, now);
1771
+ if (result.changed) {
1772
+ changedFiles.push(installation.configPath);
1773
+ }
1774
+ if (result.backup !== undefined) {
1775
+ backups.push(result.backup);
1776
+ }
1777
+ }
1778
+ return { changedFiles, backups };
1779
+ }
1780
+ /**
1781
+ * Re-apply the Lore-owned hook and MCP entries and the `lore` skill for the
1782
+ * agents recorded in the stored connection, for example after `lore update`
1783
+ * replaced the binary. Nothing is authorized again and folder rules are
1784
+ * untouched. Resolves to null when no connection is stored.
1785
+ */
1786
+ export async function refreshAgentEntries(paths = getLorePaths()) {
1787
+ const config = await readConnectorConfig(paths);
1788
+ if (config === null)
1789
+ return null;
1790
+ const installations = uniqueInstallations(configuredInstallations(config, paths));
1791
+ return withDirectoryLock(resolve(paths.loreDirectory, "connections.lock"), async () => {
1792
+ const entries = await prepareAgentEntries(installations, paths, captureEnabled(config));
1793
+ const written = await writeAgentEntries(entries, paths, new Date());
1794
+ const generic = (await readGenericAgents(paths.loreDirectory)).length > 0;
1795
+ const skills = await installLoreSkill(paths, generic ? [...installations, { agent: GENERIC_AGENT, configPath: paths.config }] : installations);
1796
+ const agent = await installLoreAgent(paths, installations);
1797
+ return {
1798
+ // Same order the files are written in, so the two update lines match.
1799
+ configFiles: [
1800
+ ...entries.mcpDocuments.keys(),
1801
+ ...installations.map((installation) => installation.configPath),
1802
+ ],
1803
+ ...written,
1804
+ skillFiles: skills.files,
1805
+ agentFiles: agent.files,
1806
+ warnings: [...skills.warnings, ...agent.warnings],
1807
+ };
1808
+ });
1809
+ }
1551
1810
  function countAgentIntegrations(input, agent) {
1552
1811
  const target = targetFor(agent);
1553
1812
  return target.count(input === undefined ? target.emptyValue() : input);
@@ -1595,6 +1854,14 @@ function parseConnectArguments(args) {
1595
1854
  parsed.recovery = false;
1596
1855
  continue;
1597
1856
  }
1857
+ if (argument === "--no-capture") {
1858
+ parsed.capture = false;
1859
+ continue;
1860
+ }
1861
+ if (argument === "--capture") {
1862
+ parsed.capture = true;
1863
+ continue;
1864
+ }
1598
1865
  if (argument === "--current-project") {
1599
1866
  parsed.currentProject = true;
1600
1867
  continue;
@@ -1607,6 +1874,10 @@ function parseConnectArguments(args) {
1607
1874
  parsed.allProjects = true;
1608
1875
  continue;
1609
1876
  }
1877
+ if (argument === "--write") {
1878
+ parsed.writeTools = true;
1879
+ continue;
1880
+ }
1610
1881
  if (argument !== "--repository" &&
1611
1882
  argument !== "--folder" &&
1612
1883
  argument !== "--name" &&
@@ -1614,6 +1885,7 @@ function parseConnectArguments(args) {
1614
1885
  argument !== "--dashboard-url" &&
1615
1886
  argument !== "--token" &&
1616
1887
  argument !== "--agent" &&
1888
+ argument !== "--agent-id" &&
1617
1889
  argument !== "--fallback" &&
1618
1890
  argument !== "--timeout-ms") {
1619
1891
  throw new Error(`Unknown connect option: ${argument ?? ""}`);
@@ -1629,6 +1901,9 @@ function parseConnectArguments(args) {
1629
1901
  else if (argument === "--name") {
1630
1902
  parsed.connectionName = value;
1631
1903
  }
1904
+ else if (argument === "--agent-id") {
1905
+ parsed.agentId = parseGenericAgentId(value);
1906
+ }
1632
1907
  else if (argument === "--fallback") {
1633
1908
  if (!isLaunchable(value))
1634
1909
  throw new Error(`--fallback must be ${LAUNCHABLE_AGENTS.join(", ")}`);
@@ -1654,10 +1929,13 @@ function parseConnectArguments(args) {
1654
1929
  parsed.agents.push(value);
1655
1930
  }
1656
1931
  else {
1657
- throw new Error("--agent must be claude, codex, copilot, copilot-cli, copilot-vscode, cowork, cursor, devin, opencode, polytoken, or t3code");
1932
+ throw new Error("--agent must be claude, codex, copilot, copilot-cli, copilot-vscode, cowork, cursor, devin, generic, opencode, polytoken, or t3code");
1658
1933
  }
1659
1934
  }
1660
1935
  validateConnectProjectOptions(parsed);
1936
+ if ((parsed.agentId !== undefined || parsed.writeTools) && !parsed.agents.includes(GENERIC_AGENT)) {
1937
+ throw new Error("--agent-id and --write apply to --agent generic.");
1938
+ }
1661
1939
  if (parsed.login && parsed.token !== undefined) {
1662
1940
  throw new Error("--login and --token cannot be used together");
1663
1941
  }
@@ -1755,7 +2033,7 @@ async function connectWithCredentialLocked(options, identity) {
1755
2033
  ...new Set([
1756
2034
  ...requestedExpansion.installations.map((installation) => installation.agent),
1757
2035
  ...detectedAgents,
1758
- ...options.agents.filter((agent) => isCredentialOnlyAgent(agent) || agent === "t3code"),
2036
+ ...options.agents.filter((agent) => isCredentialOnlyAgent(agent) || agent === "t3code" || agent === GENERIC_AGENT),
1759
2037
  ]),
1760
2038
  ].sort();
1761
2039
  const keptAgents = [
@@ -1769,24 +2047,17 @@ async function connectWithCredentialLocked(options, identity) {
1769
2047
  // Cowork and the Devin CLI read the saved credential from ~/.lore/config.json
1770
2048
  // and need no hooks, so they connect without a native agent installation.
1771
2049
  const credentialOnlyAgents = CREDENTIAL_ONLY_AGENTS.filter((agent) => options.agents.includes(agent));
1772
- if (agents.length === 0 && credentialOnlyAgents.length === 0) {
2050
+ // An MCP-only agent has no hooks or settings Lore can edit; it gets the skills and a printed entry.
2051
+ const genericAgent = options.agents.includes(GENERIC_AGENT) ? options.genericAgent ?? { id: GENERIC_AGENT, writeTools: false } : undefined;
2052
+ if (agents.length === 0 && credentialOnlyAgents.length === 0 && genericAgent === undefined) {
1773
2053
  throw new Error("No Claude, Codex, Copilot CLI, Cursor, OpenCode, or Polytoken installation detected. Use --agent <name>; T3 Code users can use --agent t3code, and Devin CLI users --agent devin.");
1774
2054
  }
1775
2055
  const now = new Date();
1776
- const documents = new Map();
1777
- const mergedDocuments = new Map();
1778
- const cursorMcpDocuments = new Map();
1779
- for (const installation of installations) {
1780
- const key = installationKey(installation);
1781
- const document = await readJsonDocument(installation.configPath);
1782
- documents.set(key, document);
1783
- mergedDocuments.set(key, mergeAgentConfig(document.value, installation.agent, paths));
1784
- if (installation.agent === "cursor") {
1785
- const mcpPath = resolve(dirname(installation.configPath), "mcp.json");
1786
- const mcp = await readJsonDocument(mcpPath);
1787
- cursorMcpDocuments.set(mcpPath, { document: mcp, merged: mergeLoreCursorMcp(mcp.exists ? mcp.value : {}, paths) });
1788
- }
1789
- }
2056
+ // Reconnecting without a capture option keeps the saved mode, like folder rules.
2057
+ const capture = options.capture ?? captureEnabled(existing);
2058
+ // Read and merge before anything is persisted so an unreadable agent file
2059
+ // stops the run without a half-written connection.
2060
+ const entries = await prepareAgentEntries(installations, paths, capture);
1790
2061
  const agentConfigPaths = {};
1791
2062
  for (const agent of agents) {
1792
2063
  agentConfigPaths[agent] = installations
@@ -1802,6 +2073,7 @@ async function connectWithCredentialLocked(options, identity) {
1802
2073
  token: token.trim(),
1803
2074
  agents,
1804
2075
  agentConfigPaths,
2076
+ ...(capture ? {} : { capture: "off" }),
1805
2077
  connectedAt: connection.profiles.find(value => value.connectionId === connection.fields.connectionId)?.connectedAt ?? now.toISOString(),
1806
2078
  timeoutMs,
1807
2079
  };
@@ -1812,33 +2084,10 @@ async function connectWithCredentialLocked(options, identity) {
1812
2084
  await reliabilityStore.initialize();
1813
2085
  // Folder restrictions must be durable before any new hook can run.
1814
2086
  await persistConnection(paths, config, connection.profiles);
1815
- if (installations.some((installation) => targetFor(installation.agent).runtimeRequired)) {
1816
- await installRuntime(paths);
1817
- }
1818
- const changedHookFiles = [];
1819
- const backups = [];
1820
- for (const [path, { document, merged }] of cursorMcpDocuments) {
1821
- const result = await writeMergedJson(path, document, merged, now);
1822
- if (result.changed)
1823
- changedHookFiles.push(path);
1824
- if (result.backup)
1825
- backups.push(result.backup);
1826
- }
1827
- for (const installation of installations) {
1828
- const key = installationKey(installation);
1829
- const document = documents.get(key);
1830
- const merged = mergedDocuments.get(key);
1831
- if (document === undefined || merged === undefined) {
1832
- continue;
1833
- }
1834
- const result = await writeMergedJson(installation.configPath, document, merged, now);
1835
- if (result.changed) {
1836
- changedHookFiles.push(installation.configPath);
1837
- }
1838
- if (result.backup !== undefined) {
1839
- backups.push(result.backup);
1840
- }
1841
- }
2087
+ const { changedFiles: changedHookFiles, backups } = await writeAgentEntries(entries, paths, now);
2088
+ // The Devin wrapper and hook check the capture mode when they run, so stale
2089
+ // Devin hooks record nothing; an unreadable Devin config must not fail connect.
2090
+ changedHookFiles.push(...await refreshDevinHooks(paths.home, capture).catch(() => []));
1842
2091
  if (existing && !existing.connectionId && existing.apiUrl === apiUrl && existing.token === token) {
1843
2092
  await new ReliabilityStore(identity.workspaceId, { home: paths.home }).transferPendingTo(reliabilityStore);
1844
2093
  await reliabilityStore.migrateLegacyQueue(paths.queue);
@@ -1858,7 +2107,10 @@ async function connectWithCredentialLocked(options, identity) {
1858
2107
  .catch((error) => [`Host recovery setup: ${error.message}`]) : [];
1859
2108
  if (options.recovery === false)
1860
2109
  await disableRecovery(paths.home);
1861
- const skills = await installLoreSkill(paths, installations);
2110
+ const skills = await installLoreSkill(paths, genericAgent ? [...installations, { agent: GENERIC_AGENT, configPath: paths.config }] : installations);
2111
+ const loreAgent = await installLoreAgent(paths, installations);
2112
+ if (genericAgent)
2113
+ await recordGenericAgent(paths.loreDirectory, genericAgent, now);
1862
2114
  // An unchanged reconnect: same server, credential, and workspace, every
1863
2115
  // configured agent was already configured, and no agent file was rewritten.
1864
2116
  const alreadyConnected = existing !== null &&
@@ -1867,6 +2119,7 @@ async function connectWithCredentialLocked(options, identity) {
1867
2119
  existing.workspaceId === identity.workspaceId &&
1868
2120
  existing.connectionId === config.connectionId &&
1869
2121
  agents.every((agent) => existing.agents.includes(agent)) &&
2122
+ captureEnabled(existing) === capture &&
1870
2123
  changedHookFiles.length === 0 &&
1871
2124
  JSON.stringify(existing.directories ?? null) === JSON.stringify(config.directories ?? null);
1872
2125
  // A Codex set up in this run gets the approval step in its next-step line;
@@ -1877,12 +2130,18 @@ async function connectWithCredentialLocked(options, identity) {
1877
2130
  const codexNeedsApproval = !installedAgents.includes("codex") &&
1878
2131
  changedHookFiles.some((path) => codexHookPaths.includes(path));
1879
2132
  return {
1880
- capture: { folders: config.directories?.include ?? null, excludedFolders: config.directories?.exclude ?? [] },
2133
+ capture: { mode: capture ? "on" : "off", folders: config.directories?.include ?? null, excludedFolders: config.directories?.exclude ?? [] },
1881
2134
  skillFiles: skills.files,
2135
+ agentFiles: loreAgent.files,
2136
+ ...(genericAgent ? { genericAgent: {
2137
+ ...genericAgent,
2138
+ mcpServers: genericMcpServers(loreMcpInvocation(), paths.home, genericAgent).mcpServers,
2139
+ instructions: genericInstructions(genericAgent.id, skills.files.filter((file) => file.startsWith(resolve(paths.home, ".agents", "skills")))),
2140
+ } } : {}),
1882
2141
  connected: true,
1883
2142
  apiUrl: config.apiUrl,
1884
2143
  identity,
1885
- agents: [...agents, ...credentialOnlyAgents],
2144
+ agents: [...agents, ...credentialOnlyAgents, ...(genericAgent ? [GENERIC_AGENT] : [])],
1886
2145
  installedAgents,
1887
2146
  keptAgents,
1888
2147
  alreadyConnected,
@@ -1891,7 +2150,7 @@ async function connectWithCredentialLocked(options, identity) {
1891
2150
  config: paths.config,
1892
2151
  changedHookFiles,
1893
2152
  backups,
1894
- warnings: [...skills.warnings, ...(connection.needsFolders ? [`Lore is off for ${config.connectionName} until you choose where to record. Reconnect with --folder <path> or --all-projects, or run lore config open --connection ${config.connectionName}.`] : []), ...requestedExpansion.warnings, ...hostRecoveryWarnings, ...(codexNeedsApproval
2153
+ warnings: [...skills.warnings, ...loreAgent.warnings, ...(connection.needsFolders ? [`Lore is off for ${config.connectionName} until you choose where to record. Reconnect with --folder <path> or --all-projects, or run lore config open --connection ${config.connectionName}.`] : []), ...requestedExpansion.warnings, ...hostRecoveryWarnings, ...(codexNeedsApproval
1895
2154
  ? ["Codex requires hook approval. Open /hooks in Codex and review any new or changed Lore hooks, then start a fresh session. Run lore doctor to verify approval and the first invocation."]
1896
2155
  : [])],
1897
2156
  ...(recoveryNotice ? { recoveryNotice } : {}),
@@ -2024,11 +2283,11 @@ async function acknowledgeDeviceAuthorization(apiUrl, authorization) {
2024
2283
  export function connectNextStep(agent) {
2025
2284
  switch (agent) {
2026
2285
  case "claude":
2027
- return "Restart Claude Code.";
2286
+ return "Restart Claude Code and allow the lore MCP server when it asks.";
2028
2287
  case "codex":
2029
2288
  return "Restart Codex, open /hooks, approve Lore's hooks, then start a fresh session.";
2030
2289
  case "cursor":
2031
- return "Restart Cursor and enable the lore-native MCP server (lore_context and lore_complete).";
2290
+ return "Restart Cursor and enable the lore-native and lore MCP servers.";
2032
2291
  case "opencode":
2033
2292
  return "Restart OpenCode.";
2034
2293
  case "copilot":
@@ -2043,12 +2302,17 @@ export function connectNextStep(agent) {
2043
2302
  case "cowork":
2044
2303
  case "t3code":
2045
2304
  return "Restart your agent and complete a turn.";
2305
+ case "generic":
2306
+ return "Add the MCP entry below to your agent's MCP settings and the instructions to its AGENTS.md (or GEMINI.md or rules file), then restart it.";
2046
2307
  }
2047
2308
  }
2048
2309
  export function formatConnectResult(result) {
2049
- const captureSummary = result.capture.folders === null ? "all projects allowed by the workspace"
2310
+ const folderSummary = result.capture.folders === null ? "all projects allowed by the workspace"
2050
2311
  : result.capture.folders.length ? result.capture.folders.join(", ") : "paused — choose folders with --folder <path>";
2051
2312
  const installed = result.installedAgents.join(", ");
2313
+ const generic = result.genericAgent;
2314
+ // With only an MCP agent connected, nothing on this machine records turns.
2315
+ const nothingRecords = result.capture.mode === "off" || (generic !== undefined && result.agents.every((agent) => agent === GENERIC_AGENT));
2052
2316
  const lines = [
2053
2317
  result.alreadyConnected
2054
2318
  ? `Already connected to ${result.identity.workspaceName} for ${installed}; nothing changed.`
@@ -2056,14 +2320,32 @@ export function formatConnectResult(result) {
2056
2320
  ...(result.keptAgents.length ? [`kept: ${result.keptAgents.join(", ")}`] : []),
2057
2321
  `api_url: ${result.apiUrl}`,
2058
2322
  `config: ${result.config}`,
2059
- `capture: ${captureSummary}${result.capture.excludedFolders.length ? `; excluded: ${result.capture.excludedFolders.join(", ")}` : ""}`,
2323
+ ...(result.capture.mode === "off" ? [`capture: ${captureSummary("off")}`] : []),
2324
+ ...(generic ? [`capture for ${generic.id}: off; an MCP-only agent has no hooks, so Lore saves only what you ask it to`] : []),
2325
+ `${nothingRecords ? "folders" : "capture"}: ${folderSummary}${result.capture.excludedFolders.length ? `; excluded: ${result.capture.excludedFolders.join(", ")}` : ""}`,
2060
2326
  ...result.installedAgents.map((agent) => `Next step for ${agent}: ${connectNextStep(agent)}`),
2061
- result.dashboardUrl === undefined
2062
- ? `Send one message in ${result.folder}, then run lore doctor to confirm recording.`
2063
- : `Send one message in ${result.folder}, then open ${result.dashboardUrl}/connect to confirm recording.`,
2064
- ...(result.skillFiles.length ? ["Choose lore in your agent’s skills to find knowledge or remember this session."] : []),
2327
+ generic !== undefined && nothingRecords
2328
+ ? `Ask your agent "Lore, has anything like this come up before?" in ${result.folder}, then run lore doctor.`
2329
+ : result.capture.mode === "off"
2330
+ ? `Send one message in ${result.folder}, then run lore doctor to confirm context delivery.`
2331
+ : result.dashboardUrl === undefined
2332
+ ? `Send one message in ${result.folder}, then run lore doctor to confirm recording.`
2333
+ : `Send one message in ${result.folder}, then open ${result.dashboardUrl}/connect to confirm recording.`,
2334
+ ...(result.skillFiles.length ? ["Choose lore in your agent’s skills to find knowledge or remember this session, or recap to write a session up by hand."] : []),
2335
+ ...(result.agentFiles.length ? ["Delegate to the lore agent (\"use the lore agent to review my pending learnings\") for recall, proposal review, or a recap with a fresh context."] : []),
2065
2336
  ...(result.recoveryNotice ? [result.recoveryNotice] : []),
2066
2337
  ...result.warnings.map((warning) => `warning: ${warning}`),
2338
+ ...(generic ? [
2339
+ generic.writeTools
2340
+ ? `Saves from ${generic.id} are proposals credited to ${generic.id}; review them in Learnings.`
2341
+ : `${generic.id} can read Lore; it saves through the lore CLI. Reconnect with --write to let it save through MCP (saves stay proposals).`,
2342
+ "",
2343
+ `MCP entry for ${generic.id} (merge into its mcpServers settings):`,
2344
+ JSON.stringify({ mcpServers: generic.mcpServers }, null, 2),
2345
+ "",
2346
+ "Instructions for AGENTS.md, GEMINI.md, or the agent's rules file:",
2347
+ generic.instructions,
2348
+ ] : []),
2067
2349
  ];
2068
2350
  return `${lines.join("\n")}\n`;
2069
2351
  }
@@ -2107,13 +2389,17 @@ async function connectCommand(args) {
2107
2389
  }
2108
2390
  const result = await connectWithCredential({
2109
2391
  ...capture,
2392
+ ...(parsed.capture === undefined ? {} : { capture: parsed.capture }),
2110
2393
  ...(parsed.connectionName === undefined ? {} : { connectionName: parsed.connectionName }),
2111
2394
  ...(parsed.fallback === undefined ? {} : { fallback: parsed.fallback }),
2112
- recovery: parsed.recovery ?? true,
2395
+ // Automatic recovery installs hooks for other agents on PATH; an MCP-only
2396
+ // connection must not start recording them as a side effect.
2397
+ recovery: parsed.recovery ?? !(parsed.agents.length > 0 && parsed.agents.every((agent) => agent === GENERIC_AGENT)),
2113
2398
  apiUrl,
2114
2399
  ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
2115
2400
  token,
2116
2401
  agents: parsed.agents,
2402
+ ...(parsed.agents.includes(GENERIC_AGENT) ? { genericAgent: { id: parsed.agentId ?? GENERIC_AGENT, writeTools: parsed.writeTools === true } } : {}),
2117
2403
  ...(parsed.timeoutMs === undefined ? {} : { timeoutMs: parsed.timeoutMs }),
2118
2404
  });
2119
2405
  if (authorization)
@@ -2235,6 +2521,7 @@ async function inspectRuntimeInstallation(required, paths) {
2235
2521
  "skill-guidance.js": resolve(dirname(paths.runtime), "skill-guidance.js"),
2236
2522
  "repository.js": paths.runtimeRepository,
2237
2523
  "repository-address.js": resolve(dirname(paths.runtime), "repository-address.js"),
2524
+ "capture-policy.js": resolve(dirname(paths.runtime), "capture-policy.js"),
2238
2525
  "directory-policy.js": resolve(dirname(paths.runtime), "directory-policy.js"),
2239
2526
  "reliability-store.js": paths.runtimeReliabilityStore,
2240
2527
  "windows-permissions.js": resolve(dirname(paths.runtime), "windows-permissions.js"),
@@ -2349,14 +2636,31 @@ async function getAgentStatus(agent, config, paths, runtime) {
2349
2636
  existing += document.exists ? 1 : 0;
2350
2637
  units.push(...inspectInstallationUnits(document.value === undefined
2351
2638
  ? target.emptyValue()
2352
- : document.value, agent, paths, index, configPaths.length));
2353
- if (agent === "cursor") {
2354
- const mcp = await readJsonDocument(resolve(dirname(path), "mcp.json"));
2355
- const value = isObject(mcp.value) && isObject(mcp.value.mcpServers) ? mcp.value.mcpServers[CURSOR_MCP_NAME] : undefined;
2356
- const owned = Number(isLoreCursorMcp(value));
2357
- installed += owned;
2358
- units.push({ id: installationUnitId("mcp", index, configPaths.length),
2359
- state: installationUnitState(owned, Number(exactConfiguration(value, cursorMcpServer(paths)))) });
2639
+ : document.value, agent, paths, index, configPaths.length, captureEnabled(config)));
2640
+ if (isMcpHostAgent(agent)) {
2641
+ const mcpPath = mcpConfigPath(agent, path);
2642
+ if (agent === "codex") {
2643
+ const state = inspectCodexMcp((await readTextDocument(mcpPath)).value, loreMcpServer(paths, "codex"));
2644
+ installed += state.owned;
2645
+ units.push({ id: installationUnitId("mcp-tools", index, configPaths.length),
2646
+ state: installationUnitState(state.owned, Number(state.exact)) });
2647
+ }
2648
+ else {
2649
+ const mcp = await readJsonDocument(mcpPath);
2650
+ const servers = isObject(mcp.value) && isObject(mcp.value.mcpServers) ? mcp.value.mcpServers : {};
2651
+ if (agent === "cursor") {
2652
+ const bridge = servers[CURSOR_MCP_NAME];
2653
+ const owned = Number(isLoreCursorMcp(bridge));
2654
+ installed += owned;
2655
+ units.push({ id: installationUnitId("mcp", index, configPaths.length),
2656
+ state: installationUnitState(owned, Number(exactConfiguration(bridge, cursorMcpServer(paths)))) });
2657
+ }
2658
+ const value = agent === "copilot-vscode" ? loreVsCodeMcpServer(mcp.value) : servers[LORE_MCP_SERVER_NAME];
2659
+ const owned = Number(isLoreMcpServer(value));
2660
+ installed += owned;
2661
+ units.push({ id: installationUnitId("mcp-tools", index, configPaths.length),
2662
+ state: installationUnitState(owned, Number(exactConfiguration(value, loreMcpServer(paths, agent)))) });
2663
+ }
2360
2664
  }
2361
2665
  }
2362
2666
  catch {
@@ -2377,7 +2681,7 @@ async function getAgentStatus(agent, config, paths, runtime) {
2377
2681
  state: "not_required",
2378
2682
  version: null,
2379
2683
  };
2380
- const expected = target.expected * configPaths.length;
2684
+ const expected = target.expected(captureEnabled(config)) * configPaths.length;
2381
2685
  const exactUnits = units.filter((unit) => unit.state === "exact").length;
2382
2686
  const installationState = invalidConfiguration
2383
2687
  ? "invalid"
@@ -2440,13 +2744,17 @@ async function statusData(paths, cwd) {
2440
2744
  }
2441
2745
  const runtimeRequired = config?.agents.some((agent) => targetFor(agent).runtimeRequired) ?? false;
2442
2746
  const runtime = await inspectRuntimeInstallation(runtimeRequired, paths);
2443
- const [legacyQueuedTurns, reliability, agents] = await Promise.all([
2747
+ const [legacyQueuedTurns, reliability, agents, switches] = await Promise.all([
2444
2748
  queueCount(paths),
2445
2749
  inspectReliabilityStore(config, paths),
2446
2750
  Promise.all(CONFIGURED_AGENT_NAMES.map(async (agent) => getAgentStatus(agent, config, paths, runtime))),
2751
+ // Only a machine that records turns needs the dashboard switches.
2752
+ captureEnabled(config) ? captureSwitches(config, paths.home, cwd) : CAPTURE_SWITCHES_UNCHECKED,
2447
2753
  ]);
2448
2754
  return {
2449
2755
  folderAccess: await checkDirectory(cwd, paths.home, config ?? undefined),
2756
+ capture: captureEnabled(config) ? "on" : "off",
2757
+ captureSwitches: switches,
2450
2758
  schemaVersion: "lore-cli-status-v1",
2451
2759
  integrationContractVersion: RELIABILITY_INTEGRATION_CONTRACT_VERSION,
2452
2760
  connected: config !== null,
@@ -2495,7 +2803,7 @@ async function statusCommand(args) {
2495
2803
  ? "-"
2496
2804
  : `${(value * 100).toFixed(1)}%`;
2497
2805
  const retrievalFailureLines = formatRetrievalFailureDiagnostics(data.reliability?.operational.retrieval);
2498
- writeResult(data, parsed.json, `connected: ${data.connected ? "yes" : "no"}\ncurrent_folder: ${data.folderAccess.allowed ? "allowed" : "off"} (${data.folderAccess.reason})\napi_url: ${data.apiUrl ?? "-"}\nconfig_mode: ${data.configMode ?? "-"}\nruntime: ${data.runtimeRequired ? (data.runtimeInstalled ? `installed (${data.runtimeVersion})` : data.runtimeVersion === null ? "missing" : `stale (${data.runtimeVersion})`) : "not required"}\ndurable_outbox: ${data.reliability?.outbox.total ?? 0} ready=${data.reliability?.outbox.ready ?? 0} auth_blocked=${data.reliability?.outbox.authBlocked ?? 0} dead=${data.reliability?.outbox.dead ?? 0}\noldest_capture: ${data.reliability?.outbox.oldestEnqueuedAt ?? "-"}\ncontext_snapshots: ${data.reliability?.cache.context ?? 0}\noldest_context_snapshot: ${data.reliability?.cache.oldestContextWrittenAt ?? "-"}\npolicy_snapshots: ${data.reliability?.cache.policy ?? 0}\nlocal_context_fallback_rate: ${metricRate(data.reliability?.operational.retrieval.fallbackRate)}\nlocal_guard_coverage: ${metricRate(data.reliability?.operational.guard.coverageRate)}\nlocal_guard_failures: ${data.reliability?.operational.guard.failures ?? 0}\nlocal_retrieval_failures: ${data.reliability?.operational.retrieval.failed ?? 0}\n${retrievalFailureLines}${data.reliabilityError === null ? "" : `reliability_error: ${data.reliabilityError}\n`}${agentLines}\n${externalSurfaceLines}\n`);
2806
+ writeResult(data, parsed.json, `connected: ${data.connected ? "yes" : "no"}\ncurrent_folder: ${data.folderAccess.allowed ? "allowed" : "off"} (${data.folderAccess.reason})\ncapture: ${captureSummary(data.capture, data.captureSwitches)}\napi_url: ${data.apiUrl ?? "-"}\nconfig_mode: ${data.configMode ?? "-"}\nruntime: ${data.runtimeRequired ? (data.runtimeInstalled ? `installed (${data.runtimeVersion})` : data.runtimeVersion === null ? "missing" : `stale (${data.runtimeVersion})`) : "not required"}\ndurable_outbox: ${data.reliability?.outbox.total ?? 0} ready=${data.reliability?.outbox.ready ?? 0} auth_blocked=${data.reliability?.outbox.authBlocked ?? 0} dead=${data.reliability?.outbox.dead ?? 0}\noldest_capture: ${data.reliability?.outbox.oldestEnqueuedAt ?? "-"}\ncontext_snapshots: ${data.reliability?.cache.context ?? 0}\noldest_context_snapshot: ${data.reliability?.cache.oldestContextWrittenAt ?? "-"}\npolicy_snapshots: ${data.reliability?.cache.policy ?? 0}\nlocal_context_fallback_rate: ${metricRate(data.reliability?.operational.retrieval.fallbackRate)}\nlocal_guard_coverage: ${metricRate(data.reliability?.operational.guard.coverageRate)}\nlocal_guard_failures: ${data.reliability?.operational.guard.failures ?? 0}\nlocal_retrieval_failures: ${data.reliability?.operational.retrieval.failed ?? 0}\n${retrievalFailureLines}${data.reliabilityError === null ? "" : `reliability_error: ${data.reliabilityError}\n`}${agentLines}\n${externalSurfaceLines}\n`);
2499
2807
  }
2500
2808
  async function disconnectCommand(args) {
2501
2809
  const parsed = parseOutputArguments(args, DISCONNECT_HELP, "disconnect");
@@ -2518,15 +2826,29 @@ export async function disconnectInstallation(paths = getLorePaths(), preservePen
2518
2826
  ]);
2519
2827
  for (const installation of installations) {
2520
2828
  const path = installation.configPath;
2521
- if (installation.agent === "cursor") {
2522
- const mcpPath = resolve(dirname(path), "mcp.json");
2523
- const mcp = await readJsonDocument(mcpPath);
2524
- if (mcp.exists) {
2525
- const result = await writeMergedJson(mcpPath, mcp, removeLoreCursorMcp(mcp.value), now);
2526
- if (result.changed)
2527
- changedHookFiles.push(mcpPath);
2528
- if (result.backup)
2529
- backups.push(result.backup);
2829
+ if (isMcpHostAgent(installation.agent)) {
2830
+ const mcpPath = mcpConfigPath(installation.agent, path);
2831
+ if (installation.agent === "codex") {
2832
+ const text = await readTextDocument(mcpPath);
2833
+ if (text.exists) {
2834
+ const result = await writeMergedText(mcpPath, text, removeLoreCodexMcp(text.value), now);
2835
+ if (result.changed)
2836
+ changedHookFiles.push(mcpPath);
2837
+ if (result.backup)
2838
+ backups.push(result.backup);
2839
+ }
2840
+ }
2841
+ else {
2842
+ const mcp = await readJsonDocument(mcpPath);
2843
+ if (mcp.exists) {
2844
+ const withoutTools = removeLoreMcp(mcp.value, installation.agent);
2845
+ const cleaned = installation.agent === "cursor" ? removeLoreCursorMcp(withoutTools) : withoutTools;
2846
+ const result = await writeMergedJson(mcpPath, mcp, cleaned, now);
2847
+ if (result.changed)
2848
+ changedHookFiles.push(mcpPath);
2849
+ if (result.backup)
2850
+ backups.push(result.backup);
2851
+ }
2530
2852
  }
2531
2853
  }
2532
2854
  const document = await readJsonDocument(path);
@@ -2541,13 +2863,16 @@ export async function disconnectInstallation(paths = getLorePaths(), preservePen
2541
2863
  backups.push(result.backup);
2542
2864
  }
2543
2865
  }
2544
- await removeLoreSkill(paths, installations);
2866
+ await removeLoreSkill(paths, [...installations, { agent: GENERIC_AGENT, configPath: paths.config }]);
2867
+ await removeLoreAgent(paths, installations);
2868
+ await removeGenericAgents(paths.loreDirectory);
2545
2869
  await Promise.all([
2546
2870
  rm(paths.config, { force: true }),
2547
2871
  rm(resolve(paths.loreDirectory, "connections"), { recursive: true, force: true }),
2548
2872
  rm(paths.runtime, { force: true }),
2549
2873
  rm(paths.runtimeRepository, { force: true }),
2550
2874
  rm(resolve(dirname(paths.runtime), "repository-address.js"), { force: true }),
2875
+ rm(resolve(dirname(paths.runtime), "capture-policy.js"), { force: true }),
2551
2876
  rm(resolve(dirname(paths.runtime), "directory-policy.js"), { force: true }),
2552
2877
  rm(paths.runtimeReliabilityStore, { force: true }),
2553
2878
  rm(paths.runtimeSignedSnapshot, { force: true }),
@@ -2573,6 +2898,47 @@ export async function disconnectInstallation(paths = getLorePaths(), preservePen
2573
2898
  ]);
2574
2899
  return { connected: false, changedHookFiles, backups };
2575
2900
  }
2901
+ const CAPTURE_SWITCHES_UNCHECKED = { pausedBy: null, checked: false, reason: "not connected" };
2902
+ async function captureSwitches(config, home, cwd) {
2903
+ if (config === null)
2904
+ return CAPTURE_SWITCHES_UNCHECKED;
2905
+ const access = await checkLoreAccess(cwd, home, config);
2906
+ // A folder outside the local rules never reaches the workspace policy.
2907
+ if (access.failure !== undefined || !access.reason.startsWith("Repository"))
2908
+ return { pausedBy: null, checked: false, reason: access.reason };
2909
+ return { pausedBy: access.capturePausedBy ?? null, checked: true, reason: access.capturePausedBy ? capturePauseMessage(access.capturePausedBy) : "on" };
2910
+ }
2911
+ /** How the dashboard switches are resumed, for status and doctor wording. */
2912
+ function captureResumeHint(pausedBy) {
2913
+ return pausedBy === "workspace"
2914
+ ? "An owner or admin can resume it under Settings, Session capture."
2915
+ : "Resume it under Settings, Session capture.";
2916
+ }
2917
+ /**
2918
+ * The effective capture state for `lore capture status` and `lore status`:
2919
+ * the machine's own choice, then the dashboard switch that pauses it, worded
2920
+ * as `lore doctor` words it.
2921
+ */
2922
+ export function captureSummary(capture, switches = CAPTURE_SWITCHES_UNCHECKED) {
2923
+ if (capture === "off")
2924
+ return "off by choice; context and guard active";
2925
+ if (switches.pausedBy === null)
2926
+ return "on; turns are recorded in allowed folders";
2927
+ // "Capture paused by workspace setting" -> "on locally, paused by workspace setting"
2928
+ return `on locally, ${capturePauseMessage(switches.pausedBy).replace(/^Capture p/u, "p")}; context and guard active`;
2929
+ }
2930
+ /** Retrieval-only mode and a paused switch are chosen configurations, never warnings. */
2931
+ export function captureModeCheck(capture, switches = CAPTURE_SWITCHES_UNCHECKED) {
2932
+ return {
2933
+ name: "capture-mode",
2934
+ status: "ok",
2935
+ detail: capture === "off"
2936
+ ? "Capture off by choice; context and guard active. lore remember still saves what you choose. Run lore capture on to record turns again."
2937
+ : switches.pausedBy !== null
2938
+ ? `${capturePauseMessage(switches.pausedBy)}; context and guard active. Saved learnings and /recap still work. ${captureResumeHint(switches.pausedBy)}`
2939
+ : "on: turns are recorded in allowed folders",
2940
+ };
2941
+ }
2576
2942
  /**
2577
2943
  * Server-side queue health. Dead embedding jobs name the operator fix: once the
2578
2944
  * provider is repaired, `lore embeddings repair` retries them without waiting
@@ -2714,6 +3080,93 @@ async function verifyConnectedInstallation() {
2714
3080
  }
2715
3081
  return (await apiChecks(config)).every((check) => check.status !== "error");
2716
3082
  }
3083
+ function formatCaptureResult(result) {
3084
+ const pausedBy = result.pausedBy ?? null;
3085
+ const lines = [
3086
+ `capture: ${captureSummary(result.capture, { ...CAPTURE_SWITCHES_UNCHECKED, pausedBy })}`,
3087
+ ...(result.capture === "on" && pausedBy !== null ? [captureResumeHint(pausedBy)] : []),
3088
+ ...(result.changed
3089
+ ? [
3090
+ `updated: ${result.configFiles.join(", ")}`,
3091
+ result.capture === "on"
3092
+ ? "Restart your agents, then send one message and run lore doctor to confirm recording."
3093
+ : "Restart your agents. lore remember and the lore skill still save what you choose.",
3094
+ ]
3095
+ : []),
3096
+ ];
3097
+ return `${lines.join("\n")}\n`;
3098
+ }
3099
+ /** Switch retrieval-only mode and rewrite the agent hooks to match, without re-authorizing. */
3100
+ export async function setCaptureMode(capture, paths = getLorePaths()) {
3101
+ const config = await readConnectorConfig(paths);
3102
+ if (config === null)
3103
+ throw new Error("Lore is not connected. Run lore connect.");
3104
+ const current = captureEnabled(config) ? "on" : "off";
3105
+ if (current === capture)
3106
+ return { capture, changed: false, configFiles: [] };
3107
+ await withDirectoryLock(resolve(paths.loreDirectory, "connections.lock"), async () => {
3108
+ const { capture: _previous, ...rest } = config;
3109
+ await atomicWriteJson(paths.config, { ...rest, ...(capture === "off" ? { capture } : {}) }, 65536);
3110
+ });
3111
+ const refreshed = await refreshAgentEntries(paths);
3112
+ const devinFiles = await refreshDevinHooks(paths.home, capture === "on");
3113
+ return { capture, changed: true, configFiles: [...refreshed?.configFiles ?? [], ...devinFiles] };
3114
+ }
3115
+ async function captureCommand(args) {
3116
+ let mode;
3117
+ let json = false;
3118
+ for (const argument of args) {
3119
+ if (argument === "--help" || argument === "-h") {
3120
+ process.stdout.write(CAPTURE_HELP);
3121
+ return;
3122
+ }
3123
+ if (argument === "--json") {
3124
+ json = true;
3125
+ continue;
3126
+ }
3127
+ if ((argument === "status" || argument === "on" || argument === "off") && mode === undefined) {
3128
+ mode = argument;
3129
+ continue;
3130
+ }
3131
+ throw new Error("Usage: lore capture [status|on|off] [--json]");
3132
+ }
3133
+ const paths = getLorePaths();
3134
+ if (mode === undefined || mode === "status") {
3135
+ const config = await readConnectorConfig(paths);
3136
+ if (config === null)
3137
+ throw new Error("Lore is not connected. Run lore connect.");
3138
+ const capture = captureEnabled(config) ? "on" : "off";
3139
+ // Same reading as lore status and lore doctor: the folder's connection and its dashboard switches.
3140
+ const switches = capture === "on" ? await captureSwitches(await diagnosticConfig(paths, process.cwd()), paths.home, process.cwd()) : CAPTURE_SWITCHES_UNCHECKED;
3141
+ const result = { capture, pausedBy: switches.pausedBy, changed: false, configFiles: [] };
3142
+ writeResult(result, json, formatCaptureResult(result));
3143
+ return;
3144
+ }
3145
+ const result = await setCaptureMode(mode, paths);
3146
+ writeResult(result, json, formatCaptureResult(result));
3147
+ }
3148
+ /** MCP-only agents from `lore connect --agent generic`: their skills, and that nothing records them. */
3149
+ export async function genericAgentChecks(paths) {
3150
+ const agents = await readGenericAgents(paths.loreDirectory);
3151
+ if (agents.length === 0)
3152
+ return [];
3153
+ const skills = (await Promise.all(["lore", "recap"].map(async (name) => {
3154
+ const file = resolve(paths.home, ".agents", "skills", name, "SKILL.md");
3155
+ return await access(file).then(() => null, () => file);
3156
+ }))).filter((file) => file !== null);
3157
+ return agents.flatMap((agent) => [
3158
+ {
3159
+ name: `${agent.id}-mcp`,
3160
+ status: "ok",
3161
+ detail: `MCP only, ${agent.writeTools ? "read-write (saves are proposals credited to " + agent.id + ")" : "read-only (saves go through the lore CLI)"}. Capture off: no hooks, so this agent's turns are never recorded. Lore cannot inspect the agent's own MCP settings; ask it "Lore, has anything like this come up before?" to confirm.`,
3162
+ },
3163
+ {
3164
+ name: `${agent.id}-skills`,
3165
+ status: skills.length === 0 ? "ok" : "warning",
3166
+ detail: skills.length === 0 ? resolve(paths.home, ".agents", "skills") : `missing ${skills.join(", ")}; run lore connect --agent generic --agent-id ${agent.id} again`,
3167
+ },
3168
+ ]);
3169
+ }
2717
3170
  async function doctorCommand(args) {
2718
3171
  const parsed = parseOutputArguments(args, DOCTOR_HELP, "doctor");
2719
3172
  if (parsed === null) {
@@ -2723,7 +3176,15 @@ async function doctorCommand(args) {
2723
3176
  const config = await diagnosticConfig(paths, process.cwd());
2724
3177
  const prunedDeadLetters = await pruneUnrecoverableDeadLetters(config, paths);
2725
3178
  const status = await statusData(paths, process.cwd());
2726
- const checks = [{ name: "capture-folder", status: status.folderAccess.allowed ? "ok" : "warning", detail: status.folderAccess.allowed ? status.folderAccess.reason : `Recording is off here: ${status.folderAccess.reason}. Run lore scope check <folder> to verify a selected folder.` }];
3179
+ const checks = [
3180
+ { name: "capture-folder", status: status.folderAccess.allowed ? "ok" : "warning", detail: status.folderAccess.allowed ? status.folderAccess.reason : `${status.capture === "off" ? "Lore" : "Recording"} is off here: ${status.folderAccess.reason}. Run lore scope check <folder> to verify a selected folder.` },
3181
+ captureModeCheck(status.capture, status.captureSwitches),
3182
+ ];
3183
+ const genericChecks = await genericAgentChecks(paths);
3184
+ // With only MCP agents connected there are no hooks, so nothing records turns.
3185
+ if (genericChecks.length > 0 && !status.agents.some((agent) => agent.configured)) {
3186
+ checks[1] = { name: "capture-mode", status: "ok", detail: "off: only MCP agents are connected, and they have no hooks; Lore saves only what you ask them to" };
3187
+ }
2727
3188
  checks.push({
2728
3189
  name: "platform",
2729
3190
  status: platform() === "darwin" || platform() === "linux" || isWindows() ? "ok" : "error",
@@ -2805,6 +3266,7 @@ async function doctorCommand(args) {
2805
3266
  });
2806
3267
  }
2807
3268
  }
3269
+ checks.push(...genericChecks);
2808
3270
  for (const surface of status.externalSurfaces) {
2809
3271
  checks.push({
2810
3272
  name: `${surface.surface}-capability`,
@@ -2892,6 +3354,9 @@ async function printCommandHelp(path) {
2892
3354
  case "doctor":
2893
3355
  process.stdout.write(DOCTOR_HELP);
2894
3356
  return;
3357
+ case "capture":
3358
+ process.stdout.write(CAPTURE_HELP);
3359
+ return;
2895
3360
  case "embeddings":
2896
3361
  await runEmbeddingsCommand(args, null);
2897
3362
  return;
@@ -2907,9 +3372,15 @@ async function printCommandHelp(path) {
2907
3372
  case "ask":
2908
3373
  await runAskCommand(args, null);
2909
3374
  return;
3375
+ case "chat":
3376
+ await runChatCommand(args, null);
3377
+ return;
2910
3378
  case "learnings":
2911
3379
  await runLearningsCommand(args, null);
2912
3380
  return;
3381
+ case "review":
3382
+ await runReviewCommand(args, null);
3383
+ return;
2913
3384
  case "remember":
2914
3385
  await runRememberCommand(args, null);
2915
3386
  return;
@@ -2994,7 +3465,7 @@ async function executeCli(args = process.argv.slice(2)) {
2994
3465
  const helpIndex = args.findIndex((value, index) => index > 0 && (separator < 0 || index < separator) && (value === "--help" || value === "-h"));
2995
3466
  if (helpIndex > 0) {
2996
3467
  const subcommand = args[1];
2997
- const grouped = ["connect", "config", "embeddings", "guard", "recovery", "github", "devin", "host", "self-host", "scope"].includes(command ?? "");
3468
+ const grouped = ["connect", "config", "capture", "embeddings", "guard", "recovery", "github", "devin", "host", "self-host", "scope", "review"].includes(command ?? "");
2998
3469
  await printCommandHelp([command, ...(grouped && subcommand && !subcommand.startsWith("-") ? [subcommand] : [])]);
2999
3470
  return;
3000
3471
  }
@@ -3007,7 +3478,7 @@ async function executeCli(args = process.argv.slice(2)) {
3007
3478
  return;
3008
3479
  }
3009
3480
  const commandArgs = args.slice(1);
3010
- if (["ask", "learnings", "remember", "share", "resume", "continue", "approach", "skill"].includes(command) && !(command === "skill" && commandArgs[0] === "discover")) {
3481
+ if (["ask", "chat", "learnings", "review", "remember", "share", "resume", "continue", "approach", "skill"].includes(command) && !(command === "skill" && commandArgs[0] === "discover")) {
3011
3482
  const decision = await checkDirectory(process.cwd(), getLorePaths(["approach", "skill"].includes(command) ? process.env.LORE_HOME : undefined).home);
3012
3483
  if (!decision.allowed)
3013
3484
  throw new Error(`Lore is off in this folder. ${decision.reason}`);
@@ -3021,6 +3492,9 @@ async function executeCli(args = process.argv.slice(2)) {
3021
3492
  case "config":
3022
3493
  await runConfigCommand(commandArgs, getLorePaths().config);
3023
3494
  return;
3495
+ case "capture":
3496
+ await captureCommand(commandArgs);
3497
+ return;
3024
3498
  case "scope": {
3025
3499
  if (commandArgs[0] !== "check" || commandArgs.length > 2)
3026
3500
  throw new Error("Usage: lore scope check [folder]");
@@ -3047,9 +3521,15 @@ async function executeCli(args = process.argv.slice(2)) {
3047
3521
  case "ask":
3048
3522
  await runAskCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3049
3523
  return;
3524
+ case "chat":
3525
+ await runChatCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3526
+ return;
3050
3527
  case "learnings":
3051
3528
  await runLearningsCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3052
3529
  return;
3530
+ case "review":
3531
+ await runReviewCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3532
+ return;
3053
3533
  case "remember":
3054
3534
  await runRememberCommand(commandArgs, await readConnectorConfig(getLorePaths(), process.cwd()));
3055
3535
  return;
@@ -3164,6 +3644,10 @@ async function executeCli(args = process.argv.slice(2)) {
3164
3644
  case "cursor-mcp":
3165
3645
  await (await import("./cursor-mcp.js")).runCursorMcpServer();
3166
3646
  return;
3647
+ case "mcp":
3648
+ // The full Lore MCP server, registered by connect for hook-based agents.
3649
+ await (await import("@lore-co/mcp")).runStdioServer();
3650
+ return;
3167
3651
  case "internal-health-write": {
3168
3652
  const encoded = commandArgs[0] ?? (await readInvocationHealthWriteInput());
3169
3653
  await runInvocationHealthWrite(encoded);