@vib795/agent-memory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOWTO.md +368 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/install.ps1 +54 -0
- package/install.sh +36 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +45 -0
- package/skills/handoff/SKILL.md +344 -0
- package/skills/recall/SKILL.md +125 -0
- package/skills/remember/SKILL.md +155 -0
- package/src/atomic.js +57 -0
- package/src/cli.js +558 -0
- package/src/compact.js +242 -0
- package/src/config.js +91 -0
- package/src/digest.js +199 -0
- package/src/graph.js +99 -0
- package/src/index-db.js +317 -0
- package/src/promptfile.js +78 -0
- package/src/redact.js +97 -0
- package/src/schema.js +98 -0
- package/src/setup.js +211 -0
- package/src/staleness.js +151 -0
- package/src/store.js +0 -0
- package/src/targets.js +139 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync, rmSync, existsSync, lstatSync, symlinkSync, cpSync, readlinkSync, readFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { saveConfig, loadConfig } from './config.js';
|
|
7
|
+
import { agentHome, installableTargets } from './targets.js';
|
|
8
|
+
import { atomicWrite } from './atomic.js';
|
|
9
|
+
import { toPromptFile, isGenerated } from './promptfile.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Installation, in Node rather than in two shell scripts.
|
|
13
|
+
*
|
|
14
|
+
* This is the single implementation behind three entry points: `npm install` via
|
|
15
|
+
* postinstall, `agent-memory setup`, and install.sh / install.ps1. Writing it once
|
|
16
|
+
* matters because the machine that has to run it is a Windows desktop this was never
|
|
17
|
+
* developed on, and a PowerShell copy of this logic would drift silently.
|
|
18
|
+
*
|
|
19
|
+
* Two formats are written, because the tools disagree about what a skill is. Agent
|
|
20
|
+
* skills are directories containing SKILL.md and get linked. VS Code prompt files are
|
|
21
|
+
* single `<name>.prompt.md` files and get written. Both are derived from the same
|
|
22
|
+
* skills/ directory, so there is still one source of truth.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SKILLS = ['handoff', 'recall', 'remember'];
|
|
26
|
+
|
|
27
|
+
/** Where the packaged skills live, whether installed globally or run from a checkout. */
|
|
28
|
+
export function packagedSkillsDir() {
|
|
29
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Skill directories only. Kept for the dangling-link check and for uninstall. */
|
|
33
|
+
export function skillTargets() {
|
|
34
|
+
return installableTargets().filter((t) => t.kind === 'skill-dir').map((t) => t.dir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isLink(path) {
|
|
38
|
+
try {
|
|
39
|
+
return lstatSync(path).isSymbolicLink();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clear(path) {
|
|
46
|
+
// A stale symlink must be removed as a link rather than followed, or clearing it
|
|
47
|
+
// would delete the packaged skills on the other end.
|
|
48
|
+
if (isLink(path)) rmSync(path, { force: true });
|
|
49
|
+
else if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Link one skill into one agent directory.
|
|
54
|
+
*
|
|
55
|
+
* 'junction' makes a directory junction on Windows, which needs neither admin rights
|
|
56
|
+
* nor Developer Mode, and is ignored on POSIX where an ordinary symlink is made.
|
|
57
|
+
* Junctions still fail on a network-backed profile (FSLogix, roaming), so there is a
|
|
58
|
+
* copy fallback and the caller is told which one it got.
|
|
59
|
+
*/
|
|
60
|
+
export function linkSkill(name, targetDir) {
|
|
61
|
+
const source = join(packagedSkillsDir(), name);
|
|
62
|
+
const link = join(targetDir, name);
|
|
63
|
+
mkdirSync(targetDir, { recursive: true });
|
|
64
|
+
clear(link);
|
|
65
|
+
try {
|
|
66
|
+
symlinkSync(source, link, 'junction');
|
|
67
|
+
return { name, path: link, mode: 'link' };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
cpSync(source, link, { recursive: true });
|
|
70
|
+
return { name, path: link, mode: 'copy', reason: err.message };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write one skill as a VS Code prompt file.
|
|
76
|
+
*
|
|
77
|
+
* Written rather than linked: prompt files are single files, and a symlinked one is
|
|
78
|
+
* not reliably picked up across VS Code's file watching. The cost is that an upgrade
|
|
79
|
+
* needs `agent-memory setup` re-run, which the installer says out loud.
|
|
80
|
+
*/
|
|
81
|
+
export function writePromptFile(name, targetDir) {
|
|
82
|
+
const source = join(packagedSkillsDir(), name, 'SKILL.md');
|
|
83
|
+
const dest = join(targetDir, `${name}.prompt.md`);
|
|
84
|
+
mkdirSync(targetDir, { recursive: true });
|
|
85
|
+
atomicWrite(dest, toPromptFile(readFileSync(source, 'utf8'), { name }));
|
|
86
|
+
return { name, path: dest, mode: 'prompt' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Skill links whose target no longer exists.
|
|
91
|
+
*
|
|
92
|
+
* npm 7 dropped support for uninstall lifecycle hooks, so `npm uninstall -g` deletes
|
|
93
|
+
* the package and leaves these behind with nothing to clean them up — and by then the
|
|
94
|
+
* binary that would have done it is gone too. Detecting them is the only remedy left,
|
|
95
|
+
* and a broken skill an agent keeps trying to load is worth naming out loud.
|
|
96
|
+
*/
|
|
97
|
+
export function danglingSkillLinks() {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const name of SKILLS) {
|
|
100
|
+
for (const dir of skillTargets()) {
|
|
101
|
+
const link = join(dir, name);
|
|
102
|
+
// existsSync follows the link, so a true isLink with a false existsSync is
|
|
103
|
+
// exactly the broken case and nothing else.
|
|
104
|
+
if (isLink(link) && !existsSync(link)) out.push(link);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove everything this package installed, in both formats.
|
|
112
|
+
*
|
|
113
|
+
* Only entries that are ours are removed: a skill directory is ours if it resolves
|
|
114
|
+
* back to this package, and a prompt file is ours if it carries the generated marker.
|
|
115
|
+
* Someone may have written their own `recall.prompt.md`, and deleting it because the
|
|
116
|
+
* name matched would be destroying work we were never asked to manage.
|
|
117
|
+
*
|
|
118
|
+
* The store is never touched. Notes are the user's own writing and outlive the tool
|
|
119
|
+
* that indexed them; removing them is a separate, deliberate act.
|
|
120
|
+
*/
|
|
121
|
+
export function unlinkSkills() {
|
|
122
|
+
const packaged = packagedSkillsDir();
|
|
123
|
+
const removed = [];
|
|
124
|
+
const kept = [];
|
|
125
|
+
|
|
126
|
+
for (const target of installableTargets()) {
|
|
127
|
+
for (const name of SKILLS) {
|
|
128
|
+
if (target.kind === 'skill-dir') {
|
|
129
|
+
const link = join(target.dir, name);
|
|
130
|
+
if (!existsSync(link) && !isLink(link)) continue;
|
|
131
|
+
let owned = false;
|
|
132
|
+
try {
|
|
133
|
+
owned = isLink(link)
|
|
134
|
+
? resolve(readlinkSync(link)) === join(packaged, name)
|
|
135
|
+
: existsSync(join(link, 'SKILL.md'));
|
|
136
|
+
} catch {
|
|
137
|
+
// A link we cannot read is a link we cannot claim. Leave it.
|
|
138
|
+
owned = false;
|
|
139
|
+
}
|
|
140
|
+
if (owned) {
|
|
141
|
+
clear(link);
|
|
142
|
+
removed.push(link);
|
|
143
|
+
} else {
|
|
144
|
+
kept.push(link);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
const file = join(target.dir, `${name}.prompt.md`);
|
|
148
|
+
if (!existsSync(file)) continue;
|
|
149
|
+
if (isGenerated(readFileSync(file, 'utf8'))) {
|
|
150
|
+
rmSync(file, { force: true });
|
|
151
|
+
removed.push(file);
|
|
152
|
+
} else {
|
|
153
|
+
kept.push(file);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { removed, kept };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Install into every agent present on this machine, then build the store.
|
|
163
|
+
*
|
|
164
|
+
* Only `recall` is registered for description regeneration. `compact` overwrites the
|
|
165
|
+
* description of every path it is given, and handoff and remember describe
|
|
166
|
+
* themselves; registering all three would replace two good descriptions with a third.
|
|
167
|
+
* Copies and prompt files register their own path, because rewriting the packaged
|
|
168
|
+
* original would never reach them.
|
|
169
|
+
*/
|
|
170
|
+
export function setup({ compactFn } = {}) {
|
|
171
|
+
const targets = installableTargets();
|
|
172
|
+
const installed = [];
|
|
173
|
+
const copies = [];
|
|
174
|
+
|
|
175
|
+
for (const target of targets) {
|
|
176
|
+
for (const name of SKILLS) {
|
|
177
|
+
const r =
|
|
178
|
+
target.kind === 'skill-dir'
|
|
179
|
+
? linkSkill(name, target.dir)
|
|
180
|
+
: writePromptFile(name, target.dir);
|
|
181
|
+
installed.push({ ...r, target: target.id, label: target.label });
|
|
182
|
+
if (r.mode === 'copy') copies.push(r);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const skillPaths = [
|
|
187
|
+
join(packagedSkillsDir(), 'recall', 'SKILL.md'),
|
|
188
|
+
...copies.filter((c) => c.name === 'recall').map((c) => join(c.path, 'SKILL.md')),
|
|
189
|
+
...installed.filter((i) => i.mode === 'prompt' && i.name === 'recall').map((i) => i.path),
|
|
190
|
+
];
|
|
191
|
+
// Drop registrations whose file is gone before adding the current ones. Renaming
|
|
192
|
+
// the checkout, moving it, or reinstalling under a different prefix each leave a
|
|
193
|
+
// path that no longer resolves, and a union that never prunes keeps it forever --
|
|
194
|
+
// after which compact writes to a file that is not there and doctor reports a
|
|
195
|
+
// failure while naming the path that is fine.
|
|
196
|
+
const existing = (loadConfig().skillPaths || []).filter((p) => existsSync(p));
|
|
197
|
+
saveConfig({ skillPaths: [...new Set([...existing, ...skillPaths])] });
|
|
198
|
+
|
|
199
|
+
// compact is passed in so this module does not pull the database, and the whole
|
|
200
|
+
// npm postinstall path with it, into memory just to make some symlinks.
|
|
201
|
+
const result = compactFn ? compactFn() : null;
|
|
202
|
+
return {
|
|
203
|
+
targets,
|
|
204
|
+
installed,
|
|
205
|
+
copies,
|
|
206
|
+
skillPaths,
|
|
207
|
+
home: agentHome(),
|
|
208
|
+
digest: result?.digest ?? null,
|
|
209
|
+
notes: result?.indexed ?? 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/staleness.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Staleness detection.
|
|
7
|
+
*
|
|
8
|
+
* A `system` note claiming "auth uses JWT" after the code moved to sessions is
|
|
9
|
+
* worse than no memory at all, because it misleads with confidence. This module
|
|
10
|
+
* cannot prevent that. What it does is make the age of a note visible at the exact
|
|
11
|
+
* moment the note is being used, which is the only moment anyone can act on it.
|
|
12
|
+
*
|
|
13
|
+
* Deterministic, no model involved, no scheduled job: just a commit count.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
|
|
18
|
+
function git(args, cwd) {
|
|
19
|
+
return execFileSync('git', args, {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Repository name for the working directory, or null when not inside one. */
|
|
28
|
+
export function currentRepo(cwd = process.cwd()) {
|
|
29
|
+
const key = `repo\0${cwd}`;
|
|
30
|
+
if (cache.has(key)) return cache.get(key);
|
|
31
|
+
let repo = null;
|
|
32
|
+
try {
|
|
33
|
+
repo = basename(git(['rev-parse', '--show-toplevel'], cwd));
|
|
34
|
+
} catch {
|
|
35
|
+
repo = null;
|
|
36
|
+
}
|
|
37
|
+
cache.set(key, repo);
|
|
38
|
+
return repo;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How many commits have landed since `sha`.
|
|
43
|
+
*
|
|
44
|
+
* Reachability is checked first so that a rebased or pruned commit reports
|
|
45
|
+
* `unreachable` instead of surfacing a git error to someone who only asked a
|
|
46
|
+
* question about their own codebase.
|
|
47
|
+
*/
|
|
48
|
+
export function commitsSince(sha, cwd = process.cwd()) {
|
|
49
|
+
if (!sha) return { status: 'no-sha' };
|
|
50
|
+
const key = `count\0${cwd}\0${sha}`;
|
|
51
|
+
if (cache.has(key)) return cache.get(key);
|
|
52
|
+
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
git(['rev-parse', '--git-dir'], cwd);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
result = { status: err.code === 'ENOENT' ? 'no-git' : 'no-repo' };
|
|
58
|
+
cache.set(key, result);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
git(['cat-file', '-e', `${sha}^{commit}`], cwd);
|
|
64
|
+
} catch {
|
|
65
|
+
result = { status: 'unreachable' };
|
|
66
|
+
cache.set(key, result);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const count = Number.parseInt(git(['rev-list', '--count', `${sha}..HEAD`], cwd), 10);
|
|
72
|
+
result = Number.isFinite(count) ? { status: 'ok', count } : { status: 'unreachable' };
|
|
73
|
+
} catch {
|
|
74
|
+
// Reachable but not countable: HEAD may be unborn on a fresh repository.
|
|
75
|
+
result = { status: 'unreachable' };
|
|
76
|
+
}
|
|
77
|
+
cache.set(key, result);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The annotation for a node, or null when there is nothing worth saying.
|
|
83
|
+
*
|
|
84
|
+
* Only annotates when the current repository is one the note claims, because a
|
|
85
|
+
* commit count taken against unrelated history is noise dressed as a signal. A note
|
|
86
|
+
* scoped to another project is left alone rather than flagged as rewritten.
|
|
87
|
+
*/
|
|
88
|
+
export function annotate(node, { cwd = process.cwd(), cfg = loadConfig(), repo } = {}) {
|
|
89
|
+
if (!node?.captured_sha) return null;
|
|
90
|
+
const here = repo ?? currentRepo(cwd);
|
|
91
|
+
if (!here) return null;
|
|
92
|
+
if (Array.isArray(node.repos) && node.repos.length && !node.repos.includes(here)) return null;
|
|
93
|
+
|
|
94
|
+
const res = commitsSince(node.captured_sha, cwd);
|
|
95
|
+
if (res.status === 'unreachable') return 'history rewritten, verify';
|
|
96
|
+
if (res.status !== 'ok') return null;
|
|
97
|
+
if (res.count < cfg.staleAnnotateCommits) return null;
|
|
98
|
+
return `captured ${res.count} commits ago — verify before trusting`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The same signal in structured form, for `--json` consumers. */
|
|
102
|
+
export function staleness(node, opts = {}) {
|
|
103
|
+
const note = annotate(node, opts);
|
|
104
|
+
if (!note) return null;
|
|
105
|
+
const res = commitsSince(node.captured_sha, opts.cwd ?? process.cwd());
|
|
106
|
+
return {
|
|
107
|
+
status: res.status === 'ok' ? 'stale' : 'rewritten',
|
|
108
|
+
commits: res.status === 'ok' ? res.count : null,
|
|
109
|
+
note,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Nodes far enough behind to be worth a deliberate review, for `doctor`.
|
|
115
|
+
*
|
|
116
|
+
* Annotation happens where knowledge is used; this list exists so an operator can
|
|
117
|
+
* also go looking, rather than waiting to be told one note at a time.
|
|
118
|
+
*/
|
|
119
|
+
export function reviewCandidates(db, { cwd = process.cwd(), cfg = loadConfig() } = {}) {
|
|
120
|
+
const here = currentRepo(cwd);
|
|
121
|
+
if (!here) return [];
|
|
122
|
+
|
|
123
|
+
const rows = db
|
|
124
|
+
.prepare(`
|
|
125
|
+
SELECT id, type, title, captured_sha
|
|
126
|
+
FROM nodes
|
|
127
|
+
WHERE archived = 0 AND captured_sha IS NOT NULL
|
|
128
|
+
ORDER BY id
|
|
129
|
+
`)
|
|
130
|
+
.all();
|
|
131
|
+
|
|
132
|
+
const repoStmt = db.prepare('SELECT repo FROM node_repos WHERE node_id = ?');
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
const repos = repoStmt.all(row.id).map((r) => r.repo);
|
|
136
|
+
if (repos.length && !repos.includes(here)) continue;
|
|
137
|
+
|
|
138
|
+
const res = commitsSince(row.captured_sha, cwd);
|
|
139
|
+
if (res.status === 'unreachable') {
|
|
140
|
+
out.push({ ...row, reason: 'history rewritten', commits: null });
|
|
141
|
+
} else if (res.status === 'ok' && res.count >= cfg.staleReviewCommits) {
|
|
142
|
+
out.push({ ...row, reason: 'far behind HEAD', commits: res.count });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Testing seam: the per-process cache would otherwise outlive a fixture repo. */
|
|
149
|
+
export function resetCache() {
|
|
150
|
+
cache.clear();
|
|
151
|
+
}
|
package/src/store.js
ADDED
|
Binary file
|
package/src/targets.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the agents on this machine look for their instructions.
|
|
7
|
+
*
|
|
8
|
+
* Different tools read different places, and a user should not have to know which
|
|
9
|
+
* one they are using. `setup` probes for all of them and installs wherever a tool is
|
|
10
|
+
* actually present, then reports what it found and what it skipped.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here is guessed. A path appears in this file only when the mechanism is
|
|
13
|
+
* documented or observed; a tool with no verifiable user-global hook is detected and
|
|
14
|
+
* reported rather than written to speculatively.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Home, overridable so a test never touches a real profile. */
|
|
18
|
+
export function agentHome() {
|
|
19
|
+
return process.env.AGENT_MEMORY_SKILLS_HOME || process.env.USERPROFILE || homedir();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* VS Code and its forks keep user data in a per-application directory, and prompt
|
|
24
|
+
* files live in `User/prompts` inside it. The forks are included because they
|
|
25
|
+
* inherit the mechanism; on one that does not, an extra markdown file is inert.
|
|
26
|
+
*/
|
|
27
|
+
const VSCODE_FLAVOURS = [
|
|
28
|
+
['vscode', 'VS Code', 'Code'],
|
|
29
|
+
['vscode-insiders', 'VS Code Insiders', 'Code - Insiders'],
|
|
30
|
+
['vscodium', 'VSCodium', 'VSCodium'],
|
|
31
|
+
['cursor', 'Cursor', 'Cursor'],
|
|
32
|
+
['windsurf', 'Windsurf', 'Windsurf'],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function vscodeUserDir(flavourDir, home = agentHome()) {
|
|
36
|
+
if (platform() === 'darwin') {
|
|
37
|
+
return join(home, 'Library', 'Application Support', flavourDir, 'User');
|
|
38
|
+
}
|
|
39
|
+
if (platform() === 'win32') {
|
|
40
|
+
// %APPDATA% is authoritative on Windows, but it points at the real profile, so
|
|
41
|
+
// honouring it under an overridden home would let a test write into the actual
|
|
42
|
+
// VS Code installation. When the home is overridden, derive the path instead.
|
|
43
|
+
const overridden = Boolean(process.env.AGENT_MEMORY_SKILLS_HOME);
|
|
44
|
+
const appData = (!overridden && process.env.APPDATA) || join(home, 'AppData', 'Roaming');
|
|
45
|
+
return join(appData, flavourDir, 'User');
|
|
46
|
+
}
|
|
47
|
+
return join(home, '.config', flavourDir, 'User');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Codex keeps skills at `$CODEX_HOME/skills/<name>/SKILL.md`, the same layout Claude
|
|
52
|
+
* Code uses. Observed rather than assumed: its bundled `skill-installer` skill states
|
|
53
|
+
* the location, and the shipped system skills sit there in exactly that shape.
|
|
54
|
+
*/
|
|
55
|
+
export function codexHome(home = agentHome()) {
|
|
56
|
+
// As with %APPDATA%, CODEX_HOME points at the real profile, so honouring it under an
|
|
57
|
+
// overridden home would let a test write into the actual Codex installation.
|
|
58
|
+
const overridden = Boolean(process.env.AGENT_MEMORY_SKILLS_HOME);
|
|
59
|
+
return (!overridden && process.env.CODEX_HOME) || join(home, '.codex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every VS Code-family user directory that exists on this machine. */
|
|
63
|
+
export function vscodeUserDirs(home = agentHome()) {
|
|
64
|
+
return VSCODE_FLAVOURS.map(([id, label, dir]) => ({
|
|
65
|
+
id,
|
|
66
|
+
label,
|
|
67
|
+
userDir: vscodeUserDir(dir, home),
|
|
68
|
+
})).filter((f) => existsSync(f.userDir));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every install location, with whether the tool behind it is actually here.
|
|
73
|
+
*
|
|
74
|
+
* `kind` decides the format written:
|
|
75
|
+
* skill-dir -> <dir>/<name>/SKILL.md, linked
|
|
76
|
+
* prompt-dir -> <dir>/<name>.prompt.md, written
|
|
77
|
+
*/
|
|
78
|
+
export function detectTargets(home = agentHome()) {
|
|
79
|
+
const targets = [
|
|
80
|
+
{
|
|
81
|
+
id: 'agents',
|
|
82
|
+
label: 'Agent skills (shared convention)',
|
|
83
|
+
kind: 'skill-dir',
|
|
84
|
+
dir: join(home, '.agents', 'skills'),
|
|
85
|
+
// Always installed: this is the location this project defines, so its absence
|
|
86
|
+
// means "not set up yet" rather than "tool not present".
|
|
87
|
+
detected: true,
|
|
88
|
+
note: 'read by GitHub Copilot agent skills',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'claude-code',
|
|
92
|
+
label: 'Claude Code (CLI and VS Code extension)',
|
|
93
|
+
kind: 'skill-dir',
|
|
94
|
+
dir: join(home, '.claude', 'skills'),
|
|
95
|
+
detected: existsSync(join(home, '.claude')),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'codex',
|
|
99
|
+
label: 'Codex CLI',
|
|
100
|
+
kind: 'skill-dir',
|
|
101
|
+
dir: join(codexHome(home), 'skills'),
|
|
102
|
+
detected: existsSync(codexHome(home)),
|
|
103
|
+
note: 'same SKILL.md layout as Claude Code',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const f of vscodeUserDirs(home)) {
|
|
108
|
+
targets.push({
|
|
109
|
+
id: f.id,
|
|
110
|
+
label: `${f.label} (Copilot chat prompt files)`,
|
|
111
|
+
kind: 'prompt-dir',
|
|
112
|
+
dir: join(f.userDir, 'prompts'),
|
|
113
|
+
detected: true,
|
|
114
|
+
note: 'invoked as /recall, /remember, /handoff in chat',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Detected and deliberately not written to. Copilot CLI documents no user-global
|
|
119
|
+
// prompt or skill directory, and writing into its config folder on a guess is how
|
|
120
|
+
// you ship a file that does nothing while claiming support for it.
|
|
121
|
+
const copilotCli = join(home, '.copilot');
|
|
122
|
+
if (existsSync(copilotCli)) {
|
|
123
|
+
targets.push({
|
|
124
|
+
id: 'copilot-cli',
|
|
125
|
+
label: 'Copilot CLI',
|
|
126
|
+
kind: 'unsupported',
|
|
127
|
+
dir: copilotCli,
|
|
128
|
+
detected: true,
|
|
129
|
+
note: 'no user-global prompt directory; use .github/copilot-instructions.md per repo',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return targets;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The targets setup will actually write to. */
|
|
137
|
+
export function installableTargets(home = agentHome()) {
|
|
138
|
+
return detectTargets(home).filter((t) => t.detected && t.kind !== 'unsupported');
|
|
139
|
+
}
|