@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.
- package/README.md +64 -1
- package/dist/api/routes.js +337 -3
- package/dist/attest.d.ts +13 -0
- package/dist/attest.js +44 -0
- package/dist/capture.js +14 -5
- package/dist/cli.js +291 -6
- package/dist/context.d.ts +10 -1
- package/dist/context.js +105 -3
- package/dist/db.d.ts +141 -0
- package/dist/db.js +398 -0
- package/dist/embed.js +5 -14
- package/dist/estimate.d.ts +25 -1
- package/dist/estimate.js +36 -3
- package/dist/freshness.d.ts +7 -0
- package/dist/freshness.js +8 -1
- package/dist/hook.js +8 -1
- package/dist/hygiene.d.ts +26 -2
- package/dist/hygiene.js +42 -3
- package/dist/install/hosts.d.ts +15 -0
- package/dist/install/hosts.js +82 -4
- package/dist/install/skills.js +10 -5
- package/dist/kinds.d.ts +18 -0
- package/dist/kinds.js +80 -3
- package/dist/license.d.ts +1 -0
- package/dist/license.js +21 -19
- package/dist/mcp.js +221 -0
- package/dist/platforms.js +6 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +16 -1
- package/dist/remember-contract.js +13 -4
- package/dist/repo-identity.d.ts +10 -0
- package/dist/repo-identity.js +20 -1
- package/dist/skill-capture.d.ts +43 -0
- package/dist/skill-capture.js +146 -0
- package/dist/skills.d.ts +106 -0
- package/dist/skills.js +422 -0
- package/docs/backlog.md +9 -0
- package/package.json +2 -1
- package/scripts/mcp-launch.sh +26 -0
- package/skills/amem-tasks/SKILL.md +100 -0
- package/skills/amem-write-skill/SKILL.md +99 -0
- package/templates/cursor-rule.mdc +16 -6
- package/templates/policy.deny-default.toml +5 -0
- package/templates/policy.example.toml +8 -0
- package/ui-static/app.js +458 -233
- package/ui-static/index.html +11 -34
- package/ui-static/styles.css +310 -1
package/README.md
CHANGED
|
@@ -184,7 +184,8 @@ Tabs after setup:
|
|
|
184
184
|
|
|
185
185
|
1. **Setup** — scan/select repos, platforms, login auto-start, bootstrap proposal
|
|
186
186
|
2. **Memory** — facts by file, scored drafts (approve / replace older / dismiss / reject noisy), edit/pin/delete, search, recent hits/misses
|
|
187
|
-
3. **
|
|
187
|
+
3. **Tasks** — per-project Kanban for deferred agent work (Backlog → Next → Doing → Blocked → Done). MCP: `amem_task_add` / `amem_task_update` / `amem_task_complete`. Open tasks appear in `amem_context`. Use Memory for durable facts; Tasks for “do later.”
|
|
188
|
+
4. **Stats** — estimated tokens saved per LLM, plus JSON / markdown / PDF export (proxies, not a bill)
|
|
188
189
|
|
|
189
190
|
Server-only (no browser open):
|
|
190
191
|
|
|
@@ -253,6 +254,8 @@ Memory is a small local graph in SQLite:
|
|
|
253
254
|
| **Claim** | A durable fact with file anchors (may be `active` or `superseded`; optional pin) |
|
|
254
255
|
| **Edge** | Links (claim → flow → component); `kind: "supersedes"` archives the target claim |
|
|
255
256
|
| **Draft** | Pending session / miss→learn proposals waiting for Memory approve |
|
|
257
|
+
| **Task** | Deferred work on the project Kanban (Backlog / Next / Doing / Blocked / Done) — not a durable fact |
|
|
258
|
+
| **Skill** | A reusable multi-step procedure, stored as a `SKILL.md` file (see below) |
|
|
256
259
|
| **Usage event** | Each `amem context` hit + token estimate |
|
|
257
260
|
|
|
258
261
|
Claims are the retrieval unit. Ranking combines:
|
|
@@ -280,6 +283,60 @@ Example claim:
|
|
|
280
283
|
|
|
281
284
|
---
|
|
282
285
|
|
|
286
|
+
## Skills (procedural memory)
|
|
287
|
+
|
|
288
|
+
Claims answer *what is true*. Skills answer *how we do this here* — a deploy sequence, a
|
|
289
|
+
migration dance, a debugging path someone already walked. They are too long to sit in every
|
|
290
|
+
prompt, so they load on demand.
|
|
291
|
+
|
|
292
|
+
Skills live as `SKILL.md` files under `~/.amem/skills/`, indexed in SQLite for ranking:
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
~/.amem/skills/deploy-staging/SKILL.md
|
|
296
|
+
~/.amem/skills/deploy-staging/references/runbook.md
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Progressive disclosure.** A context packet carries only names and descriptions. The agent
|
|
300
|
+
calls `amem_skill_view` to pull a body once it decides the procedure applies, so an unused
|
|
301
|
+
library of skills costs almost no tokens.
|
|
302
|
+
|
|
303
|
+
**The learning loop.** amem ships no model, so it never writes a skill itself. At session end
|
|
304
|
+
it looks for the shape of a hard-won procedure — enumerated steps or real commands, plus an
|
|
305
|
+
error it recovered from or a correction you gave. When several signals line up it queues one
|
|
306
|
+
suggestion, which reaches your agent as a nudge in the next context packet. The agent writes
|
|
307
|
+
the skill; you approve it. If a skill was loaded during a session that still went sideways,
|
|
308
|
+
amem queues a revision instead of a duplicate.
|
|
309
|
+
|
|
310
|
+
The bar is deliberately high, and a session can queue at most one suggestion.
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
amem skills list # index of what is stored
|
|
314
|
+
amem skills show deploy-staging # full body
|
|
315
|
+
amem skills new deploy-staging --desc "Deploy staging and verify health"
|
|
316
|
+
amem skills import ./some-skill # bring in an agentskills.io skill
|
|
317
|
+
amem skills drafts # pending suggestions and staged writes
|
|
318
|
+
amem skills approve <draft-id>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Skills are also a Skills tab in `amem ui`.
|
|
322
|
+
|
|
323
|
+
**Safety.** Skills are instructions an agent will follow, so content is scanned for
|
|
324
|
+
credentials and prompt-injection patterns before any write. Three policy keys control them:
|
|
325
|
+
|
|
326
|
+
| Key | Default | Effect |
|
|
327
|
+
| --- | --- | --- |
|
|
328
|
+
| `skills_enabled` | `true` | Master switch for storage, ranking, and injection |
|
|
329
|
+
| `skill_write_approval` | `false` | Stage agent writes for review instead of writing to disk |
|
|
330
|
+
| `skill_capture` | `true` | Allow session-end skill suggestions |
|
|
331
|
+
|
|
332
|
+
An unreadable `policy.toml` forces `skill_write_approval` on. `amem doctor --attest` reports
|
|
333
|
+
every installed skill with a content hash, so you can diff what agents are being told to do.
|
|
334
|
+
|
|
335
|
+
> Backups currently copy the database only — `~/.amem/skills/` is not included yet. Keep
|
|
336
|
+
> skills you care about in version control until that lands.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
283
340
|
## Token savings (estimates)
|
|
284
341
|
|
|
285
342
|
Every `amem context` logs a usage event. The UI **Stats** tab breaks this down by platform (`cursor`, `claude`, …).
|
|
@@ -380,6 +437,9 @@ MCP tools (stdio or HTTP):
|
|
|
380
437
|
| `amem_context` | Ranked memory packet for the current question |
|
|
381
438
|
| `amem_remember` | Store a durable fact after an outcome |
|
|
382
439
|
| `amem_recipe` | Generic read-then-write contract (any MCP host) |
|
|
440
|
+
| `amem_skill_list` | Cheap index of stored procedures (names + descriptions only) |
|
|
441
|
+
| `amem_skill_view` | Load one skill body, after the index says it applies |
|
|
442
|
+
| `amem_skill_save` | Store a multi-step procedure you just worked out |
|
|
383
443
|
| `amem_repos` | What is monitored (git repos + named workspaces) |
|
|
384
444
|
| `amem_stats` | Lookup time, estimated tokens/ms saved, hit rate |
|
|
385
445
|
| `amem_graph` | Claims / components / flows stored for a workspace or repo |
|
|
@@ -400,6 +460,8 @@ amem doctor [--attest] [--json]
|
|
|
400
460
|
amem context "<query>" [--workspace <name>] [--platform …]
|
|
401
461
|
amem remember "<text>" [--workspace <name>] [--kind …] [--anchor <path>]
|
|
402
462
|
amem recipe [--json]
|
|
463
|
+
amem skills list|show <name>|new <name> [--desc <text>]|rm <name>|sync|import <path>
|
|
464
|
+
amem skills drafts|approve <draft-id>|dismiss <draft-id>
|
|
403
465
|
amem mcp [--print-config] [--workspace <name>]
|
|
404
466
|
amem propose validate|diff|apply <file.json>
|
|
405
467
|
amem export [--out <file.json>]
|
|
@@ -428,6 +490,7 @@ amem service install|uninstall|status
|
|
|
428
490
|
| `context` | Retrieve a Markdown packet; log usage |
|
|
429
491
|
| `remember` | Store one local fact |
|
|
430
492
|
| `mcp` | Stdio MCP tools; HTTP MCP at `http://127.0.0.1:7843/mcp` while UI runs |
|
|
493
|
+
| `skills` | Manage procedural memory (`list`, `show`, `new`, `import`, `drafts`, `approve`) |
|
|
431
494
|
| `propose diff` | Preview claim/component/flow changes before apply |
|
|
432
495
|
| `propose apply` | Upsert structured memory locally |
|
|
433
496
|
| `lock` / `unlock` | Optional AES-256-GCM encrypt-at-rest for `graph.db` |
|
package/dist/api/routes.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { estimateUsdSaved, metricsFromPacket, USD_PER_MILLION_INPUT_TOKENS } from "../estimate.js";
|
|
4
|
+
import { estimateUsdSaved, metricsFromPacket, savingsBasis, USD_PER_MILLION_INPUT_TOKENS, } from "../estimate.js";
|
|
5
5
|
import { buildActivityGraph, speedForEvent } from "../activity.js";
|
|
6
|
-
import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
|
|
6
|
+
import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, listTasks, listTasksAll, getSkillDraft, insertSkillDraft, listSkillDrafts, recordSkillUse, setSkillDraftStatus, setSkillRepo, findTaskAnyRepo, getTask, insertTask, updateTask, completeTask, deleteTask, countTasks, countTasksAll, normalizeTaskStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
|
|
7
|
+
import { deleteSkill, findSkillOnDisk, isValidSkillName, listIndexedSkills, listSkillAssets, rankSkills, readSkillAsset, readSkillBody, renderSkillMarkdown, scanSkillContent, skillsDir, slugifySkillName, syncSkillIndex, writeSkill, } from "../skills.js";
|
|
7
8
|
import { buildContext, buildRetrievalShowdown, decorateUsageEvents, renderContextMarkdown } from "../context.js";
|
|
8
9
|
import { installClaude, claudeInstallHealth } from "../install/claude.js";
|
|
9
10
|
import { installCursor, cursorInstallHealth } from "../install/cursor.js";
|
|
10
11
|
import { hostInstallHealth, installHost } from "../install/hosts.js";
|
|
11
12
|
import { decorateDraft, decorateDrafts } from "../draft-quality.js";
|
|
13
|
+
/**
|
|
14
|
+
* Which repo owns a task. Scoped requests may only touch the current repo; an
|
|
15
|
+
* all-memory request resolves the task's real owner so a board that shows every
|
|
16
|
+
* memory can also edit what it shows.
|
|
17
|
+
*/
|
|
18
|
+
function taskOwnerRepoId(id, currentRepoId, all) {
|
|
19
|
+
if (currentRepoId) {
|
|
20
|
+
const direct = getTask(currentRepoId, id);
|
|
21
|
+
if (direct)
|
|
22
|
+
return currentRepoId;
|
|
23
|
+
}
|
|
24
|
+
if (all) {
|
|
25
|
+
return findTaskAnyRepo(id)?.repo_id ?? null;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
12
29
|
import { isUsefulRememberText } from "../capture.js";
|
|
13
30
|
import { buildSavingsExport, formatSavingsMarkdown, savingsPdf, } from "../savings-export.js";
|
|
14
31
|
import { assertPlatformAllowed, assertRemoteAllowed, loadPolicy, } from "../policy.js";
|
|
@@ -58,6 +75,40 @@ function ok(body) {
|
|
|
58
75
|
function err(status, message) {
|
|
59
76
|
return { status, body: { error: message } };
|
|
60
77
|
}
|
|
78
|
+
function safeJsonArray(raw) {
|
|
79
|
+
try {
|
|
80
|
+
const list = JSON.parse(raw);
|
|
81
|
+
return Array.isArray(list) ? list.filter((v) => typeof v === "string") : [];
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Once a skill is saved, the nudge that prompted it has done its job. */
|
|
88
|
+
function resolveSuggestionFor(repoId, sessionId) {
|
|
89
|
+
if (!repoId)
|
|
90
|
+
return;
|
|
91
|
+
for (const draft of listSkillDrafts({ status: "pending", repoId, limit: 20 })) {
|
|
92
|
+
if (draft.kind !== "suggestion")
|
|
93
|
+
continue;
|
|
94
|
+
if (sessionId && draft.session_id && draft.session_id !== sessionId)
|
|
95
|
+
continue;
|
|
96
|
+
setSkillDraftStatus(draft.id, "applied");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Level-0 view of a skill: enough to decide whether to load it, without the body. */
|
|
101
|
+
function skillSummary(skill) {
|
|
102
|
+
return {
|
|
103
|
+
name: skill.name,
|
|
104
|
+
description: skill.description,
|
|
105
|
+
version: skill.version,
|
|
106
|
+
tags: skill.tags,
|
|
107
|
+
source: skill.source,
|
|
108
|
+
repo_id: skill.repoId ?? null,
|
|
109
|
+
uses: skill.uses ?? 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
61
112
|
function bodyField(body, key) {
|
|
62
113
|
if (!body || typeof body !== "object")
|
|
63
114
|
return undefined;
|
|
@@ -307,10 +358,14 @@ function aggregateUsage(events, windowDays = 30) {
|
|
|
307
358
|
}
|
|
308
359
|
const queries = events.length;
|
|
309
360
|
const estimatedTokensSaved = events.reduce((s, e) => s + e.estimated_tokens_saved, 0);
|
|
361
|
+
const reportedTokensSaved = events.reduce((s, e) => s + (e.reported_tokens_saved ?? 0), 0);
|
|
310
362
|
return {
|
|
311
363
|
pricing: {
|
|
312
364
|
usdPerMillionInputTokens: USD_PER_MILLION_INPUT_TOKENS,
|
|
313
365
|
basis: "input",
|
|
366
|
+
// How much to trust the numbers below. Until reported savings exist,
|
|
367
|
+
// every "saved" figure is a model, and callers must say so.
|
|
368
|
+
...savingsBasis(reportedTokensSaved),
|
|
314
369
|
},
|
|
315
370
|
byPlatform: Object.values(byPlatform).map((p) => ({
|
|
316
371
|
...p,
|
|
@@ -327,7 +382,7 @@ function aggregateUsage(events, windowDays = 30) {
|
|
|
327
382
|
queries,
|
|
328
383
|
estimatedTokensSaved,
|
|
329
384
|
estimatedUsdSaved: estimateUsdSaved(estimatedTokensSaved),
|
|
330
|
-
reportedTokensSaved
|
|
385
|
+
reportedTokensSaved,
|
|
331
386
|
estimatedMsSaved,
|
|
332
387
|
localHits,
|
|
333
388
|
serverTrips,
|
|
@@ -1242,6 +1297,285 @@ export function handleApi(req) {
|
|
|
1242
1297
|
return err(404, "Claim not found");
|
|
1243
1298
|
return ok({ deleted: id });
|
|
1244
1299
|
}
|
|
1300
|
+
// Skills are a global library, not repo-scoped like claims and tasks — no `repo` guard.
|
|
1301
|
+
if (method === "GET" && pathname === "/api/skills") {
|
|
1302
|
+
const query = searchParams.get("q") || "";
|
|
1303
|
+
const skills = listIndexedSkills();
|
|
1304
|
+
const ranked = query ? rankSkills(skills, query, Number(searchParams.get("limit") || 10)) : [];
|
|
1305
|
+
return ok({
|
|
1306
|
+
skills: skills.map(skillSummary),
|
|
1307
|
+
matches: ranked.map((s) => ({ ...skillSummary(s), score: s.score, reasons: s.reasons })),
|
|
1308
|
+
dir: skillsDir(),
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (method === "GET" && pathname === "/api/skills/view") {
|
|
1312
|
+
const name = searchParams.get("name");
|
|
1313
|
+
if (!name)
|
|
1314
|
+
return err(400, "name required");
|
|
1315
|
+
const file = searchParams.get("file");
|
|
1316
|
+
if (file) {
|
|
1317
|
+
const asset = readSkillAsset(name, file);
|
|
1318
|
+
if (asset === null)
|
|
1319
|
+
return err(404, "Skill file not found");
|
|
1320
|
+
return ok({ name, file, content: asset });
|
|
1321
|
+
}
|
|
1322
|
+
const meta = findSkillOnDisk(name);
|
|
1323
|
+
if (!meta)
|
|
1324
|
+
return err(404, "Skill not found");
|
|
1325
|
+
const content = readSkillBody(name);
|
|
1326
|
+
if (content === null)
|
|
1327
|
+
return err(404, "Skill not found");
|
|
1328
|
+
// A skill can be viewed before anything indexed it, and the usage counter lives in
|
|
1329
|
+
// the index — reconcile first or the increment silently updates zero rows.
|
|
1330
|
+
syncSkillIndex();
|
|
1331
|
+
recordSkillUse(meta.name, {
|
|
1332
|
+
repoId: repo ? repo.id : null,
|
|
1333
|
+
sessionId: searchParams.get("session_id"),
|
|
1334
|
+
});
|
|
1335
|
+
return ok({ ...skillSummary(meta), content, files: listSkillAssets(meta.name) });
|
|
1336
|
+
}
|
|
1337
|
+
if (method === "POST" && pathname === "/api/skills") {
|
|
1338
|
+
const name = bodyField(body, "name");
|
|
1339
|
+
if (!name)
|
|
1340
|
+
return err(400, "name required");
|
|
1341
|
+
const slug = slugifySkillName(name);
|
|
1342
|
+
if (!isValidSkillName(slug))
|
|
1343
|
+
return err(400, "Invalid skill name");
|
|
1344
|
+
const content = bodyField(body, "content");
|
|
1345
|
+
const description = bodyField(body, "description") || "";
|
|
1346
|
+
// Accept either a full SKILL.md or the parts, so agents can save without templating.
|
|
1347
|
+
const markdown = content && content.includes("---")
|
|
1348
|
+
? content
|
|
1349
|
+
: renderSkillMarkdown({
|
|
1350
|
+
name: slug,
|
|
1351
|
+
description,
|
|
1352
|
+
body: content || "",
|
|
1353
|
+
version: bodyField(body, "version"),
|
|
1354
|
+
});
|
|
1355
|
+
const scan = scanSkillContent(markdown);
|
|
1356
|
+
if (!scan.ok)
|
|
1357
|
+
return err(400, `Rejected: ${scan.reason}`);
|
|
1358
|
+
const policy = loadPolicy().policy;
|
|
1359
|
+
if (!policy.skills_enabled)
|
|
1360
|
+
return err(403, "Skills are disabled by policy");
|
|
1361
|
+
// A SKILL.md is too long to review inline, so an approval gate stages rather than
|
|
1362
|
+
// blocks — the agent keeps working and a human decides later.
|
|
1363
|
+
if (policy.skill_write_approval) {
|
|
1364
|
+
const draft = insertSkillDraft({
|
|
1365
|
+
repoId: repo ? repo.id : null,
|
|
1366
|
+
name: slug,
|
|
1367
|
+
title: description || slug,
|
|
1368
|
+
summary: description,
|
|
1369
|
+
content: markdown,
|
|
1370
|
+
kind: findSkillOnDisk(slug) ? "revision" : "create",
|
|
1371
|
+
targetSkill: findSkillOnDisk(slug) ? slug : null,
|
|
1372
|
+
source: `agent-save:${slug}:${Date.now()}`,
|
|
1373
|
+
sessionId: bodyField(body, "session_id") ?? null,
|
|
1374
|
+
reasons: ["staged by skill_write_approval"],
|
|
1375
|
+
});
|
|
1376
|
+
return ok({
|
|
1377
|
+
staged: draft.id,
|
|
1378
|
+
name: slug,
|
|
1379
|
+
pending: true,
|
|
1380
|
+
message: "Skill staged for review — approve it in the Skills tab or `amem skills drafts`.",
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
const written = writeSkill(slug, markdown);
|
|
1384
|
+
syncSkillIndex();
|
|
1385
|
+
const repoId = bodyField(body, "repo_id") ?? (repo ? repo.id : null);
|
|
1386
|
+
if (repoId)
|
|
1387
|
+
setSkillRepo(slug, repoId);
|
|
1388
|
+
resolveSuggestionFor(repo?.id, bodyField(body, "session_id"));
|
|
1389
|
+
return ok({ saved: written.name, path: written.path, hash: written.hash });
|
|
1390
|
+
}
|
|
1391
|
+
if (method === "GET" && pathname === "/api/skills/drafts") {
|
|
1392
|
+
const drafts = listSkillDrafts({
|
|
1393
|
+
status: searchParams.get("status") || "pending",
|
|
1394
|
+
limit: Number(searchParams.get("limit") || 50),
|
|
1395
|
+
});
|
|
1396
|
+
const repoNames = new Map(listRepos().map((r) => [r.id, r.repo_name]));
|
|
1397
|
+
return ok({
|
|
1398
|
+
drafts: drafts.map((d) => ({
|
|
1399
|
+
...d,
|
|
1400
|
+
reasons: safeJsonArray(d.reasons),
|
|
1401
|
+
repo_name: d.repo_id ? (repoNames.get(d.repo_id) ?? null) : null,
|
|
1402
|
+
// A suggestion has no content yet — only an agent can write the body.
|
|
1403
|
+
has_content: Boolean(d.content),
|
|
1404
|
+
})),
|
|
1405
|
+
counts: { pending: listSkillDrafts({ status: "pending", limit: 200 }).length },
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
if (method === "POST" && pathname === "/api/skills/drafts/apply") {
|
|
1409
|
+
const id = bodyField(body, "id");
|
|
1410
|
+
if (!id)
|
|
1411
|
+
return err(400, "id required");
|
|
1412
|
+
const draft = getSkillDraft(id);
|
|
1413
|
+
if (!draft)
|
|
1414
|
+
return err(404, "Draft not found");
|
|
1415
|
+
if (!draft.content || !draft.name) {
|
|
1416
|
+
return err(400, "This is a suggestion, not a staged skill — an agent must write it first");
|
|
1417
|
+
}
|
|
1418
|
+
const scan = scanSkillContent(draft.content);
|
|
1419
|
+
if (!scan.ok)
|
|
1420
|
+
return err(400, `Rejected: ${scan.reason}`);
|
|
1421
|
+
const written = writeSkill(draft.name, draft.content);
|
|
1422
|
+
syncSkillIndex();
|
|
1423
|
+
if (draft.repo_id)
|
|
1424
|
+
setSkillRepo(draft.name, draft.repo_id);
|
|
1425
|
+
setSkillDraftStatus(id, "applied");
|
|
1426
|
+
return ok({ applied: id, name: written.name, path: written.path });
|
|
1427
|
+
}
|
|
1428
|
+
if (method === "POST" && pathname === "/api/skills/drafts/dismiss") {
|
|
1429
|
+
const id = bodyField(body, "id");
|
|
1430
|
+
if (!id)
|
|
1431
|
+
return err(400, "id required");
|
|
1432
|
+
if (!getSkillDraft(id))
|
|
1433
|
+
return err(404, "Draft not found");
|
|
1434
|
+
setSkillDraftStatus(id, "dismissed");
|
|
1435
|
+
return ok({ dismissed: id });
|
|
1436
|
+
}
|
|
1437
|
+
if (method === "DELETE" && pathname === "/api/skills") {
|
|
1438
|
+
const name = searchParams.get("name") || bodyField(body, "name");
|
|
1439
|
+
if (!name)
|
|
1440
|
+
return err(400, "name required");
|
|
1441
|
+
const removed = deleteSkill(name);
|
|
1442
|
+
if (!removed)
|
|
1443
|
+
return err(404, "Skill not found");
|
|
1444
|
+
syncSkillIndex();
|
|
1445
|
+
return ok({ deleted: slugifySkillName(name) });
|
|
1446
|
+
}
|
|
1447
|
+
if (method === "GET" && pathname === "/api/tasks") {
|
|
1448
|
+
const all = searchParams.get("scope") === "all" ||
|
|
1449
|
+
bodyField(body, "scope") === "all" ||
|
|
1450
|
+
searchParams.get("repo") === "all";
|
|
1451
|
+
if (!all && !repo)
|
|
1452
|
+
return err(400, "Repo not initialized");
|
|
1453
|
+
const statusRaw = searchParams.get("status") || bodyField(body, "status");
|
|
1454
|
+
const status = statusRaw ? normalizeTaskStatus(statusRaw) : null;
|
|
1455
|
+
if (statusRaw && !status)
|
|
1456
|
+
return err(400, "invalid status");
|
|
1457
|
+
const includeDone = searchParams.get("include_done") === "1" ||
|
|
1458
|
+
searchParams.get("include_done") === "true" ||
|
|
1459
|
+
bodyField(body, "include_done") === "1" ||
|
|
1460
|
+
bodyField(body, "include_done") === "true";
|
|
1461
|
+
const listOpts = {
|
|
1462
|
+
status: status || undefined,
|
|
1463
|
+
includeDone: includeDone || Boolean(status === "done"),
|
|
1464
|
+
limit: Number(searchParams.get("limit") || (all ? 200 : 100)),
|
|
1465
|
+
};
|
|
1466
|
+
const tasks = all ? listTasksAll(listOpts) : listTasks(repo.id, listOpts);
|
|
1467
|
+
const count = (o) => all ? countTasksAll(o) : countTasks(repo.id, o);
|
|
1468
|
+
// Name the owning memory so an all-memory board can say where each task lives.
|
|
1469
|
+
const repoNames = all
|
|
1470
|
+
? new Map(listRepos().map((r) => [r.id, r.repo_name]))
|
|
1471
|
+
: new Map();
|
|
1472
|
+
return ok({
|
|
1473
|
+
scope: all ? "all" : "current",
|
|
1474
|
+
tasks: all
|
|
1475
|
+
? tasks.map((t) => ({ ...t, repo_name: repoNames.get(t.repo_id) ?? null }))
|
|
1476
|
+
: tasks,
|
|
1477
|
+
counts: {
|
|
1478
|
+
open: count({ openOnly: true }),
|
|
1479
|
+
backlog: count({ status: "backlog" }),
|
|
1480
|
+
next: count({ status: "next" }),
|
|
1481
|
+
doing: count({ status: "doing" }),
|
|
1482
|
+
blocked: count({ status: "blocked" }),
|
|
1483
|
+
done: count({ status: "done" }),
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
if (method === "POST" && pathname === "/api/tasks") {
|
|
1488
|
+
const targetRepo = repo || ensurePersonalWorkspace();
|
|
1489
|
+
const payload = body && typeof body === "object" ? body : {};
|
|
1490
|
+
const title = typeof payload.title === "string" ? payload.title : "";
|
|
1491
|
+
if (!title.trim())
|
|
1492
|
+
return err(400, "title required");
|
|
1493
|
+
let anchors;
|
|
1494
|
+
if (Array.isArray(payload.anchors)) {
|
|
1495
|
+
anchors = payload.anchors.filter((a) => typeof a === "string");
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
const task = insertTask({
|
|
1499
|
+
repoId: targetRepo.id,
|
|
1500
|
+
title,
|
|
1501
|
+
body: typeof payload.body === "string" ? payload.body : "",
|
|
1502
|
+
status: typeof payload.status === "string" ? payload.status : "backlog",
|
|
1503
|
+
anchors,
|
|
1504
|
+
source: typeof payload.source === "string" ? payload.source : "ui",
|
|
1505
|
+
});
|
|
1506
|
+
return ok({ task });
|
|
1507
|
+
}
|
|
1508
|
+
catch (error) {
|
|
1509
|
+
return err(400, error instanceof Error ? error.message : String(error));
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (method === "PATCH" && pathname === "/api/tasks") {
|
|
1513
|
+
const all = searchParams.get("scope") === "all" ||
|
|
1514
|
+
bodyField(body, "scope") === "all" ||
|
|
1515
|
+
searchParams.get("repo") === "all";
|
|
1516
|
+
if (!all && !repo)
|
|
1517
|
+
return err(400, "Repo not initialized");
|
|
1518
|
+
const payload = body && typeof body === "object" ? body : {};
|
|
1519
|
+
const id = bodyField(body, "id");
|
|
1520
|
+
if (!id)
|
|
1521
|
+
return err(400, "id required");
|
|
1522
|
+
// In all-memory scope the card may belong to any repo, so find its real owner.
|
|
1523
|
+
const ownerId = taskOwnerRepoId(id, repo?.id, all);
|
|
1524
|
+
if (!ownerId)
|
|
1525
|
+
return err(404, "Task not found");
|
|
1526
|
+
let anchors;
|
|
1527
|
+
if (Array.isArray(payload.anchors)) {
|
|
1528
|
+
anchors = payload.anchors.filter((a) => typeof a === "string");
|
|
1529
|
+
}
|
|
1530
|
+
try {
|
|
1531
|
+
const task = updateTask(ownerId, id, {
|
|
1532
|
+
title: typeof payload.title === "string" ? payload.title : undefined,
|
|
1533
|
+
body: typeof payload.body === "string" ? payload.body : undefined,
|
|
1534
|
+
status: typeof payload.status === "string" ? payload.status : undefined,
|
|
1535
|
+
anchors,
|
|
1536
|
+
});
|
|
1537
|
+
if (!task)
|
|
1538
|
+
return err(404, "Task not found");
|
|
1539
|
+
return ok({ task });
|
|
1540
|
+
}
|
|
1541
|
+
catch (error) {
|
|
1542
|
+
return err(400, error instanceof Error ? error.message : String(error));
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
if (method === "POST" && pathname === "/api/tasks/complete") {
|
|
1546
|
+
const all = searchParams.get("scope") === "all" ||
|
|
1547
|
+
bodyField(body, "scope") === "all" ||
|
|
1548
|
+
searchParams.get("repo") === "all";
|
|
1549
|
+
if (!all && !repo)
|
|
1550
|
+
return err(400, "Repo not initialized");
|
|
1551
|
+
const id = bodyField(body, "id");
|
|
1552
|
+
if (!id)
|
|
1553
|
+
return err(400, "id required");
|
|
1554
|
+
const ownerId = taskOwnerRepoId(id, repo?.id, all);
|
|
1555
|
+
if (!ownerId)
|
|
1556
|
+
return err(404, "Task not found");
|
|
1557
|
+
const task = completeTask(ownerId, id);
|
|
1558
|
+
if (!task)
|
|
1559
|
+
return err(404, "Task not found");
|
|
1560
|
+
return ok({ task });
|
|
1561
|
+
}
|
|
1562
|
+
if (method === "DELETE" && pathname === "/api/tasks") {
|
|
1563
|
+
const all = searchParams.get("scope") === "all" ||
|
|
1564
|
+
bodyField(body, "scope") === "all" ||
|
|
1565
|
+
searchParams.get("repo") === "all";
|
|
1566
|
+
if (!all && !repo)
|
|
1567
|
+
return err(400, "Repo not initialized");
|
|
1568
|
+
const id = searchParams.get("id") || bodyField(body, "id");
|
|
1569
|
+
if (!id)
|
|
1570
|
+
return err(400, "id required");
|
|
1571
|
+
const ownerId = taskOwnerRepoId(id, repo?.id, all);
|
|
1572
|
+
if (!ownerId)
|
|
1573
|
+
return err(404, "Task not found");
|
|
1574
|
+
const removed = deleteTask(ownerId, id);
|
|
1575
|
+
if (!removed)
|
|
1576
|
+
return err(404, "Task not found");
|
|
1577
|
+
return ok({ deleted: id });
|
|
1578
|
+
}
|
|
1245
1579
|
if (method === "GET" && pathname === "/api/usage/export") {
|
|
1246
1580
|
const scope = searchParams.get("scope") ?? "current";
|
|
1247
1581
|
const days = Number(searchParams.get("days") ?? "30");
|
package/dist/attest.d.ts
CHANGED
|
@@ -38,6 +38,19 @@ export type AttestReport = {
|
|
|
38
38
|
};
|
|
39
39
|
license: ReturnType<typeof licenseStatus>;
|
|
40
40
|
embed: ReturnType<typeof embedStatus>;
|
|
41
|
+
/** Procedural memory an auditor should be able to review: what agents may be told to do. */
|
|
42
|
+
skills: {
|
|
43
|
+
dir: string;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
write_approval: boolean;
|
|
46
|
+
pending_drafts: number;
|
|
47
|
+
installed: Array<{
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
source: string;
|
|
51
|
+
hash: string;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
41
54
|
sku?: {
|
|
42
55
|
tier: string;
|
|
43
56
|
airgap: true;
|
package/dist/attest.js
CHANGED
|
@@ -12,6 +12,8 @@ import { FEATURE_ATTEST_SKU, hasFeature, licenseStatus } from "./license.js";
|
|
|
12
12
|
import { embedIndexIssues, embedStatus } from "./embed.js";
|
|
13
13
|
import { vaultStatus } from "./vault.js";
|
|
14
14
|
import { hostInstallHealth } from "./install/hosts.js";
|
|
15
|
+
import { listSkillDrafts } from "./db.js";
|
|
16
|
+
import { scanSkills, skillsDir } from "./skills.js";
|
|
15
17
|
function packageRoot() {
|
|
16
18
|
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
17
19
|
// dist/ -> package root
|
|
@@ -104,6 +106,8 @@ export function buildAttestReport(cwd = process.cwd()) {
|
|
|
104
106
|
catch {
|
|
105
107
|
// Locked vault: the vault section already reports that.
|
|
106
108
|
}
|
|
109
|
+
const skills = skillsAttestSection();
|
|
110
|
+
issues.push(...skillIssues(skills));
|
|
107
111
|
const pkgPath = join(packageRoot(), "package.json");
|
|
108
112
|
const sku = hasFeature(FEATURE_ATTEST_SKU)
|
|
109
113
|
? {
|
|
@@ -154,11 +158,51 @@ export function buildAttestReport(cwd = process.cwd()) {
|
|
|
154
158
|
},
|
|
155
159
|
license,
|
|
156
160
|
embed,
|
|
161
|
+
skills,
|
|
157
162
|
sku,
|
|
158
163
|
ok: issues.length === 0,
|
|
159
164
|
issues,
|
|
160
165
|
};
|
|
161
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Inventory of procedural memory. Hashes let an auditor diff what agents are being told to
|
|
169
|
+
* do between two machines, which a bare list of names would not support.
|
|
170
|
+
*/
|
|
171
|
+
function skillsAttestSection() {
|
|
172
|
+
const policy = loadPolicy().policy;
|
|
173
|
+
const base = {
|
|
174
|
+
dir: skillsDir(),
|
|
175
|
+
enabled: policy.skills_enabled,
|
|
176
|
+
write_approval: policy.skill_write_approval,
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
pending_drafts: listSkillDrafts({ status: "pending", limit: 200 }).length,
|
|
182
|
+
installed: scanSkills().map((s) => ({
|
|
183
|
+
name: s.name,
|
|
184
|
+
description: s.description,
|
|
185
|
+
source: s.source,
|
|
186
|
+
hash: s.hash,
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { ...base, pending_drafts: 0, installed: [] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function skillIssues(skills) {
|
|
195
|
+
const issues = [];
|
|
196
|
+
for (const skill of skills.installed) {
|
|
197
|
+
if (!skill.description.trim()) {
|
|
198
|
+
issues.push(`skill ${skill.name} has no description — agents cannot rank it`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (skills.pending_drafts > 0 && skills.write_approval) {
|
|
202
|
+
issues.push(`${skills.pending_drafts} skill write(s) awaiting approval`);
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
162
206
|
export function formatAttestHuman(report) {
|
|
163
207
|
const lines = [
|
|
164
208
|
`amem attest ${report.ok ? "OK" : "ISSUES"} · v${report.version}`,
|
package/dist/capture.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { PLACEHOLDER_ANCHOR } from "./freshness.js";
|
|
4
5
|
import { insertProposalDraft, listProposalDrafts, listProposalDraftsAll, listUsageEvents, setProposalDraftStatus, } from "./db.js";
|
|
5
|
-
import { compactClaimText, compactFromNotes, inferClaimKind, isDurableCapture } from "./kinds.js";
|
|
6
|
+
import { compactClaimText, compactFromNotes, inferClaimKind, isDurableCapture, isFactLike, } from "./kinds.js";
|
|
6
7
|
import { loadPolicy } from "./policy.js";
|
|
7
8
|
import { applyProposal } from "./proposal.js";
|
|
8
9
|
import { scoreProposal } from "./draft-quality.js";
|
|
@@ -49,12 +50,20 @@ function buildClaimDraft(input) {
|
|
|
49
50
|
}
|
|
50
51
|
const anchors = extractCaptureAnchors(`${input.prompt}\n${input.answer ?? ""}`, input.repoRoot);
|
|
51
52
|
const kind = input.forceKind ?? inferClaimKind(input.prompt, input.answer ?? "");
|
|
52
|
-
const usableAnchors = anchors.length > 0
|
|
53
|
+
const usableAnchors = anchors.length > 0
|
|
54
|
+
? anchors
|
|
55
|
+
: kind === "constraint" || kind === "gotcha"
|
|
56
|
+
? [PLACEHOLDER_ANCHOR]
|
|
57
|
+
: [];
|
|
53
58
|
if (!isDurableCapture(input.prompt, input.answer, usableAnchors.length)) {
|
|
54
59
|
if (usableAnchors.length === 0)
|
|
55
60
|
return null;
|
|
56
61
|
}
|
|
57
62
|
const text = compactClaimText(input.prompt, input.answer);
|
|
63
|
+
// Single choke point for every capture path. Conversational residue never
|
|
64
|
+
// becomes a durable claim, however many file paths it happens to mention.
|
|
65
|
+
if (!isFactLike(text))
|
|
66
|
+
return null;
|
|
58
67
|
const id = `${input.idPrefix}_${createHash("sha256").update(text).digest("hex").slice(0, 12)}`;
|
|
59
68
|
return {
|
|
60
69
|
id,
|
|
@@ -85,7 +94,7 @@ export function shouldAutoApplyProposal(proposal) {
|
|
|
85
94
|
return false;
|
|
86
95
|
if (!DURABLE_AUTO_KINDS.has((claim.kind || "").toLowerCase()))
|
|
87
96
|
return false;
|
|
88
|
-
const anchors = (claim.code_anchors ?? []).filter((a) => a && a !==
|
|
97
|
+
const anchors = (claim.code_anchors ?? []).filter((a) => a && a !== PLACEHOLDER_ANCHOR);
|
|
89
98
|
return anchors.length > 0;
|
|
90
99
|
}
|
|
91
100
|
function maybeAutoApplyDraft(repoId, draft, proposal) {
|
|
@@ -195,7 +204,7 @@ function extraSessionFacts(input, skipId) {
|
|
|
195
204
|
const sentences = answer
|
|
196
205
|
.split(/(?<=[.!?])\s+/)
|
|
197
206
|
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
198
|
-
.filter((s) => s.length >= 48 && [...s.matchAll(PATH_RE)].length > 0);
|
|
207
|
+
.filter((s) => s.length >= 48 && [...s.matchAll(PATH_RE)].length > 0 && isFactLike(s));
|
|
199
208
|
const out = [];
|
|
200
209
|
for (const sentence of sentences.slice(0, 3)) {
|
|
201
210
|
const built = buildClaimDraft({
|
|
@@ -251,7 +260,7 @@ export function captureMissLearnDraft(input) {
|
|
|
251
260
|
});
|
|
252
261
|
if (!built)
|
|
253
262
|
return null;
|
|
254
|
-
const realAnchors = built.anchors.filter((a) => a !==
|
|
263
|
+
const realAnchors = built.anchors.filter((a) => a !== PLACEHOLDER_ANCHOR);
|
|
255
264
|
if (realAnchors.length === 0)
|
|
256
265
|
return null;
|
|
257
266
|
built.proposal.claims[0].code_anchors = realAnchors;
|