brainclaw 0.21.0 → 0.22.1

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.
@@ -11,6 +11,7 @@ import { nowISO, generateId } from '../core/ids.js';
11
11
  import { appendAuditEntry } from '../core/audit.js';
12
12
  import { SessionSnapshotSchema } from '../core/schema.js';
13
13
  import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
14
+ import { buildAgentInventory, loadAgentInventory, saveAgentInventory, diffInventory } from '../core/agent-inventory.js';
14
15
  function sessionsDir(cwd) {
15
16
  return resolveEntityDir('sessions', cwd ?? process.cwd(), 'read');
16
17
  }
@@ -47,6 +48,11 @@ export function runSessionStart(options = {}) {
47
48
  console.warn(' After fixing .gitignore, untrack them with `git rm --cached <path>` as needed.');
48
49
  }
49
50
  }
51
+ if (snapshot.inventory_advisory) {
52
+ for (const line of snapshot.inventory_advisory) {
53
+ console.warn(`⚠ ${line}`);
54
+ }
55
+ }
50
56
  }
51
57
  catch (e) {
52
58
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -121,6 +127,25 @@ export function startSession(options = {}) {
121
127
  }, options.cwd);
122
128
  appendAuditEntry({ action: 'session_start', actor: actor.agent, actor_id: actor.agent_id, item_id: snapshot.session_id, item_type: 'session' }, options.cwd);
123
129
  const agentGitHygiene = auditLocalAgentWorkspaceFiles(options.cwd ?? process.cwd());
130
+ // Inventory reconciliation — detect new/disappeared agents on this machine
131
+ let inventoryAdvisory;
132
+ try {
133
+ const previousInventory = loadAgentInventory();
134
+ const currentInventory = buildAgentInventory();
135
+ const diff = diffInventory(previousInventory, currentInventory);
136
+ saveAgentInventory(currentInventory);
137
+ const lines = [];
138
+ if (diff.appeared.length > 0)
139
+ lines.push(`New agents detected: ${diff.appeared.join(', ')}`);
140
+ if (diff.disappeared.length > 0)
141
+ lines.push(`Agents no longer detected: ${diff.disappeared.join(', ')}`);
142
+ for (const vc of diff.version_changed) {
143
+ lines.push(`${vc.name} version changed: ${vc.from ?? '?'} → ${vc.to ?? '?'}`);
144
+ }
145
+ if (lines.length > 0)
146
+ inventoryAdvisory = lines;
147
+ }
148
+ catch { /* non-fatal — inventory scan failure should not block session start */ }
124
149
  return {
125
150
  ...snapshot,
126
151
  ...(agentGitHygiene.isGitRepo && (agentGitHygiene.missingGitignorePaths.length > 0 || agentGitHygiene.trackedPaths.length > 0)
@@ -131,6 +156,7 @@ export function startSession(options = {}) {
131
156
  },
132
157
  }
133
158
  : {}),
159
+ ...(inventoryAdvisory ? { inventory_advisory: inventoryAdvisory } : {}),
134
160
  };
135
161
  }
136
162
  export function loadSessionSnapshot(sessionId, cwd) {
@@ -398,4 +398,32 @@ export function renderAgentInventorySummary(inventory) {
398
398
  lines.push(`Inventory generated: ${inventory.generated_at}`);
399
399
  return lines.join('\n');
400
400
  }
401
+ /**
402
+ * Compare two agent inventories and return what changed.
403
+ * Only considers agents that are `installed` in either snapshot.
404
+ */
405
+ export function diffInventory(previous, current) {
406
+ const prevMap = new Map((previous?.agents ?? []).filter(a => a.installed).map(a => [a.name, a]));
407
+ const currMap = new Map(current.agents.filter(a => a.installed).map(a => [a.name, a]));
408
+ const appeared = [];
409
+ const disappeared = [];
410
+ const version_changed = [];
411
+ for (const [name, entry] of currMap) {
412
+ if (!prevMap.has(name)) {
413
+ appeared.push(name);
414
+ }
415
+ else {
416
+ const prev = prevMap.get(name);
417
+ if (prev.version !== entry.version && (prev.version || entry.version)) {
418
+ version_changed.push({ name, from: prev.version, to: entry.version });
419
+ }
420
+ }
421
+ }
422
+ for (const name of prevMap.keys()) {
423
+ if (!currMap.has(name)) {
424
+ disappeared.push(name);
425
+ }
426
+ }
427
+ return { appeared, disappeared, version_changed };
428
+ }
401
429
  //# sourceMappingURL=agent-inventory.js.map
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { detectAiAgent } from './ai-agent-detection.js';
5
6
  import { loadConfig, saveConfig } from './config.js';
6
7
  import { nowISO } from './ids.js';
7
8
  import { MEMORY_DIR, resolveEntityDir } from './io.js';
@@ -205,6 +206,15 @@ export function resolveCurrentAgentIdentity(cwd, preferredDirName) {
205
206
  if (byEnvName)
206
207
  return byEnvName;
207
208
  }
209
+ // Auto-detect from native agent env vars (e.g. CLAUDECODE, CURSOR_TRACE_ID).
210
+ // Falls through to config if the detected agent is not registered.
211
+ const detected = detectAiAgent();
212
+ if (detected) {
213
+ const byDetected = findAgentIdentityByName(detected.name, cwd, preferredDirName);
214
+ if (byDetected)
215
+ return byDetected;
216
+ }
217
+ // Config fallback (last resort — may not reflect the actual calling agent)
208
218
  const config = loadConfig(cwd, preferredDirName);
209
219
  if (config.current_agent_id) {
210
220
  const byId = findAgentIdentityById(config.current_agent_id, cwd, preferredDirName);
@@ -32,13 +32,17 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
32
32
  };
33
33
  }
34
34
  // Claude Code — tested BEFORE Copilot because both can be present in VS Code.
35
- // CLAUDE_CODE_VERSION is set by Claude Code itself, not by VS Code passively.
36
- if (env.CLAUDE_CODE_VERSION || env.ANTHROPIC_AI_PRODUCT === 'claude-code') {
35
+ // CLAUDE_CODE_VERSION is set by Claude Code CLI; CLAUDECODE is set by the VS Code extension.
36
+ if (env.CLAUDE_CODE_VERSION || env.CLAUDECODE || env.ANTHROPIC_AI_PRODUCT === 'claude-code') {
37
37
  return {
38
38
  name: 'claude-code',
39
39
  kind: 'agent',
40
40
  trust_level: 'trusted',
41
- detection_source: env.CLAUDE_CODE_VERSION ? 'CLAUDE_CODE_VERSION env var' : 'ANTHROPIC_AI_PRODUCT env var',
41
+ detection_source: env.CLAUDE_CODE_VERSION
42
+ ? 'CLAUDE_CODE_VERSION env var'
43
+ : env.CLAUDECODE
44
+ ? 'CLAUDECODE env var'
45
+ : 'ANTHROPIC_AI_PRODUCT env var',
42
46
  };
43
47
  }
44
48
  // Cursor IDE
@@ -73,7 +77,7 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
73
77
  // whenever the Copilot extension is installed, even when another agent is active.
74
78
  // We only match if no higher-priority agent was detected above.
75
79
  if (env.GITHUB_COPILOT_PRODUCT ||
76
- (env.GITHUB_COPILOT_TOKEN && !env.CLAUDE_CODE_VERSION && !env.CURSOR_TRACE_ID && !env.WINDSURF_SESSION_ID) ||
80
+ (env.GITHUB_COPILOT_TOKEN && !env.CLAUDE_CODE_VERSION && !env.CLAUDECODE && !env.CURSOR_TRACE_ID && !env.WINDSURF_SESSION_ID) ||
77
81
  (env.VSCODE_GIT_IPC_HANDLE && (env.AGENT_NAME?.toLowerCase().includes('copilot') || env.GH_COPILOT_AGENT))) {
78
82
  return {
79
83
  name: 'github-copilot',
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import YAML from 'yaml';
3
4
  import { MEMORY_DIR } from './io.js';
4
5
  const MULTI_PROJECT_MARKERS = [
5
6
  'pnpm-workspace.yaml',
@@ -11,6 +12,32 @@ const MULTI_PROJECT_MARKERS = [
11
12
  const MULTI_PROJECT_DIRS = ['apps', 'packages', 'services'];
12
13
  export function analyzeRepository(cwd) {
13
14
  const reasons = [];
15
+ // ── Signal 1: Existing brainclaw config already declares multi-project ──
16
+ const configPath = path.join(cwd, MEMORY_DIR, 'config.yaml');
17
+ if (fs.existsSync(configPath)) {
18
+ try {
19
+ const raw = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
20
+ if (raw) {
21
+ const mode = raw.project_mode;
22
+ const projects = raw.projects;
23
+ const strategy = projects?.strategy ?? 'manual';
24
+ const knownCount = Array.isArray(projects?.known) ? projects.known.length : 0;
25
+ if (mode === 'multi-project' || strategy === 'folder' || knownCount > 0) {
26
+ reasons.push(`Existing brainclaw config: project_mode=${mode ?? 'auto'}, strategy=${strategy}` +
27
+ (knownCount > 0 ? `, ${knownCount} known project(s)` : ''));
28
+ }
29
+ }
30
+ }
31
+ catch {
32
+ // Config unreadable — fall through to heuristic detection.
33
+ }
34
+ }
35
+ // ── Signal 2: Child brainclaw stores (subdirectories with .brainclaw/) ──
36
+ const scan = scanChildStoresShallow(cwd);
37
+ if (scan.length > 0) {
38
+ reasons.push(`Found ${scan.length} child brainclaw store(s): ${scan.join(', ')}`);
39
+ }
40
+ // ── Signal 3: Classic monorepo / workspace markers ──
14
41
  for (const marker of MULTI_PROJECT_MARKERS) {
15
42
  if (fs.existsSync(path.join(cwd, marker))) {
16
43
  reasons.push(`Found workspace marker: ${marker}`);
@@ -46,6 +73,33 @@ export function analyzeRepository(cwd) {
46
73
  reasons: ['No monorepo or multi-project markers detected'],
47
74
  };
48
75
  }
76
+ /**
77
+ * Quick depth-1 scan for subdirectories that contain a .brainclaw/ store.
78
+ * Returns relative paths of child stores found.
79
+ */
80
+ function scanChildStoresShallow(cwd) {
81
+ const childStores = [];
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(cwd, { withFileTypes: true });
85
+ }
86
+ catch {
87
+ return childStores;
88
+ }
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory())
91
+ continue;
92
+ if (SKIP_DIRS.has(entry.name))
93
+ continue;
94
+ if (entry.name.startsWith('.'))
95
+ continue;
96
+ const childBrainclaw = path.join(cwd, entry.name, MEMORY_DIR);
97
+ if (fs.existsSync(childBrainclaw)) {
98
+ childStores.push(entry.name);
99
+ }
100
+ }
101
+ return childStores;
102
+ }
49
103
  /** Markers whose presence indicates a service/project boundary worth initialising. */
50
104
  const SERVICE_MARKERS = [
51
105
  'package.json',
@@ -104,6 +104,13 @@ export function resolveContextStoreCwd(cwd = process.cwd(), target) {
104
104
  if (!absoluteTarget) {
105
105
  return cwd;
106
106
  }
107
+ // ── Fast path: walk from target upward to cwd looking for a child store ──
108
+ // This works regardless of project_mode or strategy configuration.
109
+ const childStore = findClosestStoreBelow(absoluteTarget, primary.cwd);
110
+ if (childStore) {
111
+ return childStore;
112
+ }
113
+ // ── Fallback: use workspace project discovery (folder mode, registry, etc.) ──
107
114
  let config;
108
115
  try {
109
116
  config = loadConfig(primary.cwd);
@@ -127,6 +134,37 @@ export function resolveContextStoreCwd(cwd = process.cwd(), target) {
127
134
  }
128
135
  return cwd;
129
136
  }
137
+ /**
138
+ * Walk from `target` upward toward `ceiling` (exclusive), returning the first
139
+ * directory that contains a `.brainclaw/config.yaml`. Returns undefined when
140
+ * no child store is found between target and ceiling.
141
+ *
142
+ * This deliberately bypasses workspace project discovery so that child stores
143
+ * are resolved even when the parent config is set to auto/manual mode.
144
+ */
145
+ function findClosestStoreBelow(target, ceiling) {
146
+ const resolvedCeiling = path.resolve(ceiling);
147
+ // If target is a file, start from its parent directory
148
+ let current;
149
+ try {
150
+ current = fs.statSync(target).isDirectory() ? path.resolve(target) : path.resolve(path.dirname(target));
151
+ }
152
+ catch {
153
+ // Target doesn't exist on disk — try its parent as a directory
154
+ current = path.resolve(path.dirname(target));
155
+ }
156
+ while (current !== resolvedCeiling) {
157
+ const configPath = path.join(current, MEMORY_DIR, 'config.yaml');
158
+ if (fs.existsSync(configPath)) {
159
+ return current;
160
+ }
161
+ const parent = path.dirname(current);
162
+ if (parent === current)
163
+ break; // filesystem root
164
+ current = parent;
165
+ }
166
+ return undefined;
167
+ }
130
168
  /**
131
169
  * Return true if `dir` is at or below `ancestor` in the filesystem hierarchy.
132
170
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {