@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/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
+ }
@@ -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
+ }