@iamem/amem 0.1.1 → 0.1.3

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 +98 -4
  2. package/desktop/icons/icon-1024.png +0 -0
  3. package/desktop/icons/icon.icns +0 -0
  4. package/desktop/icons/icon.png +0 -0
  5. package/desktop/main.js +100 -0
  6. package/desktop/package.json +16 -0
  7. package/desktop/preload.js +4 -0
  8. package/desktop/scripts/ensure-electron.mjs +122 -0
  9. package/dist/api/routes.js +331 -1
  10. package/dist/app-shell.d.ts +13 -0
  11. package/dist/app-shell.js +126 -0
  12. package/dist/attest.d.ts +13 -0
  13. package/dist/attest.js +44 -0
  14. package/dist/cli.js +258 -2
  15. package/dist/context.d.ts +10 -1
  16. package/dist/context.js +105 -3
  17. package/dist/db.d.ts +141 -0
  18. package/dist/db.js +398 -0
  19. package/dist/embed.js +5 -14
  20. package/dist/hook.js +8 -1
  21. package/dist/hygiene.d.ts +1 -2
  22. package/dist/hygiene.js +1 -2
  23. package/dist/install/hosts.js +8 -0
  24. package/dist/install/skills.js +10 -5
  25. package/dist/license.d.ts +1 -0
  26. package/dist/license.js +21 -19
  27. package/dist/mcp.js +221 -0
  28. package/dist/policy.d.ts +6 -0
  29. package/dist/policy.js +16 -1
  30. package/dist/publish.d.ts +1 -1
  31. package/dist/publish.js +1 -0
  32. package/dist/skill-capture.d.ts +43 -0
  33. package/dist/skill-capture.js +146 -0
  34. package/dist/skills.d.ts +106 -0
  35. package/dist/skills.js +422 -0
  36. package/docs/backlog.md +9 -0
  37. package/docs/npm-release.md +1 -1
  38. package/package.json +7 -2
  39. package/scripts/postinstall.js +62 -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 +435 -227
  46. package/ui-static/index.html +11 -34
  47. package/ui-static/styles.css +299 -0
@@ -3,12 +3,29 @@ import { resolve, join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { estimateUsdSaved, metricsFromPacket, 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;
@@ -1242,6 +1293,285 @@ export function handleApi(req) {
1242
1293
  return err(404, "Claim not found");
1243
1294
  return ok({ deleted: id });
1244
1295
  }
1296
+ // Skills are a global library, not repo-scoped like claims and tasks — no `repo` guard.
1297
+ if (method === "GET" && pathname === "/api/skills") {
1298
+ const query = searchParams.get("q") || "";
1299
+ const skills = listIndexedSkills();
1300
+ const ranked = query ? rankSkills(skills, query, Number(searchParams.get("limit") || 10)) : [];
1301
+ return ok({
1302
+ skills: skills.map(skillSummary),
1303
+ matches: ranked.map((s) => ({ ...skillSummary(s), score: s.score, reasons: s.reasons })),
1304
+ dir: skillsDir(),
1305
+ });
1306
+ }
1307
+ if (method === "GET" && pathname === "/api/skills/view") {
1308
+ const name = searchParams.get("name");
1309
+ if (!name)
1310
+ return err(400, "name required");
1311
+ const file = searchParams.get("file");
1312
+ if (file) {
1313
+ const asset = readSkillAsset(name, file);
1314
+ if (asset === null)
1315
+ return err(404, "Skill file not found");
1316
+ return ok({ name, file, content: asset });
1317
+ }
1318
+ const meta = findSkillOnDisk(name);
1319
+ if (!meta)
1320
+ return err(404, "Skill not found");
1321
+ const content = readSkillBody(name);
1322
+ if (content === null)
1323
+ return err(404, "Skill not found");
1324
+ // A skill can be viewed before anything indexed it, and the usage counter lives in
1325
+ // the index — reconcile first or the increment silently updates zero rows.
1326
+ syncSkillIndex();
1327
+ recordSkillUse(meta.name, {
1328
+ repoId: repo ? repo.id : null,
1329
+ sessionId: searchParams.get("session_id"),
1330
+ });
1331
+ return ok({ ...skillSummary(meta), content, files: listSkillAssets(meta.name) });
1332
+ }
1333
+ if (method === "POST" && pathname === "/api/skills") {
1334
+ const name = bodyField(body, "name");
1335
+ if (!name)
1336
+ return err(400, "name required");
1337
+ const slug = slugifySkillName(name);
1338
+ if (!isValidSkillName(slug))
1339
+ return err(400, "Invalid skill name");
1340
+ const content = bodyField(body, "content");
1341
+ const description = bodyField(body, "description") || "";
1342
+ // Accept either a full SKILL.md or the parts, so agents can save without templating.
1343
+ const markdown = content && content.includes("---")
1344
+ ? content
1345
+ : renderSkillMarkdown({
1346
+ name: slug,
1347
+ description,
1348
+ body: content || "",
1349
+ version: bodyField(body, "version"),
1350
+ });
1351
+ const scan = scanSkillContent(markdown);
1352
+ if (!scan.ok)
1353
+ return err(400, `Rejected: ${scan.reason}`);
1354
+ const policy = loadPolicy().policy;
1355
+ if (!policy.skills_enabled)
1356
+ return err(403, "Skills are disabled by policy");
1357
+ // A SKILL.md is too long to review inline, so an approval gate stages rather than
1358
+ // blocks — the agent keeps working and a human decides later.
1359
+ if (policy.skill_write_approval) {
1360
+ const draft = insertSkillDraft({
1361
+ repoId: repo ? repo.id : null,
1362
+ name: slug,
1363
+ title: description || slug,
1364
+ summary: description,
1365
+ content: markdown,
1366
+ kind: findSkillOnDisk(slug) ? "revision" : "create",
1367
+ targetSkill: findSkillOnDisk(slug) ? slug : null,
1368
+ source: `agent-save:${slug}:${Date.now()}`,
1369
+ sessionId: bodyField(body, "session_id") ?? null,
1370
+ reasons: ["staged by skill_write_approval"],
1371
+ });
1372
+ return ok({
1373
+ staged: draft.id,
1374
+ name: slug,
1375
+ pending: true,
1376
+ message: "Skill staged for review — approve it in the Skills tab or `amem skills drafts`.",
1377
+ });
1378
+ }
1379
+ const written = writeSkill(slug, markdown);
1380
+ syncSkillIndex();
1381
+ const repoId = bodyField(body, "repo_id") ?? (repo ? repo.id : null);
1382
+ if (repoId)
1383
+ setSkillRepo(slug, repoId);
1384
+ resolveSuggestionFor(repo?.id, bodyField(body, "session_id"));
1385
+ return ok({ saved: written.name, path: written.path, hash: written.hash });
1386
+ }
1387
+ if (method === "GET" && pathname === "/api/skills/drafts") {
1388
+ const drafts = listSkillDrafts({
1389
+ status: searchParams.get("status") || "pending",
1390
+ limit: Number(searchParams.get("limit") || 50),
1391
+ });
1392
+ const repoNames = new Map(listRepos().map((r) => [r.id, r.repo_name]));
1393
+ return ok({
1394
+ drafts: drafts.map((d) => ({
1395
+ ...d,
1396
+ reasons: safeJsonArray(d.reasons),
1397
+ repo_name: d.repo_id ? (repoNames.get(d.repo_id) ?? null) : null,
1398
+ // A suggestion has no content yet — only an agent can write the body.
1399
+ has_content: Boolean(d.content),
1400
+ })),
1401
+ counts: { pending: listSkillDrafts({ status: "pending", limit: 200 }).length },
1402
+ });
1403
+ }
1404
+ if (method === "POST" && pathname === "/api/skills/drafts/apply") {
1405
+ const id = bodyField(body, "id");
1406
+ if (!id)
1407
+ return err(400, "id required");
1408
+ const draft = getSkillDraft(id);
1409
+ if (!draft)
1410
+ return err(404, "Draft not found");
1411
+ if (!draft.content || !draft.name) {
1412
+ return err(400, "This is a suggestion, not a staged skill — an agent must write it first");
1413
+ }
1414
+ const scan = scanSkillContent(draft.content);
1415
+ if (!scan.ok)
1416
+ return err(400, `Rejected: ${scan.reason}`);
1417
+ const written = writeSkill(draft.name, draft.content);
1418
+ syncSkillIndex();
1419
+ if (draft.repo_id)
1420
+ setSkillRepo(draft.name, draft.repo_id);
1421
+ setSkillDraftStatus(id, "applied");
1422
+ return ok({ applied: id, name: written.name, path: written.path });
1423
+ }
1424
+ if (method === "POST" && pathname === "/api/skills/drafts/dismiss") {
1425
+ const id = bodyField(body, "id");
1426
+ if (!id)
1427
+ return err(400, "id required");
1428
+ if (!getSkillDraft(id))
1429
+ return err(404, "Draft not found");
1430
+ setSkillDraftStatus(id, "dismissed");
1431
+ return ok({ dismissed: id });
1432
+ }
1433
+ if (method === "DELETE" && pathname === "/api/skills") {
1434
+ const name = searchParams.get("name") || bodyField(body, "name");
1435
+ if (!name)
1436
+ return err(400, "name required");
1437
+ const removed = deleteSkill(name);
1438
+ if (!removed)
1439
+ return err(404, "Skill not found");
1440
+ syncSkillIndex();
1441
+ return ok({ deleted: slugifySkillName(name) });
1442
+ }
1443
+ if (method === "GET" && pathname === "/api/tasks") {
1444
+ const all = searchParams.get("scope") === "all" ||
1445
+ bodyField(body, "scope") === "all" ||
1446
+ searchParams.get("repo") === "all";
1447
+ if (!all && !repo)
1448
+ return err(400, "Repo not initialized");
1449
+ const statusRaw = searchParams.get("status") || bodyField(body, "status");
1450
+ const status = statusRaw ? normalizeTaskStatus(statusRaw) : null;
1451
+ if (statusRaw && !status)
1452
+ return err(400, "invalid status");
1453
+ const includeDone = searchParams.get("include_done") === "1" ||
1454
+ searchParams.get("include_done") === "true" ||
1455
+ bodyField(body, "include_done") === "1" ||
1456
+ bodyField(body, "include_done") === "true";
1457
+ const listOpts = {
1458
+ status: status || undefined,
1459
+ includeDone: includeDone || Boolean(status === "done"),
1460
+ limit: Number(searchParams.get("limit") || (all ? 200 : 100)),
1461
+ };
1462
+ const tasks = all ? listTasksAll(listOpts) : listTasks(repo.id, listOpts);
1463
+ const count = (o) => all ? countTasksAll(o) : countTasks(repo.id, o);
1464
+ // Name the owning memory so an all-memory board can say where each task lives.
1465
+ const repoNames = all
1466
+ ? new Map(listRepos().map((r) => [r.id, r.repo_name]))
1467
+ : new Map();
1468
+ return ok({
1469
+ scope: all ? "all" : "current",
1470
+ tasks: all
1471
+ ? tasks.map((t) => ({ ...t, repo_name: repoNames.get(t.repo_id) ?? null }))
1472
+ : tasks,
1473
+ counts: {
1474
+ open: count({ openOnly: true }),
1475
+ backlog: count({ status: "backlog" }),
1476
+ next: count({ status: "next" }),
1477
+ doing: count({ status: "doing" }),
1478
+ blocked: count({ status: "blocked" }),
1479
+ done: count({ status: "done" }),
1480
+ },
1481
+ });
1482
+ }
1483
+ if (method === "POST" && pathname === "/api/tasks") {
1484
+ const targetRepo = repo || ensurePersonalWorkspace();
1485
+ const payload = body && typeof body === "object" ? body : {};
1486
+ const title = typeof payload.title === "string" ? payload.title : "";
1487
+ if (!title.trim())
1488
+ return err(400, "title required");
1489
+ let anchors;
1490
+ if (Array.isArray(payload.anchors)) {
1491
+ anchors = payload.anchors.filter((a) => typeof a === "string");
1492
+ }
1493
+ try {
1494
+ const task = insertTask({
1495
+ repoId: targetRepo.id,
1496
+ title,
1497
+ body: typeof payload.body === "string" ? payload.body : "",
1498
+ status: typeof payload.status === "string" ? payload.status : "backlog",
1499
+ anchors,
1500
+ source: typeof payload.source === "string" ? payload.source : "ui",
1501
+ });
1502
+ return ok({ task });
1503
+ }
1504
+ catch (error) {
1505
+ return err(400, error instanceof Error ? error.message : String(error));
1506
+ }
1507
+ }
1508
+ if (method === "PATCH" && pathname === "/api/tasks") {
1509
+ const all = searchParams.get("scope") === "all" ||
1510
+ bodyField(body, "scope") === "all" ||
1511
+ searchParams.get("repo") === "all";
1512
+ if (!all && !repo)
1513
+ return err(400, "Repo not initialized");
1514
+ const payload = body && typeof body === "object" ? body : {};
1515
+ const id = bodyField(body, "id");
1516
+ if (!id)
1517
+ return err(400, "id required");
1518
+ // In all-memory scope the card may belong to any repo, so find its real owner.
1519
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1520
+ if (!ownerId)
1521
+ return err(404, "Task not found");
1522
+ let anchors;
1523
+ if (Array.isArray(payload.anchors)) {
1524
+ anchors = payload.anchors.filter((a) => typeof a === "string");
1525
+ }
1526
+ try {
1527
+ const task = updateTask(ownerId, id, {
1528
+ title: typeof payload.title === "string" ? payload.title : undefined,
1529
+ body: typeof payload.body === "string" ? payload.body : undefined,
1530
+ status: typeof payload.status === "string" ? payload.status : undefined,
1531
+ anchors,
1532
+ });
1533
+ if (!task)
1534
+ return err(404, "Task not found");
1535
+ return ok({ task });
1536
+ }
1537
+ catch (error) {
1538
+ return err(400, error instanceof Error ? error.message : String(error));
1539
+ }
1540
+ }
1541
+ if (method === "POST" && pathname === "/api/tasks/complete") {
1542
+ const all = searchParams.get("scope") === "all" ||
1543
+ bodyField(body, "scope") === "all" ||
1544
+ searchParams.get("repo") === "all";
1545
+ if (!all && !repo)
1546
+ return err(400, "Repo not initialized");
1547
+ const id = bodyField(body, "id");
1548
+ if (!id)
1549
+ return err(400, "id required");
1550
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1551
+ if (!ownerId)
1552
+ return err(404, "Task not found");
1553
+ const task = completeTask(ownerId, id);
1554
+ if (!task)
1555
+ return err(404, "Task not found");
1556
+ return ok({ task });
1557
+ }
1558
+ if (method === "DELETE" && pathname === "/api/tasks") {
1559
+ const all = searchParams.get("scope") === "all" ||
1560
+ bodyField(body, "scope") === "all" ||
1561
+ searchParams.get("repo") === "all";
1562
+ if (!all && !repo)
1563
+ return err(400, "Repo not initialized");
1564
+ const id = searchParams.get("id") || bodyField(body, "id");
1565
+ if (!id)
1566
+ return err(400, "id required");
1567
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1568
+ if (!ownerId)
1569
+ return err(404, "Task not found");
1570
+ const removed = deleteTask(ownerId, id);
1571
+ if (!removed)
1572
+ return err(404, "Task not found");
1573
+ return ok({ deleted: id });
1574
+ }
1245
1575
  if (method === "GET" && pathname === "/api/usage/export") {
1246
1576
  const scope = searchParams.get("scope") ?? "current";
1247
1577
  const days = Number(searchParams.get("days") ?? "30");
@@ -0,0 +1,13 @@
1
+ export declare function desktopDir(pkgRoot?: string): string;
2
+ export declare function electronInstallHint(dir?: string): string;
3
+ /** Absolute path to the Electron binary, or null if desktop deps are missing. */
4
+ export declare function resolveElectronBinary(dir?: string): string | null;
5
+ /**
6
+ * Start or attach to amem ui, then open the Electron window.
7
+ * Owns server shutdown only when this process started it.
8
+ */
9
+ export declare function runDesktopApp(options?: {
10
+ port?: number;
11
+ cwd?: string;
12
+ pkgRoot?: string;
13
+ }): Promise<void>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Desktop (Electron) shell launcher for the localhost Brain UI.
3
+ */
4
+ import { spawn } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+ import { existsSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { loadPolicy } from "./policy.js";
10
+ import { buildUiLandingUrl, isAddrInUse, probeUiHealth, startUiServer, } from "./ui/server.js";
11
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
12
+ export function desktopDir(pkgRoot = PKG_ROOT) {
13
+ return join(pkgRoot, "desktop");
14
+ }
15
+ export function electronInstallHint(dir = desktopDir()) {
16
+ return `npm run app:setup # or: npm install --prefix "${dir}" && node desktop/scripts/ensure-electron.mjs`;
17
+ }
18
+ /** Absolute path to the Electron binary, or null if desktop deps are missing. */
19
+ export function resolveElectronBinary(dir = desktopDir()) {
20
+ const pkgJson = join(dir, "package.json");
21
+ if (!existsSync(pkgJson))
22
+ return null;
23
+ const candidates = [
24
+ join(dir, "node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"),
25
+ join(dir, "node_modules/electron/dist/electron"),
26
+ join(dir, "node_modules/electron/dist/electron.exe"),
27
+ ];
28
+ try {
29
+ const require = createRequire(pkgJson);
30
+ const bin = require("electron");
31
+ if (typeof bin === "string" && bin && existsSync(bin))
32
+ return bin;
33
+ }
34
+ catch {
35
+ /* path.txt missing or postinstall incomplete — try dist paths */
36
+ }
37
+ for (const candidate of candidates) {
38
+ if (existsSync(candidate))
39
+ return candidate;
40
+ }
41
+ return null;
42
+ }
43
+ function spawnElectron(electronBin, dir, url) {
44
+ return new Promise((resolve, reject) => {
45
+ const env = { ...process.env, AMEM_UI_URL: url };
46
+ // Parent IDEs sometimes set this; it makes Electron run as plain Node.
47
+ delete env.ELECTRON_RUN_AS_NODE;
48
+ const child = spawn(electronBin, [".", url], {
49
+ cwd: dir,
50
+ env,
51
+ stdio: "inherit",
52
+ });
53
+ child.on("error", reject);
54
+ child.on("exit", (code, signal) => {
55
+ if (signal) {
56
+ resolve(1);
57
+ return;
58
+ }
59
+ resolve(code ?? 0);
60
+ });
61
+ });
62
+ }
63
+ /**
64
+ * Start or attach to amem ui, then open the Electron window.
65
+ * Owns server shutdown only when this process started it.
66
+ */
67
+ export async function runDesktopApp(options = {}) {
68
+ const port = options.port ?? 7843;
69
+ const cwd = options.cwd ?? process.cwd();
70
+ const dir = desktopDir(options.pkgRoot ?? PKG_ROOT);
71
+ const electronBin = resolveElectronBinary(dir);
72
+ if (!electronBin) {
73
+ const err = new Error([
74
+ "Electron desktop shell is not installed.",
75
+ `Run once: ${electronInstallHint(dir)}`,
76
+ "Then: amem app",
77
+ "(Browser UI still works with: amem ui)",
78
+ ].join("\n"));
79
+ err.code = "AMEM_ELECTRON_MISSING";
80
+ throw err;
81
+ }
82
+ const policy = loadPolicy().policy;
83
+ const landing = buildUiLandingUrl(port, cwd);
84
+ let owned = null;
85
+ try {
86
+ owned = await startUiServer({
87
+ port,
88
+ cwd,
89
+ openBrowser: false,
90
+ host: policy.ui_bind,
91
+ landingUrl: landing,
92
+ });
93
+ }
94
+ catch (error) {
95
+ if (!isAddrInUse(error))
96
+ throw error;
97
+ const probe = await probeUiHealth(port);
98
+ if (!probe.hasVault) {
99
+ throw new Error([
100
+ `Port ${port} is already serving an older amem without lock/backup APIs.`,
101
+ "Stop that process, then run amem app again:",
102
+ ` lsof -nP -iTCP:${port} -sTCP:LISTEN`,
103
+ ].join("\n"));
104
+ }
105
+ }
106
+ console.log(`amem app → ${landing}`);
107
+ if (owned)
108
+ console.log("UI server started for this window (localhost only).");
109
+ else
110
+ console.log("Attached to UI server already running on this port.");
111
+ try {
112
+ const code = await spawnElectron(electronBin, dir, landing);
113
+ if (code !== 0)
114
+ process.exitCode = code;
115
+ }
116
+ finally {
117
+ if (owned) {
118
+ try {
119
+ await owned.close();
120
+ }
121
+ catch {
122
+ /* ignore */
123
+ }
124
+ }
125
+ }
126
+ }
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}`,