@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
package/dist/mcp.js CHANGED
@@ -83,6 +83,122 @@ const TOOLS = [
83
83
  properties: { workspace: { type: "string" } },
84
84
  },
85
85
  },
86
+ {
87
+ name: "amem_skill_list",
88
+ description: "List stored skills (procedures the agent learned) as an index of names and descriptions only. Cheap — call this first, then amem_skill_view to load the one you need. Skills are longer procedures; use amem_context for small durable facts.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ query: {
93
+ type: "string",
94
+ description: "Optional: rank the index against this task description",
95
+ },
96
+ },
97
+ },
98
+ },
99
+ {
100
+ name: "amem_skill_view",
101
+ description: "Load the full body of one skill by name, or a supporting file inside it. Call this only after amem_skill_list (or the 'Relevant skills' section of an amem context packet) shows a skill worth following.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ name: { type: "string", description: "Skill name from amem_skill_list" },
106
+ file: {
107
+ type: "string",
108
+ description: "Optional supporting file, e.g. references/api.md",
109
+ },
110
+ sessionId: {
111
+ type: "string",
112
+ description: "Optional conversation id, so amem can tell whether this procedure actually worked out",
113
+ },
114
+ },
115
+ required: ["name"],
116
+ },
117
+ },
118
+ {
119
+ name: "amem_skill_save",
120
+ description: "Save a non-trivial multi-step procedure you just worked out as a reusable skill. Use after solving something worth repeating — a workflow, a dead end you found the way past, or a correction the user gave you. Provide a full SKILL.md in `content`, or a description plus body.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ name: {
125
+ type: "string",
126
+ description: "Short slug, e.g. deploy-staging",
127
+ },
128
+ description: {
129
+ type: "string",
130
+ description: "One line on when to use this skill",
131
+ },
132
+ content: {
133
+ type: "string",
134
+ description: "Full SKILL.md, or the markdown body to wrap",
135
+ },
136
+ sessionId: {
137
+ type: "string",
138
+ description: "Optional conversation id, so amem can clear the nudge that prompted this",
139
+ },
140
+ },
141
+ required: ["name"],
142
+ },
143
+ },
144
+ {
145
+ name: "amem_task_list",
146
+ description: "List deferred agent tasks (Kanban) for a workspace/repo. Use for work to do later — not durable facts (use amem_remember for those). Default excludes done tasks.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ workspace: { type: "string" },
151
+ status: {
152
+ type: "string",
153
+ description: "backlog | next | doing | blocked | done",
154
+ },
155
+ include_done: { type: "boolean" },
156
+ },
157
+ },
158
+ },
159
+ {
160
+ name: "amem_task_add",
161
+ description: "Create a deferred agent task on the project Kanban (default backlog). Use for work that should not get lost in chat. Durable facts still go through amem_remember.",
162
+ inputSchema: {
163
+ type: "object",
164
+ properties: {
165
+ title: { type: "string" },
166
+ body: { type: "string", description: "Why / notes" },
167
+ workspace: { type: "string" },
168
+ status: { type: "string", description: "backlog | next | doing | blocked | done" },
169
+ anchors: { type: "array", items: { type: "string" } },
170
+ },
171
+ required: ["title"],
172
+ },
173
+ },
174
+ {
175
+ name: "amem_task_update",
176
+ description: "Update a task title, notes, anchors, or move its Kanban status (backlog/next/doing/blocked/done).",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ id: { type: "string" },
181
+ workspace: { type: "string" },
182
+ title: { type: "string" },
183
+ body: { type: "string" },
184
+ status: { type: "string" },
185
+ anchors: { type: "array", items: { type: "string" } },
186
+ },
187
+ required: ["id"],
188
+ },
189
+ },
190
+ {
191
+ name: "amem_task_complete",
192
+ description: "Mark a deferred agent task as done on the Kanban board.",
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ id: { type: "string" },
197
+ workspace: { type: "string" },
198
+ },
199
+ required: ["id"],
200
+ },
201
+ },
86
202
  ];
87
203
  function textResult(text, isError = false) {
88
204
  return {
@@ -127,6 +243,23 @@ function parseDays(value, fallback = 30) {
127
243
  return String(fallback);
128
244
  return String(Math.min(365, Math.floor(n)));
129
245
  }
246
+ /**
247
+ * Keep the skill index at level 0. Returning bodies here would defeat the point of having
248
+ * a separate view call, so this hands back names and descriptions only.
249
+ */
250
+ function compactSkillIndex(body, ranked) {
251
+ const source = (body ?? {});
252
+ const list = ranked ? (source.matches ?? []) : (source.skills ?? []);
253
+ return {
254
+ skills: list.map((s) => ({
255
+ name: s.name,
256
+ description: s.description,
257
+ tags: s.tags,
258
+ uses: s.uses,
259
+ })),
260
+ hint: "Call amem_skill_view with a name to load the full procedure.",
261
+ };
262
+ }
130
263
  function compactGraph(body) {
131
264
  if (!body || typeof body !== "object")
132
265
  return body;
@@ -255,6 +388,94 @@ function callTool(name, args, fallbackWorkspace) {
255
388
  const result = api("GET", "/api/status", null, { workspace });
256
389
  return apiResult(result);
257
390
  }
391
+ if (name === "amem_skill_list") {
392
+ const query = {};
393
+ if (typeof args.query === "string" && args.query.trim())
394
+ query.q = args.query.trim();
395
+ const result = api("GET", "/api/skills", null, { query });
396
+ if (result.status >= 400)
397
+ return apiResult(result);
398
+ return jsonResult(compactSkillIndex(result.body, Boolean(query.q)));
399
+ }
400
+ if (name === "amem_skill_view") {
401
+ const skillName = typeof args.name === "string" ? args.name.trim() : "";
402
+ if (!skillName)
403
+ return textResult("name is required", true);
404
+ const query = { name: skillName };
405
+ if (typeof args.file === "string" && args.file.trim())
406
+ query.file = args.file.trim();
407
+ if (typeof args.sessionId === "string" && args.sessionId.trim()) {
408
+ query.session_id = args.sessionId.trim();
409
+ }
410
+ const result = api("GET", "/api/skills/view", null, { query });
411
+ if (result.status >= 400)
412
+ return apiResult(result);
413
+ const body = result.body;
414
+ // The body is markdown the agent should read as-is, not JSON to re-parse.
415
+ return textResult(typeof body?.content === "string" ? body.content : JSON.stringify(body));
416
+ }
417
+ if (name === "amem_skill_save") {
418
+ const skillName = typeof args.name === "string" ? args.name.trim() : "";
419
+ if (!skillName)
420
+ return textResult("name is required", true);
421
+ const result = api("POST", "/api/skills", {
422
+ name: skillName,
423
+ description: typeof args.description === "string" ? args.description : "",
424
+ content: typeof args.content === "string" ? args.content : "",
425
+ session_id: typeof args.sessionId === "string" ? args.sessionId : undefined,
426
+ }, { workspace });
427
+ return apiResult(result);
428
+ }
429
+ if (name === "amem_task_list") {
430
+ const query = {};
431
+ if (typeof args.status === "string" && args.status.trim())
432
+ query.status = args.status.trim();
433
+ if (args.include_done === true)
434
+ query.include_done = "1";
435
+ const result = api("GET", "/api/tasks", null, { workspace, query });
436
+ if (result.status >= 400)
437
+ return apiResult(result);
438
+ return jsonResult(result.body);
439
+ }
440
+ if (name === "amem_task_add") {
441
+ const title = typeof args.title === "string" ? args.title : "";
442
+ if (!title.trim())
443
+ return textResult("title is required", true);
444
+ const result = api("POST", "/api/tasks", {
445
+ title,
446
+ body: typeof args.body === "string" ? args.body : "",
447
+ status: typeof args.status === "string" ? args.status : "backlog",
448
+ anchors: args.anchors,
449
+ source: "mcp",
450
+ }, { workspace });
451
+ if (result.status >= 400)
452
+ return textResult(JSON.stringify(result.body), true);
453
+ return textResult(JSON.stringify(result.body));
454
+ }
455
+ if (name === "amem_task_update") {
456
+ const id = typeof args.id === "string" ? args.id : "";
457
+ if (!id.trim())
458
+ return textResult("id is required", true);
459
+ const result = api("PATCH", "/api/tasks", {
460
+ id,
461
+ title: typeof args.title === "string" ? args.title : undefined,
462
+ body: typeof args.body === "string" ? args.body : undefined,
463
+ status: typeof args.status === "string" ? args.status : undefined,
464
+ anchors: args.anchors,
465
+ }, { workspace });
466
+ if (result.status >= 400)
467
+ return textResult(JSON.stringify(result.body), true);
468
+ return textResult(JSON.stringify(result.body));
469
+ }
470
+ if (name === "amem_task_complete") {
471
+ const id = typeof args.id === "string" ? args.id : "";
472
+ if (!id.trim())
473
+ return textResult("id is required", true);
474
+ const result = api("POST", "/api/tasks/complete", { id }, { workspace });
475
+ if (result.status >= 400)
476
+ return textResult(JSON.stringify(result.body), true);
477
+ return textResult(JSON.stringify(result.body));
478
+ }
258
479
  return textResult(`Unknown tool: ${name}`, true);
259
480
  }
260
481
  function rpcResult(id, result) {
package/dist/platforms.js CHANGED
@@ -2,6 +2,12 @@
2
2
  export const KNOWN_PLATFORMS = [
3
3
  { id: "cursor", label: "Cursor", hint: "Rules, skills, hooks", installs: "cursor" },
4
4
  { id: "claude", label: "Claude Code", hint: "Skills and settings hooks", installs: "claude" },
5
+ {
6
+ id: "claude-desktop",
7
+ label: "Claude Desktop",
8
+ hint: "MCP config installer",
9
+ installs: "host",
10
+ },
5
11
  { id: "copilot", label: "GitHub Copilot", hint: "MCP / HTTP API" },
6
12
  { id: "codex", label: "ChatGPT / Codex", hint: "MCP / HTTP API" },
7
13
  { id: "gemini", label: "Gemini", hint: "MCP / HTTP API" },
package/dist/policy.d.ts CHANGED
@@ -8,6 +8,12 @@ export type AmemPolicy = {
8
8
  deny_claim_patterns: string[];
9
9
  /** Draft kinds that may auto-apply without Memory approve (still local). */
10
10
  auto_apply_kinds: string[];
11
+ /** Master switch for procedural memory (store, index, injection). */
12
+ skills_enabled: boolean;
13
+ /** Stage agent-authored skills for review instead of writing them straight to disk. */
14
+ skill_write_approval: boolean;
15
+ /** Let session-end suggest skills worth writing up. */
16
+ skill_capture: boolean;
11
17
  };
12
18
  export type PolicySource = {
13
19
  path: string;
package/dist/policy.js CHANGED
@@ -22,6 +22,9 @@ export const DEFAULT_POLICY = {
22
22
  allowed_remote_hosts: [],
23
23
  deny_claim_patterns: [],
24
24
  auto_apply_kinds: [],
25
+ skills_enabled: true,
26
+ skill_write_approval: false,
27
+ skill_capture: true,
25
28
  };
26
29
  let cached = null;
27
30
  export function clearPolicyCache() {
@@ -60,6 +63,9 @@ export function parsePolicyToml(raw) {
60
63
  case "telemetry":
61
64
  case "ui_enabled":
62
65
  case "allow_export":
66
+ case "skills_enabled":
67
+ case "skill_write_approval":
68
+ case "skill_capture":
63
69
  out[key] = parseBool(valueRaw, key, i + 1);
64
70
  break;
65
71
  case "ui_bind":
@@ -131,6 +137,9 @@ function mergePolicy(base, overlay) {
131
137
  allowed_remote_hosts: overlay.allowed_remote_hosts ?? base.allowed_remote_hosts,
132
138
  deny_claim_patterns: overlay.deny_claim_patterns ?? base.deny_claim_patterns,
133
139
  auto_apply_kinds: overlay.auto_apply_kinds ?? base.auto_apply_kinds,
140
+ skills_enabled: overlay.skills_enabled ?? base.skills_enabled,
141
+ skill_write_approval: overlay.skill_write_approval ?? base.skill_write_approval,
142
+ skill_capture: overlay.skill_capture ?? base.skill_capture,
134
143
  };
135
144
  }
136
145
  /**
@@ -198,7 +207,13 @@ export function loadPolicy(forceReload = false) {
198
207
  // Clamp only the exfiltration-relevant knobs — blanking allowed_platforms here would
199
208
  // brick every host instead of protecting anything.
200
209
  if (sources.some((s) => s.error)) {
201
- policy = { ...policy, allow_export: false, auto_apply_kinds: [] };
210
+ policy = {
211
+ ...policy,
212
+ allow_export: false,
213
+ auto_apply_kinds: [],
214
+ // Skills are instructions the agent follows, so an unreadable policy means review.
215
+ skill_write_approval: true,
216
+ };
202
217
  }
203
218
  cached = { policy, sources };
204
219
  return cached;
@@ -64,11 +64,17 @@ Generic MCP host recipe. Same tools for every client — do not fork per product
64
64
 
65
65
  ## Connect
66
66
 
67
- Keep \`amem ui\` running, then attach:
67
+ Two supported transports. Either is fine — pick one.
68
+
69
+ **stdio (no daemon).** The host spawns amem itself. Let amem write the config:
70
+
71
+ \`amem init --platform claude-desktop\`
72
+
73
+ **HTTP (shared daemon).** Keep \`amem ui\` running, then attach:
68
74
 
69
75
  \`${MCP_URL_TEMPLATE}\`
70
76
 
71
- GUI hosts often cannot see Homebrew on PATH. Prefer the HTTP URL over a bare \`amem\` command.
77
+ Whichever you pick, never configure a GUI host with a bare \`amem\` or \`node\` command. GUI apps are not launched from a login shell, so they inherit a minimal PATH with no Homebrew and no nvm: the connector registers but tool discovery never completes, and the host reports no reason. An absolute path — to the launcher or to the node binary — is what makes stdio work.
72
78
 
73
79
  ## Tools
74
80
 
@@ -88,8 +94,11 @@ ${ruleLines}
88
94
  function renderPaste() {
89
95
  return `amem remember contract v${REMEMBER_CONTRACT_VERSION}
90
96
 
91
- Connect (any MCP host): ${MCP_URL_TEMPLATE}
92
- Keep amem ui running. Do not use a bare amem command from GUI apps.
97
+ Connect (any MCP host), either:
98
+ - stdio, no daemon: amem init --platform claude-desktop (or point the host at an absolute launcher path)
99
+ - HTTP, needs amem ui running: ${MCP_URL_TEMPLATE}
100
+
101
+ Never give a GUI host a bare amem or node command — no login PATH, so tool discovery silently fails.
93
102
 
94
103
  Every task:
95
104
  1. amem_context — read local memory first (pass workspace=<slug>)
@@ -9,6 +9,16 @@ export declare function findGitRoot(startPath?: string): string | null;
9
9
  /** Normalize remotes so ssh/https clones of the same repo share a key. */
10
10
  export declare function normalizeRemoteUrl(url: string): string;
11
11
  export declare function detectRepoIdentity(cwd?: string): RepoIdentity;
12
+ /**
13
+ * Why this directory must not become a repo binding, or null if it is fine.
14
+ *
15
+ * detectRepoIdentity falls back to the cwd when there is no git root, so
16
+ * running an init from a home directory would otherwise register the entire
17
+ * home as a "repo" — a binding that matches everything and can never be the
18
+ * right answer. Workspace roots under ~/.amem are created deliberately and are
19
+ * not affected: they are built by workspaceIdentity, not by cwd detection.
20
+ */
21
+ export declare function unbindableRootReason(rootPath: string): string | null;
12
22
  export declare function slugifyWorkspace(name: string): string;
13
23
  export declare function workspaceIdentity(name: string, rootPath: string): RepoIdentity;
14
24
  export declare function parseWorkspaceSlug(remoteUrl: string | null | undefined): string | null;
@@ -1,7 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
- import { resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { parse as parsePath, resolve } from "node:path";
5
6
  function runGit(cwd, args) {
6
7
  try {
7
8
  return execFileSync("git", args, {
@@ -51,6 +52,24 @@ export function detectRepoIdentity(cwd = process.cwd()) {
51
52
  defaultBranch,
52
53
  };
53
54
  }
55
+ /**
56
+ * Why this directory must not become a repo binding, or null if it is fine.
57
+ *
58
+ * detectRepoIdentity falls back to the cwd when there is no git root, so
59
+ * running an init from a home directory would otherwise register the entire
60
+ * home as a "repo" — a binding that matches everything and can never be the
61
+ * right answer. Workspace roots under ~/.amem are created deliberately and are
62
+ * not affected: they are built by workspaceIdentity, not by cwd detection.
63
+ */
64
+ export function unbindableRootReason(rootPath) {
65
+ const resolved = resolve(rootPath);
66
+ const parsed = parsePath(resolved);
67
+ if (resolved === parsed.root)
68
+ return "the filesystem root";
69
+ if (resolved === resolve(homedir()))
70
+ return "your home directory";
71
+ return null;
72
+ }
54
73
  export function slugifyWorkspace(name) {
55
74
  const slug = name
56
75
  .trim()
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Deciding when a session is worth writing up as a skill.
3
+ *
4
+ * This is tuned for precision, not recall. Memory drafts already taught us that a noisy
5
+ * queue is worse than an empty one — a user facing hundreds of suggestions stops reading
6
+ * all of them. A session must show several independent signals before it earns one
7
+ * suggestion, and a session can never produce more than one.
8
+ */
9
+ import { type RepoRow, type SkillDraftRow } from "./db.js";
10
+ export type SessionNote = {
11
+ role: string;
12
+ text: string;
13
+ };
14
+ export type SkillOpportunity = {
15
+ title: string;
16
+ summary: string;
17
+ reasons: string[];
18
+ score: number;
19
+ };
20
+ /**
21
+ * Score a session against the three triggers Hermes names: a multi-step workflow worth
22
+ * repeating, a dead end the agent found the way past, and a correction from the user.
23
+ * Requires at least two distinct signals plus real procedural content.
24
+ */
25
+ export declare function detectSkillOpportunity(notes: SessionNote[]): SkillOpportunity | null;
26
+ /**
27
+ * Queue at most one suggestion per session. The agent, not amem, writes the actual
28
+ * SKILL.md — amem has no model, so it only points at the material.
29
+ */
30
+ export declare function captureSkillSuggestion(input: {
31
+ repo: RepoRow;
32
+ sessionId?: string | null;
33
+ notes: SessionNote[];
34
+ }): SkillDraftRow | null;
35
+ /**
36
+ * A skill was followed this session and things still went wrong — that is the signal the
37
+ * skill itself needs work. Mirrors the existing miss→learn loop, pointed at procedures.
38
+ */
39
+ export declare function captureSkillRevision(input: {
40
+ repo: RepoRow;
41
+ sessionId?: string | null;
42
+ notes: SessionNote[];
43
+ }): SkillDraftRow | null;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Deciding when a session is worth writing up as a skill.
3
+ *
4
+ * This is tuned for precision, not recall. Memory drafts already taught us that a noisy
5
+ * queue is worse than an empty one — a user facing hundreds of suggestions stops reading
6
+ * all of them. A session must show several independent signals before it earns one
7
+ * suggestion, and a session can never produce more than one.
8
+ */
9
+ import { insertSkillDraft, listRecentSkillUses, listSkillsUsedInSession, skillDraftExists, } from "./db.js";
10
+ import { loadPolicy } from "./policy.js";
11
+ /** Numbered or bulleted command-ish steps — the shape of a procedure. */
12
+ const STEP_RE = /^\s*(?:\d+[.)]\s+|[-*]\s+)/gm;
13
+ const COMMAND_RE = /\b(?:npm|pnpm|yarn|npx|git|docker|kubectl|make|cargo|go|python3?|node|psql|aws|terraform|ssh|curl)\s+[\w-]/g;
14
+ const ERROR_RE = /\b(?:error|failed|failure|exception|traceback|not found|cannot find|denied|timed? out|broken|does ?n[o']t work)\b/i;
15
+ const RESOLUTION_RE = /\b(?:fixed|resolved|works now|that did it|success(?:ful)?|passing|green|it works|solved|now working)\b/i;
16
+ const CORRECTION_RE = /\b(?:no,|nope|actually|that'?s wrong|not quite|instead of|don'?t do that|wrong approach|try again)\b/i;
17
+ function assistantText(notes) {
18
+ return notes
19
+ .filter((n) => n.role === "assistant")
20
+ .map((n) => n.text)
21
+ .join("\n\n");
22
+ }
23
+ function userText(notes) {
24
+ return notes
25
+ .filter((n) => n.role === "user")
26
+ .map((n) => n.text)
27
+ .join("\n\n");
28
+ }
29
+ function countMatches(text, re) {
30
+ return (text.match(re) ?? []).length;
31
+ }
32
+ /**
33
+ * Score a session against the three triggers Hermes names: a multi-step workflow worth
34
+ * repeating, a dead end the agent found the way past, and a correction from the user.
35
+ * Requires at least two distinct signals plus real procedural content.
36
+ */
37
+ export function detectSkillOpportunity(notes) {
38
+ if (!Array.isArray(notes) || notes.length < 4)
39
+ return null;
40
+ const assistant = assistantText(notes);
41
+ const user = userText(notes);
42
+ if (assistant.trim().length < 400)
43
+ return null;
44
+ const reasons = [];
45
+ let score = 0;
46
+ const steps = countMatches(assistant, STEP_RE);
47
+ const commands = countMatches(assistant, COMMAND_RE);
48
+ // A procedure needs enumerated steps or repeated concrete commands, not just prose.
49
+ if (steps >= 3) {
50
+ score += 30;
51
+ reasons.push(`${steps} enumerated steps`);
52
+ }
53
+ if (commands >= 3) {
54
+ score += 20;
55
+ reasons.push(`${commands} shell commands`);
56
+ }
57
+ if (steps < 3 && commands < 3)
58
+ return null;
59
+ const hitError = ERROR_RE.test(assistant);
60
+ if (hitError && RESOLUTION_RE.test(assistant)) {
61
+ score += 25;
62
+ reasons.push("hit an error and found the working path");
63
+ }
64
+ if (CORRECTION_RE.test(user)) {
65
+ score += 20;
66
+ reasons.push("user corrected the approach");
67
+ }
68
+ const turns = notes.filter((n) => n.role === "user").length;
69
+ if (turns >= 3) {
70
+ score += 10;
71
+ reasons.push(`${turns} back-and-forth turns`);
72
+ }
73
+ // Two independent signals minimum: one lone heuristic is not evidence of a procedure.
74
+ if (reasons.length < 2 || score < 55)
75
+ return null;
76
+ const firstUser = notes.find((n) => n.role === "user")?.text ?? "";
77
+ const title = firstUser.replace(/\s+/g, " ").trim().slice(0, 96) || "Multi-step workflow";
78
+ return {
79
+ title,
80
+ summary: firstUser.replace(/\s+/g, " ").trim().slice(0, 400),
81
+ reasons,
82
+ score,
83
+ };
84
+ }
85
+ /**
86
+ * Queue at most one suggestion per session. The agent, not amem, writes the actual
87
+ * SKILL.md — amem has no model, so it only points at the material.
88
+ */
89
+ export function captureSkillSuggestion(input) {
90
+ if (!loadPolicy().policy.skill_capture)
91
+ return null;
92
+ const sid = input.sessionId || "unknown";
93
+ const source = `skill-suggest:${sid}`;
94
+ if (skillDraftExists(source))
95
+ return null;
96
+ const opportunity = detectSkillOpportunity(input.notes);
97
+ if (!opportunity)
98
+ return null;
99
+ return insertSkillDraft({
100
+ repoId: input.repo.id,
101
+ title: opportunity.title,
102
+ summary: opportunity.summary,
103
+ kind: "suggestion",
104
+ source,
105
+ sessionId: input.sessionId,
106
+ reasons: opportunity.reasons,
107
+ });
108
+ }
109
+ /**
110
+ * A skill was followed this session and things still went wrong — that is the signal the
111
+ * skill itself needs work. Mirrors the existing miss→learn loop, pointed at procedures.
112
+ */
113
+ export function captureSkillRevision(input) {
114
+ if (!loadPolicy().policy.skill_capture)
115
+ return null;
116
+ const sid = input.sessionId || "";
117
+ // Prefer the exact session; fall back to recent use in this memory, because MCP
118
+ // clients do not reliably pass a session id through to skill views.
119
+ const used = sid ? listSkillsUsedInSession(sid, 3) : [];
120
+ const candidates = used.length > 0 ? used : listRecentSkillUses(input.repo.id, 120, 3);
121
+ if (candidates.length === 0)
122
+ return null;
123
+ const assistant = assistantText(input.notes);
124
+ const user = userText(input.notes);
125
+ const struggled = (ERROR_RE.test(assistant) && RESOLUTION_RE.test(assistant)) || CORRECTION_RE.test(user);
126
+ if (!struggled)
127
+ return null;
128
+ const target = candidates[0];
129
+ const source = `skill-revise:${sid || input.repo.id}:${target}`;
130
+ if (skillDraftExists(source))
131
+ return null;
132
+ const reasons = [
133
+ `followed ${target} but still hit trouble`,
134
+ CORRECTION_RE.test(user) ? "user corrected the approach" : "error then recovery in the session",
135
+ ];
136
+ return insertSkillDraft({
137
+ repoId: input.repo.id,
138
+ title: `Revise ${target}`,
139
+ summary: `The ${target} skill was loaded this session but the work still went sideways. Check whether the procedure is missing a step or a pitfall.`,
140
+ kind: "revision",
141
+ targetSkill: target,
142
+ source,
143
+ sessionId: input.sessionId,
144
+ reasons,
145
+ });
146
+ }