@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.
- package/README.md +98 -4
- package/desktop/icons/icon-1024.png +0 -0
- package/desktop/icons/icon.icns +0 -0
- package/desktop/icons/icon.png +0 -0
- package/desktop/main.js +100 -0
- package/desktop/package.json +16 -0
- package/desktop/preload.js +4 -0
- package/desktop/scripts/ensure-electron.mjs +122 -0
- package/dist/api/routes.js +331 -1
- package/dist/app-shell.d.ts +13 -0
- package/dist/app-shell.js +126 -0
- package/dist/attest.d.ts +13 -0
- package/dist/attest.js +44 -0
- package/dist/cli.js +258 -2
- 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/hook.js +8 -1
- package/dist/hygiene.d.ts +1 -2
- package/dist/hygiene.js +1 -2
- package/dist/install/hosts.js +8 -0
- package/dist/install/skills.js +10 -5
- package/dist/license.d.ts +1 -0
- package/dist/license.js +21 -19
- package/dist/mcp.js +221 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +16 -1
- package/dist/publish.d.ts +1 -1
- package/dist/publish.js +1 -0
- 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/docs/npm-release.md +1 -1
- package/package.json +7 -2
- package/scripts/postinstall.js +62 -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 +435 -227
- package/ui-static/index.html +11 -34
- package/ui-static/styles.css +299 -0
package/dist/api/routes.js
CHANGED
|
@@ -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}`,
|