@iamem/amem 0.1.2 → 0.2.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 (47) hide show
  1. package/README.md +64 -1
  2. package/dist/api/routes.js +337 -3
  3. package/dist/attest.d.ts +13 -0
  4. package/dist/attest.js +44 -0
  5. package/dist/capture.js +14 -5
  6. package/dist/cli.js +291 -6
  7. package/dist/context.d.ts +10 -1
  8. package/dist/context.js +105 -3
  9. package/dist/db.d.ts +141 -0
  10. package/dist/db.js +398 -0
  11. package/dist/embed.js +5 -14
  12. package/dist/estimate.d.ts +25 -1
  13. package/dist/estimate.js +36 -3
  14. package/dist/freshness.d.ts +7 -0
  15. package/dist/freshness.js +8 -1
  16. package/dist/hook.js +8 -1
  17. package/dist/hygiene.d.ts +26 -2
  18. package/dist/hygiene.js +42 -3
  19. package/dist/install/hosts.d.ts +15 -0
  20. package/dist/install/hosts.js +82 -4
  21. package/dist/install/skills.js +10 -5
  22. package/dist/kinds.d.ts +18 -0
  23. package/dist/kinds.js +80 -3
  24. package/dist/license.d.ts +1 -0
  25. package/dist/license.js +21 -19
  26. package/dist/mcp.js +221 -0
  27. package/dist/platforms.js +6 -0
  28. package/dist/policy.d.ts +6 -0
  29. package/dist/policy.js +16 -1
  30. package/dist/remember-contract.js +13 -4
  31. package/dist/repo-identity.d.ts +10 -0
  32. package/dist/repo-identity.js +20 -1
  33. package/dist/skill-capture.d.ts +43 -0
  34. package/dist/skill-capture.js +146 -0
  35. package/dist/skills.d.ts +106 -0
  36. package/dist/skills.js +422 -0
  37. package/docs/backlog.md +9 -0
  38. package/package.json +2 -1
  39. package/scripts/mcp-launch.sh +26 -0
  40. package/skills/amem-tasks/SKILL.md +100 -0
  41. package/skills/amem-write-skill/SKILL.md +99 -0
  42. package/templates/cursor-rule.mdc +16 -6
  43. package/templates/policy.deny-default.toml +5 -0
  44. package/templates/policy.example.toml +8 -0
  45. package/ui-static/app.js +458 -233
  46. package/ui-static/index.html +11 -34
  47. package/ui-static/styles.css +310 -1
@@ -0,0 +1,106 @@
1
+ export declare const SKILL_FILE = "SKILL.md";
2
+ /** Subdirectories a skill may carry, matching the agentskills.io layout. */
3
+ export declare const SKILL_ASSET_DIRS: string[];
4
+ export type SkillSource = "local" | "bundled" | "import";
5
+ export type SkillMeta = {
6
+ name: string;
7
+ description: string;
8
+ version: string | null;
9
+ tags: string[];
10
+ /** Absolute path to the skill's SKILL.md. */
11
+ path: string;
12
+ /** Absolute path to the skill directory. */
13
+ dir: string;
14
+ hash: string;
15
+ source: SkillSource;
16
+ };
17
+ export declare function skillsDir(): string;
18
+ export declare function ensureSkillsDir(): string;
19
+ export declare function hashSkillContent(content: string): string;
20
+ /**
21
+ * Skill names become directory names and slash commands, so keep them to the identifier
22
+ * shape the ecosystem uses and never let one escape the skills directory.
23
+ */
24
+ export declare function slugifySkillName(raw: string): string;
25
+ export declare function isValidSkillName(raw: string): boolean;
26
+ export type Frontmatter = {
27
+ meta: Record<string, string | string[]>;
28
+ body: string;
29
+ };
30
+ /**
31
+ * Minimal YAML-frontmatter reader — enough for the scalar and inline-list keys skills
32
+ * actually use. Nested keys are flattened to their leaf (`metadata.hermes.tags` -> `tags`)
33
+ * so a Hermes-authored skill and a Cursor-authored one both parse.
34
+ */
35
+ export declare function parseFrontmatter(raw: string): Frontmatter;
36
+ export declare function readSkillMeta(dir: string, source?: SkillSource): SkillMeta | null;
37
+ /** Every skill on disk, sorted by name. Skips dot/underscore dirs like the hub state. */
38
+ export declare function scanSkills(root?: string): SkillMeta[];
39
+ export declare function findSkillOnDisk(name: string, root?: string): SkillMeta | null;
40
+ export declare function skillDirFor(name: string, root?: string): string;
41
+ export declare function readSkillBody(name: string, root?: string): string | null;
42
+ /**
43
+ * Read a supporting file (`references/foo.md`). Skills come from other people, so the
44
+ * path is resolved and re-checked rather than trusted.
45
+ */
46
+ export declare function readSkillAsset(name: string, relPath: string, root?: string): string | null;
47
+ export declare function listSkillAssets(name: string, root?: string): string[];
48
+ /** Render a SKILL.md from parts, for `amem skills new` and agent-authored saves. */
49
+ export declare function renderSkillMarkdown(input: {
50
+ name: string;
51
+ description: string;
52
+ body?: string;
53
+ version?: string;
54
+ tags?: string[];
55
+ }): string;
56
+ export declare function writeSkill(name: string, content: string, root?: string): {
57
+ name: string;
58
+ path: string;
59
+ hash: string;
60
+ };
61
+ export declare function deleteSkill(name: string, root?: string): boolean;
62
+ export type SkillScan = {
63
+ ok: true;
64
+ } | {
65
+ ok: false;
66
+ reason: string;
67
+ };
68
+ /**
69
+ * Gate content before it lands in the library. Deny patterns come from policy so an IT
70
+ * operator's additions apply to skills too, not just claims.
71
+ */
72
+ export declare function scanSkillContent(content: string, denyPatterns?: RegExp[]): SkillScan;
73
+ /**
74
+ * Import a skill from a local directory. Local paths only — no registries and no network,
75
+ * which keeps this on the right side of the "no cloud, no hosted anything" line.
76
+ * Supporting files come along, but only from the allowlisted asset directories.
77
+ */
78
+ export declare function importSkillFromPath(sourcePath: string, overrideName?: string, root?: string): {
79
+ name: string;
80
+ path: string;
81
+ files: string[];
82
+ };
83
+ export type IndexedSkill = SkillMeta & {
84
+ repoId: string | null;
85
+ uses: number;
86
+ lastUsedAt: string | null;
87
+ /** True when the file changed since it was installed — do not overwrite these. */
88
+ modified: boolean;
89
+ };
90
+ /**
91
+ * Reconcile the index with disk. Cheap enough to run before any read, which keeps the
92
+ * index honest when a user or agent edits a SKILL.md with ordinary file tools.
93
+ */
94
+ export declare function syncSkillIndex(root?: string): IndexedSkill[];
95
+ export declare function listIndexedSkills(root?: string): IndexedSkill[];
96
+ export type RankedSkill = IndexedSkill & {
97
+ score: number;
98
+ reasons: string[];
99
+ };
100
+ /**
101
+ * Rank skills for a query. Deliberately matches on the index fields only — name,
102
+ * description, tags — because the whole point is to decide what is worth loading
103
+ * without paying for the bodies.
104
+ */
105
+ export declare function skillSummary(s: SkillMeta | IndexedSkill): Record<string, unknown>;
106
+ export declare function rankSkills(skills: IndexedSkill[], query: string, limit?: number): RankedSkill[];
package/dist/skills.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Procedural memory. Claims are small facts that ride in every packet; a skill is a
3
+ * longer procedure that should only load when it is relevant.
4
+ *
5
+ * Files on disk are the source of truth (`~/.amem/skills/<name>/SKILL.md`), because every
6
+ * other agent tool in this ecosystem — Cursor, Claude, Hermes — discovers skills by
7
+ * scanning folders, and a skill's `references/` and `scripts/` do not fit in a column.
8
+ * SQLite only indexes them for ranking and usage stats; see `src/db.ts`.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
12
+ import { join, resolve, sep } from "node:path";
13
+ import { pruneSkillRows, upsertSkillRow } from "./db.js";
14
+ import { amemHome } from "./paths.js";
15
+ import { compiledDenyPatterns } from "./policy.js";
16
+ import { tokenize } from "./search.js";
17
+ export const SKILL_FILE = "SKILL.md";
18
+ /** Subdirectories a skill may carry, matching the agentskills.io layout. */
19
+ export const SKILL_ASSET_DIRS = ["references", "templates", "scripts", "examples", "assets"];
20
+ export function skillsDir() {
21
+ return join(amemHome(), "skills");
22
+ }
23
+ export function ensureSkillsDir() {
24
+ const dir = skillsDir();
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ return dir;
27
+ }
28
+ export function hashSkillContent(content) {
29
+ return createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
30
+ }
31
+ /**
32
+ * Skill names become directory names and slash commands, so keep them to the identifier
33
+ * shape the ecosystem uses and never let one escape the skills directory.
34
+ */
35
+ export function slugifySkillName(raw) {
36
+ const slug = String(raw || "")
37
+ .trim()
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9_-]+/g, "-")
40
+ .replace(/^-+|-+$/g, "")
41
+ .slice(0, 64);
42
+ return slug;
43
+ }
44
+ export function isValidSkillName(raw) {
45
+ return /^[a-z][a-z0-9_-]*$/.test(String(raw || "")) && String(raw).length <= 64;
46
+ }
47
+ /**
48
+ * Minimal YAML-frontmatter reader — enough for the scalar and inline-list keys skills
49
+ * actually use. Nested keys are flattened to their leaf (`metadata.hermes.tags` -> `tags`)
50
+ * so a Hermes-authored skill and a Cursor-authored one both parse.
51
+ */
52
+ export function parseFrontmatter(raw) {
53
+ const text = String(raw ?? "").replace(/^\uFEFF/, "");
54
+ const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
55
+ if (!match)
56
+ return { meta: {}, body: text.trim() };
57
+ const meta = {};
58
+ for (const line of match[1].split(/\r?\n/)) {
59
+ if (!line.trim() || line.trim().startsWith("#"))
60
+ continue;
61
+ const kv = /^\s*([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
62
+ if (!kv)
63
+ continue;
64
+ const key = kv[1].split(".").pop().toLowerCase();
65
+ const value = kv[2].trim();
66
+ if (!value)
67
+ continue; // a bare `metadata:` parent carries nothing itself
68
+ if (value.startsWith("[") && value.endsWith("]")) {
69
+ meta[key] = value
70
+ .slice(1, -1)
71
+ .split(",")
72
+ .map((v) => stripQuotes(v.trim()))
73
+ .filter(Boolean);
74
+ }
75
+ else {
76
+ meta[key] = stripQuotes(value);
77
+ }
78
+ }
79
+ return { meta, body: text.slice(match[0].length).trim() };
80
+ }
81
+ function stripQuotes(value) {
82
+ const v = value.trim();
83
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
84
+ return v.slice(1, -1);
85
+ }
86
+ return v;
87
+ }
88
+ function metaString(meta, key) {
89
+ const v = meta[key];
90
+ if (typeof v === "string" && v.trim())
91
+ return v.trim();
92
+ return null;
93
+ }
94
+ function metaList(meta, key) {
95
+ const v = meta[key];
96
+ if (Array.isArray(v))
97
+ return v.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim());
98
+ if (typeof v === "string" && v.trim())
99
+ return v.split(/[,\s]+/).filter(Boolean);
100
+ return [];
101
+ }
102
+ /** First markdown heading, used when a skill has no `description:` to show. */
103
+ function firstHeading(body) {
104
+ for (const line of body.split(/\r?\n/)) {
105
+ const h = /^#{1,3}\s+(.+?)\s*$/.exec(line);
106
+ if (h)
107
+ return h[1].trim();
108
+ }
109
+ return "";
110
+ }
111
+ export function readSkillMeta(dir, source = "local") {
112
+ const file = join(dir, SKILL_FILE);
113
+ if (!existsSync(file) || !statSync(file).isFile())
114
+ return null;
115
+ const raw = readFileSync(file, "utf8");
116
+ const { meta, body } = parseFrontmatter(raw);
117
+ // Name resolution mirrors the ecosystem: frontmatter first, then the folder name.
118
+ const declared = metaString(meta, "name");
119
+ const name = isValidSkillName(declared || "") ? declared : slugifySkillName(dir.split(sep).pop() || "");
120
+ if (!name)
121
+ return null;
122
+ return {
123
+ name,
124
+ description: metaString(meta, "description") || firstHeading(body),
125
+ version: metaString(meta, "version"),
126
+ tags: metaList(meta, "tags"),
127
+ path: file,
128
+ dir,
129
+ hash: hashSkillContent(raw),
130
+ source,
131
+ };
132
+ }
133
+ /** Every skill on disk, sorted by name. Skips dot/underscore dirs like the hub state. */
134
+ export function scanSkills(root = skillsDir()) {
135
+ if (!existsSync(root))
136
+ return [];
137
+ const out = [];
138
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
139
+ if (!entry.isDirectory())
140
+ continue;
141
+ if (entry.name.startsWith(".") || entry.name.startsWith("_"))
142
+ continue;
143
+ const dir = join(root, entry.name);
144
+ const meta = readSkillMeta(dir);
145
+ if (meta) {
146
+ out.push(meta);
147
+ continue;
148
+ }
149
+ // One level of category nesting, the way Hermes groups skills (mlops/axolotl).
150
+ for (const child of readdirSync(dir, { withFileTypes: true })) {
151
+ if (!child.isDirectory() || child.name.startsWith("."))
152
+ continue;
153
+ const nested = readSkillMeta(join(dir, child.name));
154
+ if (nested)
155
+ out.push(nested);
156
+ }
157
+ }
158
+ return out.sort((a, b) => a.name.localeCompare(b.name));
159
+ }
160
+ export function findSkillOnDisk(name, root = skillsDir()) {
161
+ const slug = slugifySkillName(name);
162
+ if (!slug)
163
+ return null;
164
+ return scanSkills(root).find((s) => s.name === slug) ?? null;
165
+ }
166
+ export function skillDirFor(name, root = skillsDir()) {
167
+ const slug = slugifySkillName(name);
168
+ if (!isValidSkillName(slug))
169
+ throw new Error(`Invalid skill name: ${name}`);
170
+ return join(root, slug);
171
+ }
172
+ export function readSkillBody(name, root = skillsDir()) {
173
+ const meta = findSkillOnDisk(name, root);
174
+ return meta ? readFileSync(meta.path, "utf8") : null;
175
+ }
176
+ /**
177
+ * Read a supporting file (`references/foo.md`). Skills come from other people, so the
178
+ * path is resolved and re-checked rather than trusted.
179
+ */
180
+ export function readSkillAsset(name, relPath, root = skillsDir()) {
181
+ const meta = findSkillOnDisk(name, root);
182
+ if (!meta)
183
+ return null;
184
+ const base = resolve(meta.dir);
185
+ const target = resolve(base, relPath);
186
+ // resolve() collapses "..", and an absolute relPath lands outside base — both caught here.
187
+ if (target !== base && !target.startsWith(base + sep))
188
+ return null;
189
+ if (!existsSync(target) || !statSync(target).isFile())
190
+ return null;
191
+ return readFileSync(target, "utf8");
192
+ }
193
+ export function listSkillAssets(name, root = skillsDir()) {
194
+ const meta = findSkillOnDisk(name, root);
195
+ if (!meta)
196
+ return [];
197
+ const out = [];
198
+ for (const sub of SKILL_ASSET_DIRS) {
199
+ const dir = join(meta.dir, sub);
200
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
201
+ continue;
202
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
203
+ if (entry.isFile())
204
+ out.push(`${sub}/${entry.name}`);
205
+ }
206
+ }
207
+ return out.sort();
208
+ }
209
+ /** Render a SKILL.md from parts, for `amem skills new` and agent-authored saves. */
210
+ export function renderSkillMarkdown(input) {
211
+ const lines = ["---", `name: ${input.name}`, `description: ${input.description}`];
212
+ if (input.version)
213
+ lines.push(`version: ${input.version}`);
214
+ if (input.tags?.length)
215
+ lines.push(`tags: [${input.tags.join(", ")}]`);
216
+ lines.push("---", "");
217
+ const body = (input.body || "").trim();
218
+ lines.push(body || defaultSkillBody(input.name));
219
+ return `${lines.join("\n")}\n`;
220
+ }
221
+ function defaultSkillBody(name) {
222
+ const title = name.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
223
+ return [
224
+ `# ${title}`,
225
+ "",
226
+ "## When to use",
227
+ "",
228
+ "Describe the trigger — the situation where this procedure applies.",
229
+ "",
230
+ "## Procedure",
231
+ "",
232
+ "1. First step.",
233
+ "2. Second step.",
234
+ "",
235
+ "## Pitfalls",
236
+ "",
237
+ "- Known failure modes and how to get past them.",
238
+ "",
239
+ "## Verification",
240
+ "",
241
+ "How to confirm it worked.",
242
+ ].join("\n");
243
+ }
244
+ export function writeSkill(name, content, root = skillsDir()) {
245
+ const slug = slugifySkillName(name);
246
+ if (!isValidSkillName(slug))
247
+ throw new Error(`Invalid skill name: ${name}`);
248
+ const dir = join(root, slug);
249
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
250
+ const path = join(dir, SKILL_FILE);
251
+ const body = content.endsWith("\n") ? content : `${content}\n`;
252
+ writeFileSync(path, body, { mode: 0o600 });
253
+ return { name: slug, path, hash: hashSkillContent(body) };
254
+ }
255
+ export function deleteSkill(name, root = skillsDir()) {
256
+ const meta = findSkillOnDisk(name, root);
257
+ if (!meta)
258
+ return false;
259
+ rmSync(meta.dir, { recursive: true, force: true });
260
+ return true;
261
+ }
262
+ /**
263
+ * Prompt-injection shapes that have no business in a stored procedure. A skill is riskier
264
+ * than a claim: a claim is a fact the agent reads, a skill is an instruction it follows.
265
+ */
266
+ const SKILL_INJECTION_PATTERNS = [
267
+ {
268
+ re: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+instructions/i,
269
+ reason: "prompt-injection directive",
270
+ },
271
+ { re: /disregard\s+(?:your\s+)?(?:system\s+prompt|safety|guidelines)/i, reason: "safety override" },
272
+ { re: /\bcurl\b[^\n]*\|\s*(?:ba)?sh\b/i, reason: "pipes a remote script into a shell" },
273
+ { re: /rm\s+-rf\s+[~/]\s*(?:$|[^\w/])/im, reason: "destructive filesystem command" },
274
+ {
275
+ re: /(?:curl|wget|fetch)[^\n]*(?:AWS_|API_KEY|SECRET|TOKEN|\.env\b|id_rsa)/i,
276
+ reason: "looks like credential exfiltration",
277
+ },
278
+ ];
279
+ /**
280
+ * Gate content before it lands in the library. Deny patterns come from policy so an IT
281
+ * operator's additions apply to skills too, not just claims.
282
+ */
283
+ export function scanSkillContent(content, denyPatterns) {
284
+ const text = String(content ?? "");
285
+ if (!text.trim())
286
+ return { ok: false, reason: "empty skill content" };
287
+ for (const { re, reason } of SKILL_INJECTION_PATTERNS) {
288
+ if (re.test(text))
289
+ return { ok: false, reason };
290
+ }
291
+ const deny = denyPatterns ?? compiledDenyPatterns();
292
+ for (const re of deny) {
293
+ if (re.test(text))
294
+ return { ok: false, reason: `matches deny pattern ${re.source}` };
295
+ }
296
+ return { ok: true };
297
+ }
298
+ /**
299
+ * Import a skill from a local directory. Local paths only — no registries and no network,
300
+ * which keeps this on the right side of the "no cloud, no hosted anything" line.
301
+ * Supporting files come along, but only from the allowlisted asset directories.
302
+ */
303
+ export function importSkillFromPath(sourcePath, overrideName, root = skillsDir()) {
304
+ const src = resolve(sourcePath);
305
+ if (!existsSync(src))
306
+ throw new Error(`No such path: ${sourcePath}`);
307
+ const srcDir = statSync(src).isDirectory() ? src : join(src, "..");
308
+ const skillFile = statSync(src).isDirectory() ? join(src, SKILL_FILE) : src;
309
+ if (!existsSync(skillFile))
310
+ throw new Error(`No ${SKILL_FILE} found at ${sourcePath}`);
311
+ const raw = readFileSync(skillFile, "utf8");
312
+ const scan = scanSkillContent(raw);
313
+ if (!scan.ok)
314
+ throw new Error(`Refusing to import: ${scan.reason}`);
315
+ const { meta } = parseFrontmatter(raw);
316
+ const declared = typeof meta.name === "string" ? meta.name : "";
317
+ const candidate = overrideName || declared || srcDir.split(sep).pop() || "";
318
+ const slug = slugifySkillName(candidate);
319
+ if (!isValidSkillName(slug)) {
320
+ throw new Error(`Could not derive a skill name from ${sourcePath} — pass --name`);
321
+ }
322
+ const written = writeSkill(slug, raw, root);
323
+ const destDir = join(root, slug);
324
+ const files = [];
325
+ for (const sub of SKILL_ASSET_DIRS) {
326
+ const from = join(srcDir, sub);
327
+ if (!existsSync(from) || !statSync(from).isDirectory())
328
+ continue;
329
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
330
+ if (!entry.isFile())
331
+ continue;
332
+ const target = join(destDir, sub, entry.name);
333
+ mkdirSync(join(destDir, sub), { recursive: true, mode: 0o700 });
334
+ writeFileSync(target, readFileSync(join(from, entry.name)), { mode: 0o600 });
335
+ files.push(`${sub}/${entry.name}`);
336
+ }
337
+ }
338
+ return { name: written.name, path: written.path, files };
339
+ }
340
+ /**
341
+ * Reconcile the index with disk. Cheap enough to run before any read, which keeps the
342
+ * index honest when a user or agent edits a SKILL.md with ordinary file tools.
343
+ */
344
+ export function syncSkillIndex(root = skillsDir()) {
345
+ const found = scanSkills(root);
346
+ const out = [];
347
+ for (const meta of found) {
348
+ const row = upsertSkillRow({
349
+ name: meta.name,
350
+ path: meta.path,
351
+ description: meta.description,
352
+ version: meta.version,
353
+ tags: meta.tags,
354
+ contentHash: meta.hash,
355
+ source: meta.source,
356
+ });
357
+ out.push({
358
+ ...meta,
359
+ repoId: row.repo_id,
360
+ uses: row.uses,
361
+ lastUsedAt: row.last_used_at,
362
+ modified: Boolean(row.origin_hash) && row.origin_hash !== meta.hash,
363
+ });
364
+ }
365
+ pruneSkillRows(found.map((s) => s.name));
366
+ return out;
367
+ }
368
+ export function listIndexedSkills(root = skillsDir()) {
369
+ return syncSkillIndex(root);
370
+ }
371
+ /**
372
+ * Rank skills for a query. Deliberately matches on the index fields only — name,
373
+ * description, tags — because the whole point is to decide what is worth loading
374
+ * without paying for the bodies.
375
+ */
376
+ export function skillSummary(s) {
377
+ return {
378
+ name: s.name,
379
+ description: s.description,
380
+ version: s.version,
381
+ tags: s.tags,
382
+ path: s.path,
383
+ dir: s.dir,
384
+ hash: s.hash,
385
+ source: s.source,
386
+ ...("repoId" in s ? { repoId: s.repoId, uses: s.uses, lastUsedAt: s.lastUsedAt, modified: s.modified } : {}),
387
+ };
388
+ }
389
+ export function rankSkills(skills, query, limit = 3) {
390
+ const tokens = tokenize(query);
391
+ if (tokens.length === 0)
392
+ return [];
393
+ const ranked = [];
394
+ for (const skill of skills) {
395
+ const name = skill.name.toLowerCase();
396
+ const haystack = `${name} ${skill.description} ${skill.tags.join(" ")}`.toLowerCase();
397
+ let score = 0;
398
+ const reasons = [];
399
+ let hits = 0;
400
+ for (const token of tokens) {
401
+ if (name.includes(token)) {
402
+ score += 6;
403
+ hits += 1;
404
+ }
405
+ else if (haystack.includes(token)) {
406
+ score += token.length > 4 ? 3 : 2;
407
+ hits += 1;
408
+ }
409
+ }
410
+ if (hits === 0)
411
+ continue;
412
+ reasons.push(`match+${score}`);
413
+ // A skill that keeps getting used is a better bet than one nobody has opened.
414
+ if (skill.uses > 0) {
415
+ const useBoost = Math.min(4, Math.log2(skill.uses + 1) * 2);
416
+ score += useBoost;
417
+ reasons.push(`used×${skill.uses}`);
418
+ }
419
+ ranked.push({ ...skill, score, reasons });
420
+ }
421
+ return ranked.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit);
422
+ }
package/docs/backlog.md CHANGED
@@ -4,6 +4,7 @@ Updated after completing the Feature Map **Later** phase (local embedding model
4
4
 
5
5
  ## Shipped recently
6
6
 
7
+ - **Agent Tasks Kanban** — per-project board + MCP `amem_task_*` + open tasks in context packets (complement to Memory facts)
7
8
  - FTS retrieval, claim staleness, supersede/conflict
8
9
  - Session-end **draft capture** + Memory approve/dismiss
9
10
  - Memory **edit / delete / pin / search**
@@ -37,6 +38,14 @@ Updated after completing the Feature Map **Later** phase (local embedding model
37
38
 
38
39
  ## Open
39
40
 
41
+ - **Skills in backup/restore** — `createBackup` copies only the DB file, so `~/.amem/skills/`
42
+ is lost on restore. Now sharper than before: the `skills` index, `skill_drafts`, and
43
+ `skill_uses` tables all survive a restore while the SKILL.md bodies they point at do not,
44
+ so a restored machine gets an index of skills that no longer exist. Needs a decision:
45
+ make backups an archive (DB + skills dir), or keep backups DB-only, prune the index on
46
+ restore, and document skills as separately versioned.
47
+ - **Skill drafts have no retention policy** — dismissed and applied rows accumulate. Memory
48
+ drafts have hygiene sweeps; skill drafts do not yet.
40
49
  - Prompt-pack before/after Stats benchmark; restore wizard polish; IT seat pack.
41
50
  - Decide one-time vs subscription (offline files cannot revoke on cancel unless you add `expires_at` and re-issue).
42
51
  - Optional vendored ONNX/MiniLM weights in a paid pack (external command is the local hook today).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamem/amem",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Local personal agent memory for Cursor and Claude Code. Private to your machine — never shared.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "templates",
22
22
  "docs",
23
23
  "ui-static",
24
+ "scripts/mcp-launch.sh",
24
25
  "scripts/mdm-offboard.sh",
25
26
  "scripts/postinstall.js"
26
27
  ],
@@ -0,0 +1,26 @@
1
+ #!/bin/sh
2
+ # amem MCP launcher for GUI hosts (Claude Desktop / Cowork).
3
+ #
4
+ # GUI apps are not launched from a login shell, so they inherit a minimal PATH
5
+ # with no Homebrew and no nvm. A host configured with a bare `amem` or `node`
6
+ # command registers the connector but never completes tool discovery. This
7
+ # script resolves node at spawn time and execs the amem stdio MCP server.
8
+ #
9
+ # Set AMEM_NODE to pin a specific node binary. Extra args are passed through.
10
+ DIR=$(cd "$(dirname "$0")/.." && pwd)
11
+ NODE=""
12
+ for c in \
13
+ "$AMEM_NODE" \
14
+ "$(command -v node 2>/dev/null)" \
15
+ /opt/homebrew/bin/node \
16
+ /usr/local/bin/node \
17
+ "$HOME/.homebrew/bin/node" \
18
+ /usr/bin/node
19
+ do
20
+ if [ -n "$c" ] && [ -x "$c" ]; then NODE="$c"; break; fi
21
+ done
22
+ if [ -z "$NODE" ]; then
23
+ echo "amem-mcp: node 20+ not found. Set AMEM_NODE=/path/to/node in the host's MCP env." >&2
24
+ exit 1
25
+ fi
26
+ exec "$NODE" "$DIR/dist/cli.js" mcp "$@"
@@ -0,0 +1,100 @@
1
+ ---
2
+ description: Manage deferred tasks and Kanban lifecycle in local personal amem memory so work is preserved across sessions and available for context retrieval.
3
+ ---
4
+
5
+ # amem-tasks
6
+
7
+ Track deferred work, multi-step progress, and backlog items in local amem memory so tasks never get lost across chat sessions.
8
+
9
+ ## Privacy
10
+
11
+ Memory is personal and stored under `~/.amem` on this machine. Tasks stay local and are never committed to remote repositories.
12
+
13
+ ## When to Use
14
+
15
+ 1. **Multi-step work**: When working on complex tasks or features with 3+ steps, create and manage tasks to track progress.
16
+ 2. **Deferred work**: When user or agent identifies follow-up work ("do X later", "verify Y after deploy", "refactor Z next week"), add it to the project backlog.
17
+ 3. **Session continuity & handoff**: Before ending a session or moving to a new topic, park unfinished items on the board.
18
+ 4. **Context retrieval**: Check `amem_task_list` or context packet `## Open tasks` / `## Tasks` to review current and completed work.
19
+
20
+ ## Task Status Lifecycle
21
+
22
+ - `backlog` — Queued items, follow-ups, and ideas.
23
+ - `next` — Prioritized tasks ready to start soon.
24
+ - `doing` — Currently in progress (keep 1 active task at a time).
25
+ - `blocked` — Stalled on external feedback, bug, or dependency.
26
+ - `done` — Finished work (retained for context retrieval and history).
27
+
28
+ ## Available Tools
29
+
30
+ ### 1. MCP Tools (Cursor, Claude Code, Windsurf, Continue, Zed)
31
+
32
+ - **`amem_task_add`**
33
+ ```json
34
+ {
35
+ "title": "Add rate limiter middleware to /api/auth",
36
+ "body": "Protect login endpoints against brute-force attempts",
37
+ "status": "backlog",
38
+ "anchors": ["src/api/auth.ts"]
39
+ }
40
+ ```
41
+
42
+ - **`amem_task_list`**
43
+ ```json
44
+ {
45
+ "status": "doing",
46
+ "include_done": true
47
+ }
48
+ ```
49
+
50
+ - **`amem_task_update`**
51
+ ```json
52
+ {
53
+ "id": "task_1234abcd",
54
+ "status": "doing",
55
+ "body": "Updated progress notes..."
56
+ }
57
+ ```
58
+
59
+ - **`amem_task_complete`**
60
+ ```json
61
+ {
62
+ "id": "task_1234abcd"
63
+ }
64
+ ```
65
+
66
+ ### 2. CLI Commands (Terminal, Aider, Bash scripts)
67
+
68
+ - **List tasks**:
69
+ ```bash
70
+ amem task list
71
+ amem task list --status doing
72
+ amem task list --include-done --all
73
+ ```
74
+
75
+ - **Add task**:
76
+ ```bash
77
+ amem task add "Add rate limiter middleware" --body "Protect /api/auth" --status backlog --anchor src/api/auth.ts
78
+ ```
79
+
80
+ - **Update task**:
81
+ ```bash
82
+ amem task update <id> --status doing
83
+ ```
84
+
85
+ - **Complete task**:
86
+ ```bash
87
+ amem task complete <id>
88
+ ```
89
+
90
+ - **Delete task**:
91
+ ```bash
92
+ amem task delete <id>
93
+ ```
94
+
95
+ ## Instructions for Agents
96
+
97
+ 1. **Deconstruct**: When starting a non-trivial user request, add the plan items to `amem_task_add`.
98
+ 2. **Track**: Mark the current working task as `doing` with `amem_task_update`.
99
+ 3. **Complete**: When a task is finished, call `amem_task_complete` immediately. Completed tasks stay stored in local memory and are searchable in context retrieval.
100
+ 4. **Learn**: If completing the task revealed durable repository facts (constraints, gotchas, ownership), save them with `amem_remember` or `amem-update-working-memory`.