total-recall-brain 3.28.0 → 3.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/src/App.spec.tsx +4 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +10 -2
- package/package.json +1 -1
- package/scaffold/openwiki/README.md +10 -0
- package/scaffold/openwiki/architecture.md +5 -0
- package/scaffold/openwiki/ide.md +10 -0
- package/scaffold/openwiki/memory.md +11 -0
- package/scaffold/openwiki/secrets.md +7 -0
- package/scaffold/openwiki/skills.md +7 -0
- package/src/cli/connect.mjs +13 -8
- package/src/cli/init.mjs +51 -27
- package/src/cli/skill-projection.mjs +67 -16
- package/src/cli/status.mjs +42 -31
- package/src/core/daemon-loop.mjs +68 -15
- package/src/core/embeddings.mjs +14 -4
- package/src/core/fact-seeker.mjs +95 -3
- package/src/core/headscale-client.mjs +30 -6
- package/src/core/leader-election.mjs +49 -0
- package/src/core/research-queue.mjs +22 -0
- package/src/core/research.mjs +71 -0
- package/src/core/runtime.mjs +65 -25
- package/src/core/sandbox.mjs +27 -3
- package/src/core/secret-integration-research.mjs +31 -20
- package/src/core/skills-registry.mjs +2 -0
- package/src/server/route-manifest.json +48 -0
|
@@ -11,6 +11,10 @@ vi.mock('./api', () => ({
|
|
|
11
11
|
fetchHealth: vi.fn(),
|
|
12
12
|
getApiBase: vi.fn(),
|
|
13
13
|
setApiBase: vi.fn(),
|
|
14
|
+
// App calls fetchPlugins() on mount. A mock module that omits it makes
|
|
15
|
+
// vitest throw "No fetchPlugins export is defined on the ./api mock", which
|
|
16
|
+
// unmounts the whole tree and left every assertion staring at an empty div.
|
|
17
|
+
fetchPlugins: vi.fn().mockResolvedValue([]),
|
|
14
18
|
}));
|
|
15
19
|
|
|
16
20
|
vi.mock('./utils/onboarding', () => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
3
4
|
import PluginsPage from './PluginsPage';
|
|
4
5
|
|
|
5
6
|
// Mock fetch globally
|
|
@@ -11,7 +12,14 @@ globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
|
11
12
|
|
|
12
13
|
describe('PluginsPage', () => {
|
|
13
14
|
it('renders plugins page', () => {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
// PluginsPage reads useSearchParams(), so it must be rendered inside a
|
|
16
|
+
// Router — without one react-router throws and nothing mounts.
|
|
17
|
+
render(
|
|
18
|
+
<MemoryRouter>
|
|
19
|
+
<PluginsPage />
|
|
20
|
+
</MemoryRouter>,
|
|
21
|
+
);
|
|
22
|
+
// getByText(/Plugin/i) matched both the heading and the install button.
|
|
23
|
+
expect(screen.getByRole('heading', { name: /Plugins/i })).toBeInTheDocument();
|
|
16
24
|
});
|
|
17
25
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# OpenWiki
|
|
2
|
+
|
|
3
|
+
Portable knowledge documentation for this Total Recall brain.
|
|
4
|
+
|
|
5
|
+
| Layer | Purpose |
|
|
6
|
+
|-------|---------|
|
|
7
|
+
| **Memory vault** | SSSS nodes (rules, prefs, facts) — machine-enforceable |
|
|
8
|
+
| **OpenWiki** | Human/agent docs (architecture, how-tos, stack notes) |
|
|
9
|
+
|
|
10
|
+
OpenWiki ships with Total Recall. Edit freely; do not put secrets here.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# IDE personalization
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
npx total-recall connect claude-code # CLAUDE.md
|
|
5
|
+
npx total-recall connect codex # AGENTS.md
|
|
6
|
+
npx total-recall connect cursor # .cursor/rules/
|
|
7
|
+
npx total-recall connect gemini # GEMINI.md
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Total Recall only writes inside managed inject markers. Your local rules stay intact.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Memory
|
|
2
|
+
|
|
3
|
+
Use the Total Recall CLI for durable memory:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx total-recall remember invariant "Never commit secrets." --importance 5
|
|
7
|
+
npx total-recall recall "secrets"
|
|
8
|
+
npx total-recall compile
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Categories: invariants, preferences, facts, decisions, patterns, anti-patterns, concepts, lore.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Secrets & API keys
|
|
2
|
+
|
|
3
|
+
Store credentials in Total Recall's secrets store (`config/secrets.enc` / future CLI).
|
|
4
|
+
|
|
5
|
+
**Never** put API keys in openwiki pages or compiled IDE instruction files.
|
|
6
|
+
|
|
7
|
+
Planned CLI surface (TR_CORE_FOCUS Phase 4): `secret set|list|rotate|audit` and usage/cost meters.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Skills
|
|
2
|
+
|
|
3
|
+
- **Total Recall skill** (only nested agent skill): `.agent/skills/total-recall/SKILL.md`
|
|
4
|
+
- **User skills**: `.agent/skills/<name>/SKILL.md` — project or global
|
|
5
|
+
- **TR modules** (not skills): `.agent/skills/total-recall/modules/` — internal docs/scripts
|
|
6
|
+
|
|
7
|
+
TR will track skill installs across repos and support adaptive deploy (see TR_CORE_FOCUS).
|
package/src/cli/connect.mjs
CHANGED
|
@@ -79,6 +79,13 @@ const CLIENTS = {
|
|
|
79
79
|
// Cline uses a .clinerules/ directory (primary); plain markdown, no frontmatter.
|
|
80
80
|
target: path.join('.clinerules', 'total-recall.md'),
|
|
81
81
|
render: instructions => instructions,
|
|
82
|
+
// Cline scans <project>/.clinerules/skills, <project>/.cline/skills,
|
|
83
|
+
// <project>/.claude/skills and <project>/.agents/skills (plus the global
|
|
84
|
+
// ~/.cline/skills and ~/.agents/skills). Only .clinerules/.cline/.agents are
|
|
85
|
+
// scanned by the `next` (SDK) bundle too — .claude/skills is VS Code-bundle
|
|
86
|
+
// only — so project into .agents/skills, which is also where Cline's own
|
|
87
|
+
// "New skill..." writes and where the codex/gemini/antigravity presets land.
|
|
88
|
+
skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
|
|
82
89
|
},
|
|
83
90
|
'claude-code': {
|
|
84
91
|
label: 'Claude Code',
|
|
@@ -93,9 +100,8 @@ const CLIENTS = {
|
|
|
93
100
|
label: 'Codex',
|
|
94
101
|
mode: 'symlink',
|
|
95
102
|
target: 'AGENTS.md',
|
|
96
|
-
// Codex
|
|
97
|
-
|
|
98
|
-
skillsProjection: { scope: 'home', dir: path.join('.codex', 'skills') }
|
|
103
|
+
// Codex discovers repository skills in <project>/.agents/skills.
|
|
104
|
+
skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
|
|
99
105
|
},
|
|
100
106
|
antigravity: {
|
|
101
107
|
label: 'Antigravity',
|
|
@@ -954,8 +960,7 @@ export default async function connect(args) {
|
|
|
954
960
|
}
|
|
955
961
|
}
|
|
956
962
|
|
|
957
|
-
// Project repo skills
|
|
958
|
-
// Each <agentDir>/skills/<name>/SKILL.md becomes /<name> in the target IDE.
|
|
963
|
+
// Project repo skills into the target IDE's Agent Skills directory.
|
|
959
964
|
if (preset.skillsProjection) {
|
|
960
965
|
const skills = discoverRepoSkills(agentDir);
|
|
961
966
|
if (skills.length > 0) {
|
|
@@ -964,14 +969,14 @@ export default async function connect(args) {
|
|
|
964
969
|
: path.join(cwd, preset.skillsProjection.dir);
|
|
965
970
|
const skillResults = projectSkillsAsCommands(destDir, skills, opts);
|
|
966
971
|
result.skill_commands = skillResults;
|
|
967
|
-
const fresh = skillResults.filter(r => r.action === 'linked');
|
|
972
|
+
const fresh = skillResults.filter(r => r.action === 'linked' || r.action === 'copied');
|
|
968
973
|
if (fresh.length > 0) {
|
|
969
974
|
const destLabel = preset.skillsProjection.scope === 'home'
|
|
970
975
|
? destDir
|
|
971
976
|
: (path.relative(cwd, destDir) || destDir);
|
|
972
977
|
result.notes.push(
|
|
973
|
-
` Repo skills projected
|
|
974
|
-
fresh.map(r => `
|
|
978
|
+
` Repo skills projected → ${destLabel}/:\n` +
|
|
979
|
+
fresh.map(r => ` ${r.name}`).join('\n')
|
|
975
980
|
);
|
|
976
981
|
}
|
|
977
982
|
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -77,28 +77,52 @@ function copyDirMerge(src, dest, dryRun) {
|
|
|
77
77
|
* Ship openwiki with the brain (global or project). Prefer packaged templates;
|
|
78
78
|
* fall back to a minimal set of pages so every brain has docs.
|
|
79
79
|
*/
|
|
80
|
-
function ensureOpenWiki(brainDir, isProject, dryRun) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
logOk(`Created openwiki at ${path.relative(process.cwd(), wikiDir) || wikiDir}`);
|
|
80
|
+
async function ensureOpenWiki(brainDir, isProject, dryRun) {
|
|
81
|
+
const wikiDirs = [path.join(brainDir, 'openwiki')];
|
|
82
|
+
if (isProject) {
|
|
83
|
+
wikiDirs.push(path.join(process.cwd(), 'openwiki'));
|
|
85
84
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
const templateRoot = fs.existsSync(path.join(ROOT, 'scaffold', 'openwiki'))
|
|
86
|
+
? path.join(ROOT, 'scaffold', 'openwiki')
|
|
87
|
+
: path.join(ROOT, 'templates', 'openwiki');
|
|
88
|
+
|
|
89
|
+
for (const wikiDir of wikiDirs) {
|
|
90
|
+
if (ensureDir(wikiDir, dryRun)) {
|
|
91
|
+
logOk(`Created openwiki at ${path.relative(process.cwd(), wikiDir) || wikiDir}`);
|
|
92
|
+
}
|
|
93
|
+
if (fs.existsSync(templateRoot)) {
|
|
94
|
+
copyDirMerge(templateRoot, wikiDir, dryRun);
|
|
95
|
+
} else {
|
|
96
|
+
// Minimal fallback pages when templates/ not packaged yet
|
|
97
|
+
const pages = {
|
|
98
|
+
'README.md': `# OpenWiki\n\nPortable knowledge docs for this Total Recall brain (${isProject ? 'project' : 'global'}).\n\n- Not the SSSS memory vault — use vault nodes for rules/facts.\n- Use these pages for architecture, stack, and how-to knowledge.\n`,
|
|
99
|
+
'architecture.md': `# Architecture\n\nComponent overview, domain boundaries, and runtime execution flow.\n`,
|
|
100
|
+
'stack.md': `# Stack\n\nRuntime dependencies, frameworks, and system tools.\n`,
|
|
101
|
+
'memory.md': `# Memory\n\nHow this brain stores invariants, preferences, and facts.\n\nUse \`npx total-recall remember\` / \`recall\` / \`compile\`.\n`,
|
|
102
|
+
'ide.md': `# IDE personalization\n\nConnect surfaces with \`npx total-recall connect <ide>\`.\nInjected blocks live inside BEGIN/END INJECTED MEMORY markers only.\n`,
|
|
103
|
+
'skills.md': `# Skills\n\nUser skills live in \`.agent/skills/<name>/\` (siblings of total-recall).\nTotal Recall tracks and deploys skills; it does not nest product skills inside itself.\n`,
|
|
104
|
+
'secrets.md': `# Secrets\n\nAPI keys and credentials belong in the encrypted secrets store — never in openwiki or instruction shims.\n`,
|
|
105
|
+
};
|
|
106
|
+
for (const [name, body] of Object.entries(pages)) {
|
|
107
|
+
const dest = path.join(wikiDir, name);
|
|
108
|
+
if (fs.existsSync(dest)) continue;
|
|
109
|
+
if (!dryRun) fs.writeFileSync(dest, body);
|
|
110
|
+
logOk(`Seeded openwiki/${name}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Auto-ingest into memory graph when project is initialized
|
|
116
|
+
if (isProject && !dryRun) {
|
|
117
|
+
try {
|
|
118
|
+
const { runOpenWikiIngest } = await import('./ingest-openwiki.mjs');
|
|
119
|
+
const targetWiki = path.join(process.cwd(), 'openwiki');
|
|
120
|
+
if (fs.existsSync(targetWiki)) {
|
|
121
|
+
await runOpenWikiIngest([targetWiki], { silent: true });
|
|
122
|
+
logOk('Ingested openwiki into semantic knowledge graph');
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Best-effort ingestion
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
}
|
|
@@ -574,15 +598,15 @@ export default async function init(args) {
|
|
|
574
598
|
// This is the non-interactive openwiki path. External `openwiki --init` is optional
|
|
575
599
|
// and only offered interactively (that CLI has no --yes flag).
|
|
576
600
|
logStep('3.6/4', 'Ensuring openwiki is present');
|
|
577
|
-
ensureOpenWiki(brainDir, isProject, opts.dryRun);
|
|
601
|
+
await ensureOpenWiki(brainDir, isProject, opts.dryRun);
|
|
578
602
|
|
|
579
|
-
// ── Step 3.7: Project skills
|
|
603
|
+
// ── Step 3.7: Project skills into the IDEs in use ──
|
|
580
604
|
// Scope matches the brain: `init --project` projects PROJECT skills into the
|
|
581
605
|
// repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
|
|
582
606
|
// the current process); `init` projects GLOBAL skills into installed IDEs'
|
|
583
|
-
// global skill dirs. Codex
|
|
607
|
+
// global skill dirs. Codex discovers project skills in .agents/skills.
|
|
584
608
|
const skillScope = isProject ? 'project' : 'global';
|
|
585
|
-
logStep('3.7/4', `Projecting ${skillScope} skills
|
|
609
|
+
logStep('3.7/4', `Projecting ${skillScope} skills (IDEs in use)`);
|
|
586
610
|
if (opts.dryRun) {
|
|
587
611
|
const targets = detectActiveSkillTargets({ scope: skillScope, cwd });
|
|
588
612
|
for (const t of targets.filter(t => t.supported)) {
|
|
@@ -600,10 +624,10 @@ export default async function init(args) {
|
|
|
600
624
|
log(` No in-use IDE detected for ${skillScope} skills — run \`npx total-recall connect <ide>\` to wire them.`);
|
|
601
625
|
} else {
|
|
602
626
|
for (const t of wired) {
|
|
603
|
-
const n = t.results.filter(r => ['linked', 'exists', 'source'].includes(r.action)).length;
|
|
627
|
+
const n = t.results.filter(r => ['linked', 'copied', 'exists', 'source'].includes(r.action)).length;
|
|
604
628
|
logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
|
|
605
629
|
}
|
|
606
|
-
log(`
|
|
630
|
+
log(` Discoverable skills: ${skills.map(s => s.name).join(', ')}`);
|
|
607
631
|
if (available.length > 0) {
|
|
608
632
|
log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
|
|
609
633
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Scope-aware skill → slash-command projection.
|
|
3
3
|
*
|
|
4
4
|
* A "repo skill" is any <skills-root>/<name>/SKILL.md directory (the Agent
|
|
5
|
-
* Skills standard). Each IDE
|
|
6
|
-
*
|
|
5
|
+
* Skills standard). Each IDE discovers skills in a different place, and that
|
|
6
|
+
* place
|
|
7
7
|
* depends on the *scope* of the skill:
|
|
8
8
|
*
|
|
9
9
|
* PROJECT skills (authored in <repo>/.agent/skills or <repo>/.agents/skills)
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* machine. They are available in every repo.
|
|
17
17
|
*
|
|
18
18
|
* This mirrors the brain model: `init --project` ⇒ project skills; `init`
|
|
19
|
-
* (global) ⇒ global skills. Codex
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* (global) ⇒ global skills. Codex discovers project skills in
|
|
20
|
+
* <repo>/.agents/skills and global skills in ~/.codex/skills. In Codex,
|
|
21
|
+
* /skills opens the picker and $skill-name explicitly invokes a skill.
|
|
22
22
|
*/
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import os from 'node:os';
|
|
@@ -54,9 +54,7 @@ export const IDE_SKILLS = [
|
|
|
54
54
|
id: 'codex',
|
|
55
55
|
label: 'Codex',
|
|
56
56
|
clients: ['codex'],
|
|
57
|
-
|
|
58
|
-
// project-local dir, so project skills are opt-in (namespaced) via connect.
|
|
59
|
-
project: null,
|
|
57
|
+
project: { dir: ['.agents', 'skills'], markers: ['.agents', 'AGENTS.md'], env: ['CODEX_HOME'] },
|
|
60
58
|
global: { dir: ['.codex', 'skills'], markers: ['.codex'] }
|
|
61
59
|
},
|
|
62
60
|
{
|
|
@@ -90,9 +88,25 @@ function pathExists(filePath) {
|
|
|
90
88
|
// to heal back onto the canonical source, even without --force.
|
|
91
89
|
const AUTO_GENERATED_MARKER = '**Auto-generated** by `npx total-recall';
|
|
92
90
|
|
|
91
|
+
// Written into a skill directory that projectSkillsAsCommands had to COPY
|
|
92
|
+
// (no symlink support — Windows without Developer Mode, some network/FAT
|
|
93
|
+
// volumes). A real directory carrying this file is Total Recall's own
|
|
94
|
+
// projection, never a user's skill, so it is refreshed on every run.
|
|
95
|
+
export const PROJECTION_MARKER_FILE = '.total-recall-projection.json';
|
|
96
|
+
|
|
97
|
+
function isProjectedCopy(entryPath) {
|
|
98
|
+
try {
|
|
99
|
+
return fs.statSync(entryPath).isDirectory()
|
|
100
|
+
&& fs.existsSync(path.join(entryPath, PROJECTION_MARKER_FILE));
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
function isStaleGeneratedDir(entryPath) {
|
|
94
107
|
try {
|
|
95
108
|
if (!fs.statSync(entryPath).isDirectory()) return false;
|
|
109
|
+
if (isProjectedCopy(entryPath)) return true;
|
|
96
110
|
const content = fs.readFileSync(path.join(entryPath, 'SKILL.md'), 'utf8');
|
|
97
111
|
return content.includes(AUTO_GENERATED_MARKER);
|
|
98
112
|
} catch {
|
|
@@ -101,21 +115,55 @@ function isStaleGeneratedDir(entryPath) {
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
118
|
+
* Put one skill at linkPath so the IDE can discover it:
|
|
119
|
+
* 1. a symlink RELATIVE to the destination dir — the projection then survives
|
|
120
|
+
* `git clone`, a moved checkout, or a different home directory (an absolute
|
|
121
|
+
* link only works on the machine that wrote it);
|
|
122
|
+
* 2. on Windows a directory junction (no Developer Mode / admin needed; Node
|
|
123
|
+
* resolves the target to an absolute path itself);
|
|
124
|
+
* 3. when the filesystem refuses links at all, a real COPY of the skill
|
|
125
|
+
* directory tagged with PROJECTION_MARKER_FILE so later runs refresh it.
|
|
126
|
+
*/
|
|
127
|
+
function placeSkill(skillDir, linkPath) {
|
|
128
|
+
const source = path.resolve(skillDir);
|
|
129
|
+
const relativeTarget = path.relative(path.dirname(path.resolve(linkPath)), source) || '.';
|
|
130
|
+
try {
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
fs.symlinkSync(source, linkPath, 'junction');
|
|
133
|
+
} else {
|
|
134
|
+
fs.symlinkSync(relativeTarget, linkPath);
|
|
135
|
+
}
|
|
136
|
+
return 'linked';
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const noLinks = ['EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'EINVAL', 'ENOSYS'];
|
|
139
|
+
if (!noLinks.includes(err?.code)) throw err;
|
|
140
|
+
}
|
|
141
|
+
fs.cpSync(source, linkPath, { recursive: true, dereference: true });
|
|
142
|
+
fs.writeFileSync(
|
|
143
|
+
path.join(linkPath, PROJECTION_MARKER_FILE),
|
|
144
|
+
JSON.stringify({ source: relativeTarget, projected_at: new Date().toISOString() }, null, 2) + '\n'
|
|
145
|
+
);
|
|
146
|
+
return 'copied';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Skill source roots, in priority order. The Total Recall location
|
|
151
|
+
* (.agent/skills, singular) wins, followed by repo-authored skills/ and
|
|
152
|
+
* already-projected .agents/skills. This avoids treating projected links as
|
|
153
|
+
* the source when the original is available.
|
|
107
154
|
*/
|
|
108
155
|
export function skillSourceRoots(agentDir) {
|
|
109
156
|
const roots = [path.join(agentDir, 'skills')];
|
|
110
157
|
const parent = path.dirname(agentDir);
|
|
111
158
|
if (path.basename(agentDir) === '.agent') {
|
|
159
|
+
roots.push(path.join(parent, 'skills'));
|
|
112
160
|
roots.push(path.join(parent, '.agents', 'skills'));
|
|
113
161
|
}
|
|
114
162
|
return roots;
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
/**
|
|
118
|
-
* Discover repo skills to expose
|
|
166
|
+
* Discover repo skills to expose to IDEs.
|
|
119
167
|
* Every <root>/<name>/ directory containing a SKILL.md is a skill. When the
|
|
120
168
|
* same name appears in more than one source root, the canonical root wins.
|
|
121
169
|
*/
|
|
@@ -141,7 +189,8 @@ export function discoverRepoSkills(agentDir) {
|
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/**
|
|
144
|
-
*
|
|
192
|
+
* Project each discovered skill dir into one IDE's Agent-Skills directory
|
|
193
|
+
* (relative symlink, Windows junction, or a marked copy — see placeSkill).
|
|
145
194
|
* Idempotent, self-healing, and strictly additive: a symlink that is broken or
|
|
146
195
|
* stale is refreshed even without --force; a *real* (non-symlink) entry is
|
|
147
196
|
* never clobbered unless --force (so a user's own skill always wins a name
|
|
@@ -165,7 +214,10 @@ export function projectSkillsAsCommands(destDir, skills, opts = {}) {
|
|
|
165
214
|
if (pathExists(linkPath)) {
|
|
166
215
|
const stat = fs.lstatSync(linkPath);
|
|
167
216
|
if (stat.isSymbolicLink()) {
|
|
168
|
-
|
|
217
|
+
// Relative links resolve against the link's own directory; Windows
|
|
218
|
+
// junctions read back with a \\?\ long-path prefix that must be dropped.
|
|
219
|
+
const raw = fs.readlinkSync(linkPath).replace(/^\\\\\?\\/, '');
|
|
220
|
+
const current = path.resolve(path.dirname(linkPath), raw);
|
|
169
221
|
if (current === wantTarget && fs.existsSync(linkPath) && !opts.force) {
|
|
170
222
|
written.push({ name: skill.name, action: 'exists' });
|
|
171
223
|
continue;
|
|
@@ -178,8 +230,7 @@ export function projectSkillsAsCommands(destDir, skills, opts = {}) {
|
|
|
178
230
|
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
179
231
|
}
|
|
180
232
|
}
|
|
181
|
-
|
|
182
|
-
written.push({ name: skill.name, action: 'linked' });
|
|
233
|
+
written.push({ name: skill.name, action: placeSkill(skill.skillDir, linkPath) });
|
|
183
234
|
}
|
|
184
235
|
return written;
|
|
185
236
|
}
|
package/src/cli/status.mjs
CHANGED
|
@@ -20,13 +20,8 @@ import fs from 'node:fs';
|
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
21
|
import { loadQueue } from '../core/research-queue.mjs';
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
const AGENT_DIR = agentDir;
|
|
26
|
-
const BRAIN_CONFIG = path.join(brainDir, 'config', 'brain.json');
|
|
27
|
-
const SYNC_STATE = path.join(brainDir, 'config', 'sync-state.json');
|
|
28
|
-
const CLIENTS_REGISTRY = path.join(brainDir, 'config', 'clients.json');
|
|
29
|
-
const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
|
|
23
|
+
import { totalRecallToken } from '../core/config.mjs';
|
|
24
|
+
import { resolveBrainDir } from './agent-dir.mjs';
|
|
30
25
|
|
|
31
26
|
function parseArgs(args) {
|
|
32
27
|
const opts = { json: false, brain: null, token: null, help: false };
|
|
@@ -55,21 +50,21 @@ function printHelp() {
|
|
|
55
50
|
`);
|
|
56
51
|
}
|
|
57
52
|
|
|
58
|
-
function loadBrainConfig() {
|
|
59
|
-
if (!fs.existsSync(
|
|
60
|
-
try { return JSON.parse(fs.readFileSync(
|
|
53
|
+
function loadBrainConfig(brainConfigPath) {
|
|
54
|
+
if (!fs.existsSync(brainConfigPath)) return null;
|
|
55
|
+
try { return JSON.parse(fs.readFileSync(brainConfigPath, 'utf8')); }
|
|
61
56
|
catch { return null; }
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
function loadSyncState() {
|
|
65
|
-
if (!fs.existsSync(
|
|
66
|
-
try { return JSON.parse(fs.readFileSync(
|
|
59
|
+
function loadSyncState(syncStatePath) {
|
|
60
|
+
if (!fs.existsSync(syncStatePath)) return {};
|
|
61
|
+
try { return JSON.parse(fs.readFileSync(syncStatePath, 'utf8')); }
|
|
67
62
|
catch { return {}; }
|
|
68
63
|
}
|
|
69
64
|
|
|
70
|
-
function loadClientsRegistry() {
|
|
71
|
-
if (!fs.existsSync(
|
|
72
|
-
try { return JSON.parse(fs.readFileSync(
|
|
65
|
+
function loadClientsRegistry(clientsRegistryPath) {
|
|
66
|
+
if (!fs.existsSync(clientsRegistryPath)) return {};
|
|
67
|
+
try { return JSON.parse(fs.readFileSync(clientsRegistryPath, 'utf8'))?.clients || {}; }
|
|
73
68
|
catch { return {}; }
|
|
74
69
|
}
|
|
75
70
|
|
|
@@ -90,21 +85,31 @@ function sha256(buf) {
|
|
|
90
85
|
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
async function probeBrain(brainUrl, token) {
|
|
88
|
+
async function probeBrain(brainUrl, token, brainId) {
|
|
94
89
|
const headers = {};
|
|
95
90
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
96
91
|
const out = { reachable: false, instructions_sha256: null, instructions_bytes: null, error: null };
|
|
97
92
|
try {
|
|
98
|
-
const
|
|
93
|
+
const url = new URL(`${brainUrl.replace(/\/$/, '')}/api/instructions`);
|
|
94
|
+
if (brainId) url.searchParams.set('brain', brainId);
|
|
95
|
+
const res = await fetch(url, { headers });
|
|
99
96
|
if (!res.ok) {
|
|
100
97
|
out.error = `HTTP ${res.status}`;
|
|
101
98
|
return out;
|
|
102
99
|
}
|
|
103
|
-
const
|
|
100
|
+
const contentType = res.headers?.get?.('content-type') || '';
|
|
101
|
+
const body = contentType.includes('application/json') ? await res.json() : null;
|
|
104
102
|
out.reachable = true;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
if (body) {
|
|
104
|
+
out.instructions_sha256 = body.sha256 || (body.content ? sha256(body.content) : null);
|
|
105
|
+
out.instructions_bytes = body.bytes || (body.content ? Buffer.byteLength(body.content) : null);
|
|
106
|
+
out.modified = body.modified || null;
|
|
107
|
+
} else {
|
|
108
|
+
const content = await res.text();
|
|
109
|
+
out.instructions_sha256 = sha256(content);
|
|
110
|
+
out.instructions_bytes = Buffer.byteLength(content);
|
|
111
|
+
out.modified = res.headers?.get?.('last-modified') || null;
|
|
112
|
+
}
|
|
108
113
|
} catch (err) {
|
|
109
114
|
out.error = err.message;
|
|
110
115
|
}
|
|
@@ -115,9 +120,12 @@ export default async function statusCmd(args) {
|
|
|
115
120
|
const opts = parseArgs(args);
|
|
116
121
|
if (opts.help) { printHelp(); return; }
|
|
117
122
|
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
123
|
+
const brainDir = resolveBrainDir();
|
|
124
|
+
const agentDir = path.dirname(path.dirname(brainDir));
|
|
125
|
+
const instructionsFile = path.join(path.dirname(agentDir), 'INSTRUCTIONS.md');
|
|
126
|
+
const brainConfig = loadBrainConfig(path.join(brainDir, 'config', 'brain.json'));
|
|
127
|
+
const syncState = loadSyncState(path.join(brainDir, 'config', 'sync-state.json'));
|
|
128
|
+
const clientsRegistry = loadClientsRegistry(path.join(brainDir, 'config', 'clients.json'));
|
|
121
129
|
const brainUrl = opts.brain || brainConfig?.url || null;
|
|
122
130
|
const token = opts.token || brainConfig?.token || totalRecallToken || null;
|
|
123
131
|
|
|
@@ -175,11 +183,11 @@ export default async function statusCmd(args) {
|
|
|
175
183
|
} catch {}
|
|
176
184
|
|
|
177
185
|
let localInstructions = null;
|
|
178
|
-
if (fs.existsSync(
|
|
179
|
-
const buf = fs.readFileSync(
|
|
180
|
-
const stat = fs.statSync(
|
|
186
|
+
if (fs.existsSync(instructionsFile)) {
|
|
187
|
+
const buf = fs.readFileSync(instructionsFile);
|
|
188
|
+
const stat = fs.statSync(instructionsFile);
|
|
181
189
|
localInstructions = {
|
|
182
|
-
path:
|
|
190
|
+
path: instructionsFile,
|
|
183
191
|
sha256: sha256(buf),
|
|
184
192
|
bytes: stat.size,
|
|
185
193
|
modified: stat.mtime.toISOString()
|
|
@@ -188,14 +196,17 @@ export default async function statusCmd(args) {
|
|
|
188
196
|
|
|
189
197
|
let remote = null;
|
|
190
198
|
if (brainUrl) {
|
|
191
|
-
|
|
199
|
+
const brainId = brainConfig?.layer === 'project' && brainConfig?.name
|
|
200
|
+
? `project:${brainConfig.name}`
|
|
201
|
+
: null;
|
|
202
|
+
remote = await probeBrain(brainUrl, token, brainId);
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
const inSync = !!(localInstructions && remote?.instructions_sha256 &&
|
|
195
206
|
remote.instructions_sha256 === localInstructions.sha256);
|
|
196
207
|
|
|
197
208
|
const instructionsMtime = localInstructions
|
|
198
|
-
? fs.statSync(
|
|
209
|
+
? fs.statSync(instructionsFile).mtimeMs
|
|
199
210
|
: null;
|
|
200
211
|
const clientProjections = projectionsStatus(clientsRegistry, instructionsMtime);
|
|
201
212
|
|