total-recall-brain 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/antigravity.mjs +62 -183
  2. package/package.json +6 -5
  3. package/scaffold/.agent/skills/total-recall/SKILL.md +1 -0
  4. package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/SKILL.md +3 -2
  5. package/scaffold/.agent/skills/total-recall/skills/{research → tr-research}/SKILL.md +2 -1
  6. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/SKILL.md +3 -2
  7. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/evals/evals.json +3 -3
  8. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/hooks/on-skill-add.sh +1 -1
  9. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/hooks/on-skill-delete.sh +1 -1
  10. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/create-skill.sh +3 -3
  11. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/find-skills.mjs +25 -1
  12. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/install-skill.mjs +117 -0
  13. package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/scan-skill.mjs +20 -0
  14. package/scaffold/.agent/skills/total-recall/skills/{ssss → tr-ssss}/SKILL.md +120 -19
  15. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +124 -0
  16. package/scaffold/.agent/skills/total-recall/skills/{ssss → tr-ssss}/references/ssss-spec.md +474 -43
  17. package/src/cli/connect.mjs +6 -59
  18. package/src/cli/ingest-openwiki.mjs +122 -0
  19. package/src/cli/ingest.mjs +9 -0
  20. package/src/cli/init.mjs +42 -2
  21. package/src/cli/skill-projection.mjs +214 -0
  22. package/src/cli/skill.mjs +44 -18
  23. package/src/core/daemon-loop.mjs +13 -2
  24. package/src/core/operation-validator.mjs +85 -9
  25. package/src/core/research.mjs +47 -9
  26. package/src/core/runtime.mjs +14 -7
  27. package/src/core/schema.mjs +85 -13
  28. package/src/server/api.mjs +2 -2
  29. package/src/server/rest.mjs +3 -3
  30. package/src/server/routes/skills.mjs +25 -4
  31. package/scaffold/.agent/skills/total-recall/skills/ssss/references/CHANGELOG.md +0 -9
  32. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/agents.yml +0 -0
  33. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/evals/.gitkeep +0 -0
  34. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/references/antigravity.txt +0 -0
  35. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/references/claude.txt +0 -0
  36. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/references/codex.txt +0 -0
  37. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/references/gemini.txt +0 -0
  38. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/scripts/.gitkeep +0 -0
  39. /package/scaffold/.agent/skills/total-recall/skills/{cli-agents → tr-cli-agents}/subagents/.gitkeep +0 -0
  40. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/assets/skill-template/SKILL.md +0 -0
  41. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/learnings/.gitkeep +0 -0
  42. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/references/.gitkeep +0 -0
  43. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/.gitkeep +0 -0
  44. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/enforce-skill-optimization.mjs +0 -0
  45. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/scripts/skill-watcher.mjs +0 -0
  46. /package/scaffold/.agent/skills/total-recall/skills/{skill → tr-skill}/subagents/audit-all-skills.md +0 -0
  47. /package/scaffold/.agent/skills/total-recall/skills/{ssss → tr-ssss}/evals/evals.json +0 -0
  48. /package/scaffold/.agent/skills/total-recall/skills/{ssss → tr-ssss}/scripts/validate-schema.mjs +0 -0
  49. /package/scaffold/.agent/skills/total-recall/skills/{ssss → tr-ssss}/subagents/audit-ssss-node.md +0 -0
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { agentDir as configAgentDir, brainDir as configBrainDir, xdgConfigHome, trBrain, trPat } from '../core/config.mjs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { resolveAgentDir, getBothBrains, detectProjectBrain } from './agent-dir.mjs';
7
+ import { discoverRepoSkills, projectSkillsAsCommands } from './skill-projection.mjs';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  // Resolve templates dir relative to this file (src/cli/ → ../../templates/)
@@ -78,7 +79,11 @@ const CLIENTS = {
78
79
  label: 'Gemini',
79
80
  mode: 'symlink',
80
81
  target: '.agents/rules/GEMINI.md',
81
- writeSlashCommands: true
82
+ writeSlashCommands: true,
83
+ // Gemini CLI reads project workspace skills from <project>/.agents/skills/
84
+ // (the same Agent Skills location Antigravity uses) and turns each into a
85
+ // /name slash command.
86
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
82
87
  },
83
88
  aider: {
84
89
  label: 'Aider',
@@ -628,64 +633,6 @@ Instructions:
628
633
  }
629
634
 
630
635
 
631
- /**
632
- * Discover repo skills to expose as slash commands.
633
- * Every <agentDir>/skills/<name>/ directory containing a SKILL.md is a skill.
634
- * The SKILL.md (Agent Skills standard) is what each IDE turns into /<name>.
635
- */
636
- function discoverRepoSkills(agentDir) {
637
- const skillsRoot = path.join(agentDir, 'skills');
638
- if (!fs.existsSync(skillsRoot)) return [];
639
- let entries;
640
- try {
641
- entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
642
- } catch {
643
- return [];
644
- }
645
- const skills = [];
646
- for (const entry of entries) {
647
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
648
- const skillDir = path.join(skillsRoot, entry.name);
649
- if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) continue;
650
- skills.push({ name: entry.name, skillDir });
651
- }
652
- return skills;
653
- }
654
-
655
- /**
656
- * Symlink each discovered skill dir into an IDE's Agent-Skills directory.
657
- * Idempotent and self-healing: a symlink that is broken or points at a stale
658
- * location (e.g. after the repo moved) is refreshed even without --force; a
659
- * real (non-symlink) entry is only replaced with --force.
660
- */
661
- function projectSkillsAsCommands(destDir, skills, opts) {
662
- fs.mkdirSync(destDir, { recursive: true });
663
- const written = [];
664
- for (const skill of skills) {
665
- const linkPath = path.join(destDir, skill.name);
666
- const wantTarget = path.resolve(skill.skillDir);
667
- if (pathExists(linkPath)) {
668
- const stat = fs.lstatSync(linkPath);
669
- if (stat.isSymbolicLink()) {
670
- const current = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
671
- if (current === wantTarget && fs.existsSync(linkPath) && !opts.force) {
672
- written.push({ name: skill.name, action: 'exists' });
673
- continue;
674
- }
675
- fs.unlinkSync(linkPath); // stale, broken, or --force → refresh
676
- } else if (!opts.force) {
677
- written.push({ name: skill.name, action: 'skipped' }); // real file/dir in the way
678
- continue;
679
- } else {
680
- fs.rmSync(linkPath, { recursive: true, force: true });
681
- }
682
- }
683
- fs.symlinkSync(skill.skillDir, linkPath);
684
- written.push({ name: skill.name, action: 'linked' });
685
- }
686
- return written;
687
- }
688
-
689
636
  export default async function connect(args) {
690
637
  const opts = parseArgs(args);
691
638
  if (opts.help || !opts.client) {
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ function printHelp() {
6
+ console.log(`
7
+ Usage:
8
+ total-recall ingest openwiki <openwiki-path> [options]
9
+
10
+ Options:
11
+ --dry-run Preview import without writing
12
+ --global Target the global brain
13
+ --project Target the project brain (default)
14
+ --help, -h Show this help
15
+ `);
16
+ }
17
+
18
+ export async function runOpenWikiIngest(args) {
19
+ let openwikiPath = null;
20
+ let dryRun = false;
21
+ let layerFlag = '--project';
22
+ let help = false;
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i];
26
+ switch (arg) {
27
+ case '--dry-run': dryRun = true; break;
28
+ case '--global': layerFlag = '--global'; break;
29
+ case '--project': layerFlag = '--project'; break;
30
+ case '--help': case '-h': help = true; break;
31
+ default:
32
+ if (!arg.startsWith('-') && !openwikiPath) {
33
+ openwikiPath = arg;
34
+ }
35
+ }
36
+ }
37
+
38
+ if (help || !openwikiPath) {
39
+ printHelp();
40
+ return;
41
+ }
42
+
43
+ if (!fs.existsSync(openwikiPath)) {
44
+ console.error(`❌ Error: OpenWiki directory not found at ${openwikiPath}`);
45
+ process.exit(1);
46
+ }
47
+
48
+ console.log(`\n 📥 Importing LangChain OpenWiki from: ${openwikiPath}`);
49
+ console.log(` 🧠 Target Brain: ${layerFlag.replace('--', '')}`);
50
+ if (dryRun) console.log(' ⚠️ DRY RUN: No actual writes will be committed.\n');
51
+
52
+ // Find all markdown files recursively
53
+ const mdFiles = [];
54
+ function scanDir(dir) {
55
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ const fullPath = path.join(dir, entry.name);
58
+ if (entry.isDirectory()) {
59
+ scanDir(fullPath);
60
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
61
+ mdFiles.push(fullPath);
62
+ }
63
+ }
64
+ }
65
+
66
+ scanDir(openwikiPath);
67
+
68
+ let imported = 0;
69
+ let errors = 0;
70
+
71
+ for (const file of mdFiles) {
72
+ try {
73
+ const content = fs.readFileSync(file, 'utf8');
74
+
75
+ // Try to extract title from the first heading, else use filename
76
+ let title = path.basename(file, '.md').replace(/[-_]/g, ' ');
77
+ const titleMatch = content.match(/^#\\s+(.+)$/m);
78
+ if (titleMatch) {
79
+ title = titleMatch[1].trim();
80
+ }
81
+
82
+ const slug = 'openwiki-' + path.basename(file, '.md').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
83
+
84
+ if (dryRun) {
85
+ console.log(` 🟢 [DRY RUN] Would import: ${slug} ("${title}")`);
86
+ imported++;
87
+ continue;
88
+ }
89
+
90
+ const cliArgs = [
91
+ process.argv[1],
92
+ 'remember', 'fact',
93
+ content,
94
+ '--title', title,
95
+ '--slug', slug,
96
+ '--tags', 'openwiki,architecture,auto-generated',
97
+ layerFlag
98
+ ];
99
+
100
+ const result = spawnSync(process.argv[0], cliArgs, { encoding: 'utf8' });
101
+
102
+ if (result.status === 0) {
103
+ console.log(` 🟢 Imported: ${slug}`);
104
+ imported++;
105
+ } else {
106
+ console.error(` 🔴 Error importing ${slug}:\\n${result.stderr}`);
107
+ errors++;
108
+ }
109
+ } catch (err) {
110
+ console.error(` 🔴 Exception on ${file}: ${err.message}`);
111
+ errors++;
112
+ }
113
+ }
114
+
115
+ console.log(`\n Import Summary:`);
116
+ console.log(` 🟢 Imported: ${imported} files`);
117
+ console.log(` 🔴 Errors: ${errors} files`);
118
+
119
+ if (!dryRun && imported > 0) {
120
+ console.log(`\n ✅ OpenWiki successfully integrated into Total Recall.`);
121
+ }
122
+ }
@@ -40,6 +40,8 @@ function printHelp() {
40
40
  Use 'total-recall ingest google-takeout --help' for details
41
41
  okf <path> Ingest an Open Knowledge Format (OKF) bundle directory
42
42
  Use 'total-recall ingest okf --help' for details
43
+ openwiki <path> Ingest a LangChain OpenWiki directory
44
+ Use 'total-recall ingest openwiki --help' for details
43
45
 
44
46
  IDE Session Ingest:
45
47
  Scans known local directories for conversation history from:
@@ -55,6 +57,7 @@ function printHelp() {
55
57
  total-recall ingest [options]
56
58
  total-recall ingest google-takeout <path> [options]
57
59
  total-recall ingest okf <path> [options]
60
+ total-recall ingest openwiki <path> [options]
58
61
 
59
62
  Options:
60
63
  --sources <list> Comma-separated source filter (e.g. claude-code,codex)
@@ -77,6 +80,12 @@ export default async function ingest(args) {
77
80
  return;
78
81
  }
79
82
 
83
+ if (args[0] === 'openwiki') {
84
+ const { runOpenWikiIngest } = await import('./ingest-openwiki.mjs');
85
+ await runOpenWikiIngest(args.slice(1));
86
+ return;
87
+ }
88
+
80
89
  const opts = parseArgs(args);
81
90
  if (opts.help) { printHelp(); return; }
82
91
 
package/src/cli/init.mjs CHANGED
@@ -30,6 +30,7 @@ import path from 'node:path';
30
30
  import os from 'node:os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { exec, spawn, spawnSync } from 'node:child_process';
33
+ import { projectSkillsForScope, detectActiveSkillTargets } from './skill-projection.mjs';
33
34
 
34
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
36
  const ROOT = path.join(__dirname, '..', '..');
@@ -83,7 +84,8 @@ function parseArgs(args) {
83
84
  domain: null,
84
85
  tunnelName: null,
85
86
  tunnelCredentials: null,
86
- yes: false
87
+ yes: false,
88
+ force: false
87
89
  };
88
90
  for (let i = 0; i < args.length; i++) {
89
91
  const arg = args[i];
@@ -97,6 +99,7 @@ function parseArgs(args) {
97
99
  else if (arg === '--tunnel-name') opts.tunnelName = args[++i];
98
100
  else if (arg === '--tunnel-credentials') opts.tunnelCredentials = args[++i];
99
101
  else if (arg === '--yes' || arg === '-y') opts.yes = true;
102
+ else if (arg === '--force') opts.force = true;
100
103
  }
101
104
  return opts;
102
105
  }
@@ -474,7 +477,7 @@ export default async function init(args) {
474
477
  }
475
478
 
476
479
  // ── Symlink nested brain skills to top-level .agent/skills/ ──
477
- // Skills inside the brain (e.g. .agent/skills/total-recall/skills/ssss/) are
480
+ // Skills inside the brain (e.g. .agent/skills/total-recall/skills/tr-ssss/) are
478
481
  // invisible to IDEs because they only scan .agent/skills/<name>/ one level
479
482
  // deep. Create symlinks so they appear as slash commands.
480
483
  const nestedSkillsDir = path.join(brainDir, 'skills');
@@ -498,6 +501,43 @@ export default async function init(args) {
498
501
  }
499
502
  }
500
503
 
504
+ // ── Step 3.7: Project skills as slash commands into the IDEs in use ──
505
+ // Scope matches the brain: `init --project` projects PROJECT skills into the
506
+ // repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
507
+ // the current process); `init` projects GLOBAL skills into installed IDEs'
508
+ // global skill dirs. Codex (global-only) is opt-in for project skills.
509
+ const skillScope = isProject ? 'project' : 'global';
510
+ logStep('3.7/4', `Projecting ${skillScope} skills as slash commands (IDEs in use)`);
511
+ if (opts.dryRun) {
512
+ const targets = detectActiveSkillTargets({ scope: skillScope, cwd });
513
+ for (const t of targets.filter(t => t.supported)) {
514
+ const destLabel = t.destDir ? path.relative(cwd, t.destDir) || t.destDir : '(n/a)';
515
+ log(` ${t.active ? 'would wire' : 'available (opt-in)'}: ${t.label} → ${destLabel}/`);
516
+ }
517
+ } else {
518
+ try {
519
+ const { skills, wired, available } = projectSkillsForScope({
520
+ scope: skillScope, cwd, agentDir, opts: { force: opts.force }
521
+ });
522
+ if (skills.length === 0) {
523
+ log(' No skills found yet — author one under .agent/skills/<name>/SKILL.md');
524
+ } else if (wired.length === 0) {
525
+ log(` No in-use IDE detected for ${skillScope} skills — run \`npx total-recall connect <ide>\` to wire them.`);
526
+ } else {
527
+ for (const t of wired) {
528
+ const n = t.results.filter(r => ['linked', 'exists', 'source'].includes(r.action)).length;
529
+ logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
530
+ }
531
+ log(` Slash commands: ${skills.map(s => '/' + s.name).join(', ')}`);
532
+ if (available.length > 0) {
533
+ log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
534
+ }
535
+ }
536
+ } catch (err) {
537
+ logWarn(`Could not project skills into IDEs: ${err.message}`);
538
+ }
539
+ }
540
+
501
541
  // ── Step 4: Run compile to inject memory block into existing IDE files ──
502
542
  logStep('4/4', 'Compiling vault and injecting into IDE instruction files');
503
543
 
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Scope-aware skill → slash-command projection.
3
+ *
4
+ * A "repo skill" is any <skills-root>/<name>/SKILL.md directory (the Agent
5
+ * Skills standard). Each IDE turns such a directory into a /<name> slash
6
+ * command, but every IDE looks in a *different* place, and the right place
7
+ * depends on the *scope* of the skill:
8
+ *
9
+ * PROJECT skills (authored in <repo>/.agent/skills or <repo>/.agents/skills)
10
+ * → project into the *repo's* IDE skill dirs, and only for IDEs actually
11
+ * in use in this repo (project marker dir present, or current process is
12
+ * that IDE). They belong to this repo, not the whole machine.
13
+ *
14
+ * GLOBAL skills (authored in ~/.agent/skills)
15
+ * → project into each IDE's *global* skill dir, for IDEs installed on this
16
+ * machine. They are available in every repo.
17
+ *
18
+ * This mirrors the brain model: `init --project` ⇒ project skills; `init`
19
+ * (global) ⇒ global skills. Codex has no project-local skills dir (it only
20
+ * reads ~/.codex/skills globally), so it participates in GLOBAL projection only;
21
+ * pushing project skills to Codex is opt-in via `connect codex`.
22
+ */
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ /**
28
+ * IDE skill descriptors. Each IDE declares how it is detected and where its
29
+ * skills live, per scope:
30
+ * project: { dir, markers, env } — repo-relative dest; detected by repo
31
+ * marker dirs or current-process env vars. null ⇒ no project skills.
32
+ * global: { dir, markers } — home-relative dest; detected by home
33
+ * marker dirs (i.e. the IDE is installed). null ⇒ no global skills.
34
+ */
35
+ export const IDE_SKILLS = [
36
+ {
37
+ id: 'claude-code',
38
+ label: 'Claude Code',
39
+ clients: ['claude-code'],
40
+ project: { dir: ['.claude', 'skills'], markers: ['.claude'], env: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'] },
41
+ global: { dir: ['.claude', 'skills'], markers: ['.claude'] }
42
+ },
43
+ {
44
+ id: 'agents',
45
+ label: 'Antigravity / Gemini',
46
+ clients: ['antigravity', 'gemini'],
47
+ // Antigravity & Gemini both read <repo>/.agents/skills as the workspace
48
+ // Agent-Skills location. Their *global* surface is ~/.gemini/commands
49
+ // (markdown commands), wired by the slash-command writer, not here.
50
+ project: { dir: ['.agents', 'skills'], markers: ['.agents', '.gemini'], env: ['ANTIGRAVITY', 'GEMINI_CLI'] },
51
+ global: null
52
+ },
53
+ {
54
+ id: 'codex',
55
+ label: 'Codex',
56
+ clients: ['codex'],
57
+ // Codex only discovers skills globally under ~/.codex/skills. There is no
58
+ // project-local dir, so project skills are opt-in (namespaced) via connect.
59
+ project: null,
60
+ global: { dir: ['.codex', 'skills'], markers: ['.codex'] }
61
+ }
62
+ ];
63
+
64
+ function pathExists(filePath) {
65
+ try {
66
+ fs.lstatSync(filePath);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Skill source roots, in priority order. The canonical Total Recall location
75
+ * (.agent/skills, singular) wins, but we also scan the Antigravity-native
76
+ * .agents/skills (plural) so a skill authored in either place is discovered.
77
+ */
78
+ export function skillSourceRoots(agentDir) {
79
+ const roots = [path.join(agentDir, 'skills')];
80
+ const parent = path.dirname(agentDir);
81
+ if (path.basename(agentDir) === '.agent') {
82
+ roots.push(path.join(parent, '.agents', 'skills'));
83
+ }
84
+ return roots;
85
+ }
86
+
87
+ /**
88
+ * Discover repo skills to expose as slash commands.
89
+ * Every <root>/<name>/ directory containing a SKILL.md is a skill. When the
90
+ * same name appears in more than one source root, the canonical root wins.
91
+ */
92
+ export function discoverRepoSkills(agentDir) {
93
+ const seen = new Map();
94
+ for (const skillsRoot of skillSourceRoots(agentDir)) {
95
+ if (!fs.existsSync(skillsRoot)) continue;
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
99
+ } catch {
100
+ continue;
101
+ }
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
104
+ if (seen.has(entry.name)) continue;
105
+ const skillDir = path.join(skillsRoot, entry.name);
106
+ if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) continue;
107
+ seen.set(entry.name, { name: entry.name, skillDir });
108
+ }
109
+ }
110
+ return [...seen.values()];
111
+ }
112
+
113
+ /**
114
+ * Symlink each discovered skill dir into one IDE's Agent-Skills directory.
115
+ * Idempotent, self-healing, and strictly additive: a symlink that is broken or
116
+ * stale is refreshed even without --force; a *real* (non-symlink) entry is
117
+ * never clobbered unless --force (so a user's own skill always wins a name
118
+ * collision); a skill whose source already *is* the destination is left alone.
119
+ */
120
+ export function projectSkillsAsCommands(destDir, skills, opts = {}) {
121
+ fs.mkdirSync(destDir, { recursive: true });
122
+ const written = [];
123
+ for (const skill of skills) {
124
+ const linkPath = path.join(destDir, skill.name);
125
+ const wantTarget = path.resolve(skill.skillDir);
126
+
127
+ if (path.resolve(linkPath) === wantTarget) {
128
+ written.push({ name: skill.name, action: 'source' });
129
+ continue;
130
+ }
131
+
132
+ if (pathExists(linkPath)) {
133
+ const stat = fs.lstatSync(linkPath);
134
+ if (stat.isSymbolicLink()) {
135
+ const current = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
136
+ if (current === wantTarget && fs.existsSync(linkPath) && !opts.force) {
137
+ written.push({ name: skill.name, action: 'exists' });
138
+ continue;
139
+ }
140
+ fs.unlinkSync(linkPath); // stale, broken, or --force → refresh
141
+ } else if (!opts.force) {
142
+ written.push({ name: skill.name, action: 'skipped' }); // user's real file/dir wins
143
+ continue;
144
+ } else {
145
+ fs.rmSync(linkPath, { recursive: true, force: true });
146
+ }
147
+ }
148
+ fs.symlinkSync(skill.skillDir, linkPath);
149
+ written.push({ name: skill.name, action: 'linked' });
150
+ }
151
+ return written;
152
+ }
153
+
154
+ /**
155
+ * Is an IDE target active for the given scope?
156
+ * project → repo has a marker dir, or the current process is this IDE (env)
157
+ * global → the IDE is installed (home marker dir present)
158
+ */
159
+ function isTargetActive(target, scope, cwd, env) {
160
+ const spec = target[scope];
161
+ if (!spec) return false;
162
+ if (scope === 'project') {
163
+ if ((spec.markers || []).some(m => pathExists(path.join(cwd, m)))) return true;
164
+ if ((spec.env || []).some(v => env[v] != null && env[v] !== '')) return true;
165
+ return false;
166
+ }
167
+ // global
168
+ return (spec.markers || []).some(m => pathExists(path.join(os.homedir(), m)));
169
+ }
170
+
171
+ /**
172
+ * Resolve which IDE targets are active (and where they project to) for a scope.
173
+ * Returns every target annotated with { active, destDir } so callers can both
174
+ * wire the active ones and advertise the inactive ones as opt-in.
175
+ */
176
+ export function detectActiveSkillTargets({ scope, cwd = process.cwd(), env = process.env }) {
177
+ return IDE_SKILLS.map(target => {
178
+ const spec = target[scope];
179
+ const active = isTargetActive(target, scope, cwd, env);
180
+ let destDir = null;
181
+ if (spec) {
182
+ destDir = scope === 'global'
183
+ ? path.join(os.homedir(), ...spec.dir)
184
+ : path.join(cwd, ...spec.dir);
185
+ }
186
+ return { id: target.id, label: target.label, clients: target.clients, scope, supported: !!spec, active, destDir };
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Project discovered skills into every *active* IDE skill dir for one scope.
192
+ * Used by `init`: project skills on `init --project`, global skills on `init`.
193
+ *
194
+ * @returns {{ scope, skills, wired: Array, available: Array }}
195
+ * wired — targets we projected into (active + supported)
196
+ * available — supported-but-inactive targets, surfaced as opt-in `connect`
197
+ */
198
+ export function projectSkillsForScope({ scope, cwd = process.cwd(), agentDir, opts = {}, env = process.env }) {
199
+ const skills = discoverRepoSkills(agentDir);
200
+ const targets = detectActiveSkillTargets({ scope, cwd, env });
201
+ const wired = [];
202
+ const available = [];
203
+
204
+ for (const target of targets) {
205
+ if (!target.supported) continue;
206
+ if (target.active && skills.length > 0) {
207
+ const results = projectSkillsAsCommands(target.destDir, skills, opts);
208
+ wired.push({ ...target, results });
209
+ } else if (!target.active) {
210
+ available.push(target);
211
+ }
212
+ }
213
+ return { scope, skills, wired, available };
214
+ }
package/src/cli/skill.mjs CHANGED
@@ -1,16 +1,30 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { resolveAgentDir, parseLayerFlag } from './agent-dir.mjs';
5
6
 
6
7
  // Helper to resolve agent directories dynamically
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const ROOT = path.resolve(__dirname, '../../');
9
- const agentDir = path.join(ROOT, '.agent');
10
- const skillsDir = path.join(agentDir, 'skills');
11
10
 
12
- // Direct dynamic imports for scripts layer to preserve environment paths
13
- const scriptsPath = path.join(agentDir, 'skills', 'total-recall', 'skills', 'skill', 'scripts');
11
+ export function resolveSkillScriptPath(scriptName, { agentDir = resolveAgentDir(), root = ROOT } = {}) {
12
+ const candidates = [
13
+ path.join(agentDir, 'skills', 'total-recall', 'skills', 'tr-skill', 'scripts', scriptName),
14
+ path.join(agentDir, 'skills', 'total-recall', 'skills', 'skill', 'scripts', scriptName),
15
+ path.join(root, 'scaffold', '.agent', 'skills', 'total-recall', 'skills', 'tr-skill', 'scripts', scriptName),
16
+ path.join(root, 'scaffold', '.agent', 'skills', 'total-recall', 'skills', 'skill', 'scripts', scriptName),
17
+ ];
18
+ const found = candidates.find(candidate => fs.existsSync(candidate));
19
+ if (!found) {
20
+ throw new Error(`Could not find bundled skill helper "${scriptName}". Checked: ${candidates.join(', ')}`);
21
+ }
22
+ return found;
23
+ }
24
+
25
+ async function importSkillScript(scriptName, options) {
26
+ return import(pathToFileURL(resolveSkillScriptPath(scriptName, options)).href);
27
+ }
14
28
 
15
29
  function printHelp() {
16
30
  console.log(`
@@ -28,6 +42,8 @@ function printHelp() {
28
42
  remove <name> Safely delete a skill package and hot-recompile shims (alias: rm)
29
43
 
30
44
  Options:
45
+ --global Target the global brain (~/.agent)
46
+ --project Target the current repo's project brain
31
47
  --help, -h Show this help menu
32
48
 
33
49
  Examples:
@@ -40,15 +56,24 @@ function printHelp() {
40
56
  }
41
57
 
42
58
  export default async function skillCli(args) {
43
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
59
+ const { layer, remainingArgs } = parseLayerFlag(args);
60
+ if (remainingArgs.length === 0 || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
44
61
  printHelp();
45
62
  return;
46
63
  }
47
64
 
48
- const command = args[0];
65
+ let agentDir;
66
+ try {
67
+ agentDir = resolveAgentDir(layer);
68
+ } catch (err) {
69
+ console.error(`❌ ${err.message}`);
70
+ process.exit(1);
71
+ }
72
+ const skillsDir = path.join(agentDir, 'skills');
73
+ const command = remainingArgs[0];
49
74
 
50
75
  if (command === 'find') {
51
- const query = args[1];
76
+ const query = remainingArgs[1];
52
77
  if (!query) {
53
78
  console.error('❌ You must provide a search query.');
54
79
  console.error('Usage: npx total-recall skill find <query>');
@@ -56,7 +81,7 @@ export default async function skillCli(args) {
56
81
  }
57
82
 
58
83
  try {
59
- const { searchAndSort } = await import('../../.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs');
84
+ const { searchAndSort } = await importSkillScript('find-skills.mjs', { agentDir });
60
85
  const results = searchAndSort(query);
61
86
 
62
87
  if (results.length === 0) {
@@ -100,7 +125,7 @@ export default async function skillCli(args) {
100
125
  }
101
126
 
102
127
  else if (command === 'install' || command === 'add' || command === 'load') {
103
- const pkg = args[1];
128
+ const pkg = remainingArgs[1];
104
129
  if (!pkg) {
105
130
  console.error('❌ You must provide a skill package to load.');
106
131
  console.error('Usage: npx total-recall skill load <owner/repo@skill>');
@@ -108,8 +133,8 @@ export default async function skillCli(args) {
108
133
  }
109
134
 
110
135
  try {
111
- const { installSkill } = await import('../../.agent/skills/total-recall/skills/skill/scripts/install-skill.mjs');
112
- const res = installSkill(pkg);
136
+ const { installSkill } = await importSkillScript('install-skill.mjs', { agentDir });
137
+ const res = installSkill(pkg, { cwd: process.cwd(), agentDir });
113
138
  if (!res.success) {
114
139
  process.exit(1);
115
140
  }
@@ -120,7 +145,7 @@ export default async function skillCli(args) {
120
145
  }
121
146
 
122
147
  else if (command === 'scan') {
123
- const target = args[1];
148
+ const target = remainingArgs[1];
124
149
  if (!target) {
125
150
  console.error('❌ You must specify a local skill directory to scan.');
126
151
  console.error('Usage: npx total-recall skill scan <skill-name>');
@@ -130,7 +155,7 @@ export default async function skillCli(args) {
130
155
  const targetDir = path.resolve(skillsDir, target);
131
156
 
132
157
  try {
133
- const { runScan } = await import('../../.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs');
158
+ const { runScan } = await importSkillScript('scan-skill.mjs', { agentDir });
134
159
  const result = runScan(targetDir);
135
160
  process.exit(result.success ? 0 : 1);
136
161
  } catch (err) {
@@ -176,7 +201,7 @@ export default async function skillCli(args) {
176
201
  }
177
202
 
178
203
  else if (command === 'remove' || command === 'rm') {
179
- const target = args[1];
204
+ const target = remainingArgs[1];
180
205
  if (!target) {
181
206
  console.error('❌ You must specify a skill name to remove.');
182
207
  console.error('Usage: npx total-recall skill remove <skill-name>');
@@ -197,7 +222,8 @@ export default async function skillCli(args) {
197
222
  console.log('🔄 Hot-recompiling active shims to reflect changes...');
198
223
  const child = spawnSync('npx', ['total-recall', 'compile'], {
199
224
  encoding: 'utf8',
200
- cwd: ROOT
225
+ cwd: process.cwd(),
226
+ env: { ...process.env, AGENT_DIR: agentDir },
201
227
  });
202
228
 
203
229
  if (child.error || child.status !== 0) {
@@ -208,7 +234,7 @@ export default async function skillCli(args) {
208
234
  }
209
235
 
210
236
  else if (command === 'create' || command === 'new') {
211
- const target = args[1];
237
+ const target = remainingArgs[1];
212
238
  if (!target) {
213
239
  console.error('❌ You must specify a skill name to create.');
214
240
  console.error('Usage: npx total-recall skill create <skill-name>');
@@ -285,7 +311,7 @@ Provide a high-level explanation of the skill's capabilities and context.
285
311
  }
286
312
 
287
313
  else if (command === 'edit') {
288
- const target = args[1];
314
+ const target = remainingArgs[1];
289
315
  if (!target) {
290
316
  console.error('❌ You must specify a skill name to edit.');
291
317
  console.error('Usage: npx total-recall skill edit <skill-name>');