@iamem/amem 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +64 -1
  2. package/dist/api/routes.js +337 -3
  3. package/dist/attest.d.ts +13 -0
  4. package/dist/attest.js +44 -0
  5. package/dist/capture.js +14 -5
  6. package/dist/cli.js +291 -6
  7. package/dist/context.d.ts +10 -1
  8. package/dist/context.js +105 -3
  9. package/dist/db.d.ts +141 -0
  10. package/dist/db.js +398 -0
  11. package/dist/embed.js +5 -14
  12. package/dist/estimate.d.ts +25 -1
  13. package/dist/estimate.js +36 -3
  14. package/dist/freshness.d.ts +7 -0
  15. package/dist/freshness.js +8 -1
  16. package/dist/hook.js +8 -1
  17. package/dist/hygiene.d.ts +26 -2
  18. package/dist/hygiene.js +42 -3
  19. package/dist/install/hosts.d.ts +15 -0
  20. package/dist/install/hosts.js +82 -4
  21. package/dist/install/skills.js +10 -5
  22. package/dist/kinds.d.ts +18 -0
  23. package/dist/kinds.js +80 -3
  24. package/dist/license.d.ts +1 -0
  25. package/dist/license.js +21 -19
  26. package/dist/mcp.js +221 -0
  27. package/dist/platforms.js +6 -0
  28. package/dist/policy.d.ts +6 -0
  29. package/dist/policy.js +16 -1
  30. package/dist/remember-contract.js +13 -4
  31. package/dist/repo-identity.d.ts +10 -0
  32. package/dist/repo-identity.js +20 -1
  33. package/dist/skill-capture.d.ts +43 -0
  34. package/dist/skill-capture.js +146 -0
  35. package/dist/skills.d.ts +106 -0
  36. package/dist/skills.js +422 -0
  37. package/docs/backlog.md +9 -0
  38. package/package.json +2 -1
  39. package/scripts/mcp-launch.sh +26 -0
  40. package/skills/amem-tasks/SKILL.md +100 -0
  41. package/skills/amem-write-skill/SKILL.md +99 -0
  42. package/templates/cursor-rule.mdc +16 -6
  43. package/templates/policy.deny-default.toml +5 -0
  44. package/templates/policy.example.toml +8 -0
  45. package/ui-static/app.js +458 -233
  46. package/ui-static/index.html +11 -34
  47. package/ui-static/styles.css +310 -1
package/ui-static/app.js CHANGED
@@ -4,7 +4,7 @@ const PLATFORM_STORAGE = "amem.selectedPlatforms";
4
4
  const WELCOME_STORAGE = "amem.welcome.dismissed";
5
5
  const BRAIN_SCOPE_STORAGE = "amem.brain.scope";
6
6
  const MEMORY_VIZ_STORAGE = "amem.memory.viz";
7
- const TABS = ["welcome", "setup", "dashboard", "brain", "analytics", "stats"];
7
+ const TABS = ["setup", "dashboard", "brain", "analytics", "stats", "tasks", "skills"];
8
8
  const VIZ_MODES = ["map", "blocks", "orbit"];
9
9
  const FALLBACK_CLIENTS = [
10
10
  { id: "cursor", label: "Cursor", hint: "Rules, skills, hooks" },
@@ -83,26 +83,14 @@ function dismissWelcome() {
83
83
 
84
84
  function initialTab() {
85
85
  const fromUrl = initialUrl.get("tab");
86
- // Paid users: never force the sell card on every launch.
87
- if (isPaidLicenseCached()) {
88
- if (fromUrl === "stats") return "analytics";
89
- if (TABS.includes(fromUrl)) return fromUrl;
90
- return "dashboard";
91
- }
92
- if (!welcomeDismissed() && fromUrl !== "brain" && fromUrl !== "stats" && fromUrl !== "analytics" && fromUrl !== "dashboard") return "welcome";
93
86
  if (fromUrl === "stats") return "analytics";
94
87
  if (TABS.includes(fromUrl)) return fromUrl;
95
- return "setup";
88
+ return "dashboard";
96
89
  }
97
90
 
98
- /** Best-effort sync read of last known license for first paint / initialTab (may be stale). */
91
+ /** All features are free and unlocked. */
99
92
  function isPaidLicenseCached() {
100
- try {
101
- const raw = localStorage.getItem("amem.license.tier");
102
- return raw === "pro" || raw === "it";
103
- } catch {
104
- return false;
105
- }
93
+ return true;
106
94
  }
107
95
 
108
96
  function persistLicenseTier(tier) {
@@ -218,6 +206,9 @@ const state = {
218
206
  selectedNode: null,
219
207
  activeEventId: null,
220
208
  brainFilter: "files",
209
+ skills: [],
210
+ skillDrafts: [],
211
+ skillsDir: "",
221
212
  draftSelected: {},
222
213
  selectedFile: null,
223
214
  brainSearch: "",
@@ -327,6 +318,12 @@ function setTab(tab) {
327
318
  persistBrainAll(true);
328
319
  state.brainError = null;
329
320
  resetBrainView();
321
+ } else if (tab === "tasks") {
322
+ // Agents file tasks against whatever repo they were working in, which is rarely the
323
+ // folder the UI was launched from. Default to every memory or the board looks empty.
324
+ state.brainAll = true;
325
+ persistBrainAll(true);
326
+ stopAllViz();
330
327
  } else {
331
328
  stopAllViz();
332
329
  }
@@ -451,19 +448,6 @@ function paintVault() {
451
448
  ? `Last backup: ${last.name} · ${last.encrypted ? "encrypted" : "plaintext"} · stays in ${state.vault.backup.dir}`
452
449
  : `Backups go to ${state.vault.backup?.dir || "~/.amem/backups"} on this machine.`;
453
450
  }
454
- const shop = state.shop;
455
- const row = $("#shopRow");
456
- if (row) row.classList.toggle("hidden", shop && shop.enabled === false);
457
- const pro = $("#buyPro");
458
- const it = $("#buyIt");
459
- if (pro && shop?.proUrl) {
460
- pro.setAttribute("href", shop.proUrl);
461
- pro.textContent = `Buy Pro · ${shop.proPrice || "$12"}`;
462
- }
463
- if (it && shop?.itUrl) {
464
- it.setAttribute("href", shop.itUrl);
465
- it.textContent = `Buy IT · ${shop.itPrice || "$49"}`;
466
- }
467
451
  }
468
452
 
469
453
  async function focusPersonal() {
@@ -810,89 +794,8 @@ function defaultProposal() {
810
794
  }
811
795
 
812
796
  function renderWelcome() {
813
- const main = $("#main");
814
- setMainMode("page");
815
- renderPageInsight();
816
- if (isPaidLicense()) {
817
- dismissWelcome();
818
- setTab("dashboard");
819
- return;
820
- }
821
- const shop = state.shop || {};
822
- const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
823
- const itUrl = shop.itUrl || "https://getamem.com/buy/it";
824
- const proPrice = shop.proPrice || "$12";
825
- const itPrice = shop.itPrice || "$49";
826
- const tier = state.license?.tier || "free";
827
- const shopOn = shop.enabled !== false;
828
-
829
- main.innerHTML = `
830
- <section class="hero welcome-hero">
831
- <div class="hero-inner welcome-wide">
832
- <div class="welcome-brand">
833
- <div class="welcome-logo" aria-hidden="true">a</div>
834
- <h1>amem</h1>
835
- </div>
836
- <p>Local agent memory for Cursor and any MCP host. Facts stay in <code>~/.amem</code> on this machine. Free already remembers; Pro = better local ranking + cleanup + rules sync. IT = richer attest for security/DevEx — not more memory.</p>
837
- <div class="plan-grid">
838
- <article class="plan-card ${tier === "free" ? "current" : ""}">
839
- <h2>Free</h2>
840
- <p class="plan-price">$0</p>
841
- <ul>
842
- <li>Memory, MCP, and Stats</li>
843
- <li>Lock and local backup</li>
844
- <li>Hashing embedder — no download</li>
845
- </ul>
846
- <button class="btn" type="button" id="continueFree">Continue with free</button>
847
- </article>
848
- <article class="plan-card ${tier === "pro" ? "current" : ""}">
849
- <h2>Pro</h2>
850
- <p class="plan-price">${esc(proPrice)} <span>once</span></p>
851
- <ul>
852
- <li>Everything in Free</li>
853
- <li>Local n-gram or external embedder</li>
854
- <li>Hygiene inbox — decay and merge</li>
855
- <li>Pin facts → Cursor rules</li>
856
- </ul>
857
- ${
858
- shopOn
859
- ? `<a class="btn" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro</a>`
860
- : `<p class="note">Shop is off on this machine.</p>`
861
- }
862
- </article>
863
- <article class="plan-card ${tier === "it" ? "current" : ""}">
864
- <h2>IT</h2>
865
- <p class="plan-price">${esc(itPrice)} <span>once</span></p>
866
- <ul>
867
- <li>Everything in Pro</li>
868
- <li>Richer <code>amem doctor --attest</code> (the exclusive)</li>
869
- <li>Solo agents usually stop at Pro</li>
870
- </ul>
871
- ${
872
- shopOn
873
- ? `<a class="btn secondary" href="${esc(itUrl)}" target="_blank" rel="noopener">Buy IT</a>`
874
- : `<p class="note">Shop is off on this machine.</p>`
875
- }
876
- </article>
877
- </div>
878
- ${licenseApplyHtml("welcome")}
879
- <p class="note welcome-note">After pay, apply <code>amem-license.json</code> above (or in Setup). Memory never uploads.</p>
880
- </div>
881
- </section>`;
882
-
883
- $("#continueFree")?.addEventListener("click", () => {
884
- dismissWelcome();
885
- setTab("setup");
886
- });
887
- wireLicenseApply("welcome", async () => {
888
- await refreshVault();
889
- if (isPaidLicense()) {
890
- dismissWelcome();
891
- setTab("setup");
892
- return;
893
- }
894
- renderWelcome();
895
- });
797
+ dismissWelcome();
798
+ setTab("dashboard");
896
799
  }
897
800
 
898
801
  const SETUP_STEPS = [
@@ -1051,18 +954,14 @@ function workspaceBlockHtml(ctx) {
1051
954
  }
1052
955
 
1053
956
  function isPaidLicense() {
1054
- const tier = String(state.license?.tier || state.status?.license?.tier || "free").toLowerCase();
1055
- const paid = state.license?.valid !== false && (tier === "pro" || tier === "it");
1056
- if (paid) persistLicenseTier(tier);
1057
- paintPlansNavVisibility(paid);
1058
- return paid;
957
+ return true;
1059
958
  }
1060
959
 
1061
960
  /** Paid users don't need the Plans sell tab in the sidebar (Setup still has Apply license). */
1062
961
  function paintPlansNavVisibility(paid) {
1063
962
  const btn = document.querySelector('#tabs button[data-tab="welcome"]');
1064
963
  if (!btn) return;
1065
- btn.classList.toggle("hidden", Boolean(paid));
964
+ btn.classList.add("hidden");
1066
965
  }
1067
966
 
1068
967
  function licenseApplyHtml(idPrefix = "lic") {
@@ -1254,74 +1153,27 @@ function wireProOnboard(idPrefix) {
1254
1153
  }
1255
1154
 
1256
1155
  function shopBuyCardHtml() {
1257
- if (state.shop?.enabled === false) return "";
1258
- const shop = state.shop || {};
1259
- const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
1260
- const itUrl = shop.itUrl || "https://getamem.com/buy/it";
1261
- const proPrice = shop.proPrice || "$12";
1262
- const itPrice = shop.itPrice || "$49";
1263
- const tier = String(state.license?.tier || state.status?.license?.tier || "free").toLowerCase();
1264
- const proOwned = tier === "pro" || tier === "it";
1265
- const itOwned = tier === "it";
1266
- if (proOwned) {
1267
- return `
1268
- <div class="setup-shop">
1269
- <div class="setup-shop-head">
1270
- <div>
1271
- <h2>${itOwned ? "IT license" : "Pro license"}</h2>
1272
- <p class="note">Extras unlocked on this machine. ${
1273
- itOwned
1274
- ? "IT exclusive: richer doctor --attest."
1275
- : "Upgrade to IT only if you need richer host attestation."
1276
- }</p>
1277
- </div>
1278
- ${itOwned ? "" : `<a class="btn secondary small" href="${esc(itUrl)}" target="_blank" rel="noopener">IT · ${esc(itPrice)}</a>`}
1279
- </div>
1280
- ${proOnboardHtml("setup")}
1281
- ${itPackCardHtml()}
1282
- </div>`;
1283
- }
1284
1156
  return `
1285
1157
  <div class="setup-shop">
1286
1158
  <div class="setup-shop-head">
1287
1159
  <div>
1288
- <h2>Signed license</h2>
1289
- <p class="note">Pay on getamem.com, then apply the file here no CLI required. Memory never uploads. <a href="https://getamem.com/pricing" target="_blank" rel="noopener">Full matrix</a></p>
1160
+ <h2>All Features Unlocked</h2>
1161
+ <p class="note">amem is 100% free and private. Pro retrieval (n-gram semantic search), memory hygiene, rules sync, security attest, and tasks are all active on this machine.</p>
1290
1162
  </div>
1291
- <button class="btn secondary small" id="seePlans" type="button">What's included</button>
1292
- </div>
1293
- <div class="setup-shop-grid">
1294
- <article class="setup-buy">
1295
- <div class="setup-buy-top"><strong>Pro</strong></div>
1296
- <p class="setup-buy-price">${esc(proPrice)} <span>once</span></p>
1297
- <p class="note">Better local ranking + cleanup + rules sync.</p>
1298
- <a class="btn" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro</a>
1299
- </article>
1300
- <article class="setup-buy">
1301
- <div class="setup-buy-top"><strong>IT</strong></div>
1302
- <p class="setup-buy-price">${esc(itPrice)} <span>once</span></p>
1303
- <p class="note">Pro + richer attest packet for security tickets.</p>
1304
- <a class="btn secondary" href="${esc(itUrl)}" target="_blank" rel="noopener">Buy IT</a>
1305
- </article>
1306
1163
  </div>
1307
- ${licenseApplyHtml("setup")}
1164
+ ${proOnboardHtml("setup")}
1308
1165
  ${itPackCardHtml()}
1309
1166
  </div>`;
1310
1167
  }
1311
1168
 
1312
1169
  function itPackCardHtml() {
1313
- const itOwned = String(state.license?.tier || "").toLowerCase() === "it";
1314
1170
  return `
1315
1171
  <div class="it-pack-card" id="itPackCard">
1316
- <h2>Security pack (local)</h2>
1317
- <p class="note">Writes policy, MDM plist, SBOM, and offboard script under <code>~/.amem/it-pack</code>. Available on Free ${
1318
- itOwned
1319
- ? "your IT license stamps richer attest when you run doctor."
1320
- : "IT’s exclusive is the richer <code>amem doctor --attest</code> packet, not this folder."
1321
- }</p>
1172
+ <h2>Security & Ops pack (local)</h2>
1173
+ <p class="note">Writes policy, MDM plist, SBOM, and offboard script under <code>~/.amem/it-pack</code>. Full <code>amem doctor --attest</code> packet is included.</p>
1322
1174
  <div class="actions">
1323
1175
  <button class="btn secondary" type="button" id="genItPack">Generate security pack</button>
1324
- ${itOwned ? `<button class="btn secondary" type="button" id="runAttest">Show attest summary</button>` : ""}
1176
+ <button class="btn secondary" type="button" id="runAttest">Show attest summary</button>
1325
1177
  </div>
1326
1178
  <pre class="it-pack-out hidden" id="itPackOut"></pre>
1327
1179
  </div>`;
@@ -1995,14 +1847,7 @@ function brainErrorNote() {
1995
1847
  return m;
1996
1848
  }
1997
1849
 
1998
- function showdownColumnHtml(title, claims, locked, shopUrl) {
1999
- if (locked) {
2000
- return `<div class="showdown-col">
2001
- <h3>${esc(title)}</h3>
2002
- <p class="note">Pro retrieval needs a license.</p>
2003
- <a class="btn small" href="${esc(shopUrl || "https://getamem.com/buy/pro")}" target="_blank" rel="noopener">Buy Pro</a>
2004
- </div>`;
2005
- }
1850
+ function showdownColumnHtml(title, claims) {
2006
1851
  if (!claims?.length) {
2007
1852
  return `<div class="showdown-col"><h3>${esc(title)}</h3><p class="note">No ranked hits.</p></div>`;
2008
1853
  }
@@ -2029,18 +1874,15 @@ function paintShowdownResult(data) {
2029
1874
  el.innerHTML = "";
2030
1875
  return;
2031
1876
  }
2032
- const shop = state.shop || {};
2033
1877
  const proOnly = data.proOnlyIds?.length || 0;
2034
1878
  el.innerHTML = `
2035
1879
  <div class="showdown-result-bar">
2036
- <p class="note">Pro-only hits in top results: <b>${proOnly}</b>${
2037
- data.proLocked ? " · apply a Pro license to unlock the right column" : ""
2038
- }</p>
1880
+ <p class="note">Semantic (n-gram) unique hits in top results: <b>${proOnly}</b></p>
2039
1881
  <button class="btn secondary small" type="button" id="showdownClear">Clear</button>
2040
1882
  </div>
2041
1883
  <div class="showdown-grid">
2042
- ${showdownColumnHtml("Free (hash)", data.free, false)}
2043
- ${showdownColumnHtml("Pro (n-gram)", data.pro, Boolean(data.proLocked), shop.proUrl)}
1884
+ ${showdownColumnHtml("Hash (128-dim)", data.free)}
1885
+ ${showdownColumnHtml("Pro n-gram (256-dim)", data.pro)}
2044
1886
  </div>`;
2045
1887
  $("#showdownClear")?.addEventListener("click", () => clearRetrievalShowdown());
2046
1888
  }
@@ -2094,50 +1936,9 @@ function dismissSoftPaywall() {
2094
1936
 
2095
1937
  async function loadSoftPaywall() {
2096
1938
  const el = $("#softPaywall");
2097
- if (!el) return;
2098
- if (isPaidLicense() || softPaywallDismissed()) {
1939
+ if (el) {
2099
1940
  el.classList.add("hidden");
2100
1941
  el.innerHTML = "";
2101
- return;
2102
- }
2103
- try {
2104
- const path = state.brainAll ? "/api/hygiene/preview?scope=all" : "/api/hygiene/preview";
2105
- const preview = state.brainAll ? await apiUnscoped(path) : await api(path);
2106
- if (!preview?.softPaywall) {
2107
- el.classList.add("hidden");
2108
- el.innerHTML = "";
2109
- return;
2110
- }
2111
- const noise = (preview.staleCount || 0) + (preview.duplicateCount || 0);
2112
- const sessions = Number(preview.sessionCount || 0);
2113
- const ratio = Number(preview.sessionRatio || 0);
2114
- const sessionHeavy = sessions >= 25 && ratio >= 0.55;
2115
- const shop = state.shop || {};
2116
- const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
2117
- el.classList.remove("hidden");
2118
- const why = sessionHeavy
2119
- ? `${preview.active} facts · <b>${Math.round(ratio * 100)}%</b> are short-lived <code>session</code> kinds (~${sessions}). Free preview → Pro applies Cleanup to archive unused sessions.`
2120
- : `${preview.active} facts · ~${noise} unused/duplicates → about <b>${preview.afterCleanup}</b> after cleanup. Free still works — this is optional.`;
2121
- el.innerHTML = `
2122
- <div class="soft-paywall-inner">
2123
- <div>
2124
- <strong>${sessionHeavy ? "Session noise is crowding retrieval" : "Pro can clean this"}</strong>
2125
- <p class="note" style="margin:0.25rem 0 0">${why}</p>
2126
- </div>
2127
- <div class="soft-paywall-actions">
2128
- <a class="btn small" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro · Apply cleanup</a>
2129
- <button class="btn secondary small" type="button" id="softPaywallShowdown">Compare retrieval</button>
2130
- <button class="btn secondary small" type="button" id="softPaywallDismiss">Dismiss</button>
2131
- </div>
2132
- </div>`;
2133
- $("#softPaywallDismiss")?.addEventListener("click", () => dismissSoftPaywall());
2134
- $("#softPaywallShowdown")?.addEventListener("click", () => {
2135
- state.showdownOpen = true;
2136
- renderBrain();
2137
- });
2138
- maybeNudgeShowdown();
2139
- } catch {
2140
- el.classList.add("hidden");
2141
1942
  }
2142
1943
  }
2143
1944
 
@@ -3307,9 +3108,26 @@ function formatPct(n) {
3307
3108
 
3308
3109
  function formatUsd(n) {
3309
3110
  const v = Number(n);
3310
- if (!Number.isFinite(v) || v <= 0) return "$0";
3311
- if (v < 0.01) return `~$${v.toFixed(3)}`;
3312
- return `~$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
3111
+ if (!Number.isFinite(v) || v === 0) return "$0";
3112
+ // Savings can be negative now (a packet that returned little still cost
3113
+ // input tokens). Rendering that as "$0" would hide exactly the case worth
3114
+ // seeing, so show the sign.
3115
+ const sign = v < 0 ? "-" : "~";
3116
+ const abs = Math.abs(v);
3117
+ if (abs < 0.01) return `${sign}$${abs.toFixed(3)}`;
3118
+ return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
3119
+ }
3120
+
3121
+ /**
3122
+ * How much to trust the savings figures. Until a real reported saving exists
3123
+ * these are model output, and the UI should say so rather than implying the
3124
+ * number was measured.
3125
+ */
3126
+ function savingsCaveat(agg) {
3127
+ const p = agg?.pricing;
3128
+ if (p?.calibrated) return "calibrated against reported savings";
3129
+ const perFile = p?.assumedTokensPerFile ?? 4000;
3130
+ return `modelled, never calibrated · assumes ${formatTokens(perFile)} tok per file avoided`;
3313
3131
  }
3314
3132
 
3315
3133
  function formatChartDay(iso) {
@@ -3322,6 +3140,384 @@ function renderStats() {
3322
3140
  return renderAnalytics();
3323
3141
  }
3324
3142
 
3143
+ const TASK_COLS = [
3144
+ { id: "backlog", label: "Backlog" },
3145
+ { id: "next", label: "Next" },
3146
+ { id: "doing", label: "Doing" },
3147
+ { id: "blocked", label: "Blocked" },
3148
+ { id: "done", label: "Done" },
3149
+ ];
3150
+
3151
+ async function refreshTasks() {
3152
+ const scopeQuery = state.brainAll ? "?scope=all&include_done=1" : "?include_done=1";
3153
+ const data = state.brainAll
3154
+ ? await apiUnscoped(`/api/tasks${scopeQuery}`)
3155
+ : await api(`/api/tasks${scopeQuery}`);
3156
+ state.tasks = data.tasks || [];
3157
+ state.taskCounts = data.counts || {};
3158
+ return data;
3159
+ }
3160
+
3161
+ /** Skills are a global library, so this never uses the repo-scoped api() helper. */
3162
+ async function refreshSkills() {
3163
+ const [list, drafts] = await Promise.all([
3164
+ apiUnscoped("/api/skills"),
3165
+ apiUnscoped("/api/skills/drafts"),
3166
+ ]);
3167
+ state.skills = list.skills || [];
3168
+ state.skillsDir = list.dir || "";
3169
+ state.skillDrafts = drafts.drafts || [];
3170
+ return list;
3171
+ }
3172
+
3173
+ function skillCardHtml(s) {
3174
+ const desc = s.description
3175
+ ? `<p class="skill-card-desc">${esc(String(s.description).slice(0, 200))}</p>`
3176
+ : `<p class="skill-card-desc muted">No description — agents rank on this, so add one.</p>`;
3177
+ const tags = (s.tags || [])
3178
+ .slice(0, 4)
3179
+ .map((t) => `<span class="skill-tag">${esc(t)}</span>`)
3180
+ .join("");
3181
+ const uses = s.uses > 0 ? `<span class="skill-uses">used ${s.uses}×</span>` : "";
3182
+ return `
3183
+ <article class="skill-card" data-name="${esc(s.name)}">
3184
+ <header class="skill-card-head">
3185
+ <h3>${esc(s.name)}</h3>
3186
+ ${uses}
3187
+ </header>
3188
+ ${desc}
3189
+ <div class="skill-card-tags">${tags}</div>
3190
+ <div class="skill-card-actions">
3191
+ <button type="button" class="btn secondary small skill-view">View</button>
3192
+ <button type="button" class="btn secondary small skill-del" title="Delete">✕</button>
3193
+ </div>
3194
+ </article>`;
3195
+ }
3196
+
3197
+ function skillDraftHtml(d) {
3198
+ const why = (d.reasons || []).length
3199
+ ? `<p class="skill-draft-why">${esc(d.reasons.join(" · "))}</p>`
3200
+ : "";
3201
+ const label =
3202
+ d.kind === "revision"
3203
+ ? `Revise <strong>${esc(d.target_skill || "")}</strong>`
3204
+ : d.kind === "create"
3205
+ ? "Staged for review"
3206
+ : "Suggested";
3207
+ // A suggestion has no body yet: only an agent can write one, so do not offer Approve.
3208
+ const approve = d.has_content
3209
+ ? `<button type="button" class="btn small skill-draft-apply">Approve</button>`
3210
+ : "";
3211
+ const hint = d.has_content
3212
+ ? ""
3213
+ : `<p class="skill-draft-hint">Ask your agent to write this up — it will see the nudge in its next context packet.</p>`;
3214
+ return `
3215
+ <article class="skill-draft" data-id="${esc(d.id)}">
3216
+ <span class="skill-draft-kind">${label}</span>
3217
+ <h4>${esc(d.title)}</h4>
3218
+ ${why}
3219
+ ${hint}
3220
+ <div class="skill-draft-actions">
3221
+ ${approve}
3222
+ <button type="button" class="btn secondary small skill-draft-dismiss">Dismiss</button>
3223
+ </div>
3224
+ </article>`;
3225
+ }
3226
+
3227
+ function renderSkills() {
3228
+ const main = $("#main");
3229
+ setMainMode("page");
3230
+ stopAllViz();
3231
+
3232
+ const skills = state.skills || [];
3233
+ const drafts = state.skillDrafts || [];
3234
+ const draftsHtml = drafts.length
3235
+ ? `<section class="skill-drafts">
3236
+ <h2>Pending (${drafts.length})</h2>
3237
+ <p class="note">Procedures amem noticed, or agent writes waiting on approval.</p>
3238
+ <div class="skill-draft-list">${drafts.map(skillDraftHtml).join("")}</div>
3239
+ </section>`
3240
+ : "";
3241
+
3242
+ const listHtml = skills.length
3243
+ ? `<div class="skill-grid">${skills.map(skillCardHtml).join("")}</div>`
3244
+ : `<div class="skill-empty">
3245
+ <h2>No skills yet</h2>
3246
+ <p>Skills are procedures agents load only when relevant — longer than a memory fact, and kept out of every prompt until needed.</p>
3247
+ <p class="note">Create one with <code>amem skills new &lt;name&gt;</code>, or let an agent save one with <code>amem_skill_save</code> after it solves something worth repeating.</p>
3248
+ </div>`;
3249
+
3250
+ main.innerHTML = `
3251
+ <section class="dash-page">
3252
+ <header class="dash-head">
3253
+ <div>
3254
+ <h1>Skills</h1>
3255
+ <p class="sub">Procedural memory — ${skills.length} skill(s) agents can load on demand. Facts live in Memory.</p>
3256
+ </div>
3257
+ <div class="dash-actions">
3258
+ <button class="btn secondary small" type="button" data-go="brain">Open Memory</button>
3259
+ <button class="btn secondary small" type="button" data-go="tasks">Tasks</button>
3260
+ </div>
3261
+ </header>
3262
+ <p class="note">Stored in <code>${esc(state.skillsDir || "~/.amem/skills")}</code> · agents see names and descriptions only until they load one</p>
3263
+ ${draftsHtml}
3264
+ ${listHtml}
3265
+ <div class="skill-detail" id="skillDetail" hidden></div>
3266
+ </section>`;
3267
+
3268
+ renderPageInsight();
3269
+
3270
+ main.querySelectorAll("[data-go]").forEach((b) => {
3271
+ b.addEventListener("click", () => {
3272
+ const tab = b.getAttribute("data-go");
3273
+ if (tab) setTab(tab);
3274
+ });
3275
+ });
3276
+
3277
+ main.querySelectorAll(".skill-view").forEach((btn) => {
3278
+ btn.addEventListener("click", async () => {
3279
+ const name = btn.closest(".skill-card")?.getAttribute("data-name");
3280
+ if (!name) return;
3281
+ try {
3282
+ const data = await apiUnscoped(`/api/skills/view?name=${encodeURIComponent(name)}`);
3283
+ const host = $("#skillDetail");
3284
+ host.hidden = false;
3285
+ host.innerHTML = `
3286
+ <header class="skill-detail-head">
3287
+ <h2>${esc(data.name)}</h2>
3288
+ <button type="button" class="btn secondary small" id="skillDetailClose">Close</button>
3289
+ </header>
3290
+ <pre class="skill-detail-body">${esc(data.content || "")}</pre>`;
3291
+ $("#skillDetailClose")?.addEventListener("click", () => {
3292
+ host.hidden = true;
3293
+ host.innerHTML = "";
3294
+ });
3295
+ host.scrollIntoView({ behavior: "smooth", block: "nearest" });
3296
+ } catch (e) {
3297
+ alert(e.message || e);
3298
+ }
3299
+ });
3300
+ });
3301
+
3302
+ main.querySelectorAll(".skill-del").forEach((btn) => {
3303
+ btn.addEventListener("click", async () => {
3304
+ const name = btn.closest(".skill-card")?.getAttribute("data-name");
3305
+ if (!name || !confirm(`Delete skill "${name}"? This removes the file from disk.`)) return;
3306
+ try {
3307
+ await apiUnscoped(`/api/skills?name=${encodeURIComponent(name)}`, { method: "DELETE" });
3308
+ await refreshSkills();
3309
+ renderSkills();
3310
+ } catch (e) {
3311
+ alert(e.message || e);
3312
+ }
3313
+ });
3314
+ });
3315
+
3316
+ const draftAction = async (btn, path) => {
3317
+ const id = btn.closest(".skill-draft")?.getAttribute("data-id");
3318
+ if (!id) return;
3319
+ try {
3320
+ await apiUnscoped(path, { method: "POST", body: JSON.stringify({ id }) });
3321
+ await refreshSkills();
3322
+ renderSkills();
3323
+ } catch (e) {
3324
+ alert(e.message || e);
3325
+ }
3326
+ };
3327
+ main.querySelectorAll(".skill-draft-apply").forEach((btn) => {
3328
+ btn.addEventListener("click", () => draftAction(btn, "/api/skills/drafts/apply"));
3329
+ });
3330
+ main.querySelectorAll(".skill-draft-dismiss").forEach((btn) => {
3331
+ btn.addEventListener("click", () => draftAction(btn, "/api/skills/drafts/dismiss"));
3332
+ });
3333
+ }
3334
+
3335
+ /** Where a task created from this board will be filed. */
3336
+ function currentRepoLabel() {
3337
+ const repo = state.status?.repo || matchBoundRepo();
3338
+ return repo?.repo_name || "this folder";
3339
+ }
3340
+
3341
+ function taskCardHtml(t) {
3342
+ const body = t.body ? `<p class="tasks-card-body">${esc(String(t.body).slice(0, 160))}</p>` : "";
3343
+ const opts = TASK_COLS.map(
3344
+ (c) => `<option value="${c.id}" ${c.id === t.status ? "selected" : ""}>${esc(c.label)}</option>`,
3345
+ ).join("");
3346
+ // Across memories a bare title is ambiguous — say which memory the task came from.
3347
+ const owner =
3348
+ state.brainAll && t.repo_name
3349
+ ? `<span class="tasks-card-repo" title="Memory this task belongs to">${esc(t.repo_name)}</span>`
3350
+ : "";
3351
+ return `
3352
+ <article class="tasks-card" data-id="${esc(t.id)}" draggable="true">
3353
+ ${owner}
3354
+ <h3>${esc(t.title)}</h3>
3355
+ ${body}
3356
+ <div class="tasks-card-actions">
3357
+ <select class="tasks-status" aria-label="Move status">${opts}</select>
3358
+ <button type="button" class="btn secondary small tasks-del" title="Delete">✕</button>
3359
+ </div>
3360
+ </article>`;
3361
+ }
3362
+
3363
+ function renderTasks() {
3364
+ const main = $("#main");
3365
+ setMainMode("page");
3366
+ stopAllViz();
3367
+ renderPageInsight();
3368
+ const tasks = Array.isArray(state.tasks) ? state.tasks : [];
3369
+ const focus = statsFocusLabel();
3370
+ const openCount = Number(state.taskCounts?.open ?? tasks.filter((t) => t.status !== "done").length);
3371
+ const byStatus = Object.fromEntries(TASK_COLS.map((c) => [c.id, []]));
3372
+ for (const t of tasks) {
3373
+ if (byStatus[t.status]) byStatus[t.status].push(t);
3374
+ else byStatus.backlog.push(t);
3375
+ }
3376
+ // Done: keep last 12
3377
+ byStatus.done = byStatus.done.slice(0, 12);
3378
+
3379
+ main.innerHTML = `
3380
+ <section class="dash-page">
3381
+ <header class="dash-head">
3382
+ <div>
3383
+ <h1>Tasks</h1>
3384
+ <p class="sub">Deferred work for <strong>${esc(focus)}</strong> — agents park items here so they do not die in chat. Facts still live in Memory.</p>
3385
+ </div>
3386
+ <div class="dash-actions">
3387
+ <button class="btn secondary small" type="button" id="tasksScope">${
3388
+ state.brainAll ? "Show this folder only" : "Show all memory"
3389
+ }</button>
3390
+ <button class="btn secondary small" type="button" data-go="brain">Open Memory</button>
3391
+ <button class="btn secondary small" type="button" data-go="dashboard">Dashboard</button>
3392
+ </div>
3393
+ </header>
3394
+ <p class="note">${openCount} open · use MCP <code>amem_task_add</code> / <code>amem_task_update</code> or the form below${
3395
+ state.brainAll ? ` · new tasks land in <strong>${esc(currentRepoLabel())}</strong>` : ""
3396
+ }</p>
3397
+ <form class="tasks-add" id="tasksAddForm">
3398
+ <input id="tasksTitle" type="text" placeholder="New task title" maxlength="200" required autocomplete="off"/>
3399
+ <input id="tasksBody" type="text" placeholder="Why / notes (optional)" maxlength="400" autocomplete="off"/>
3400
+ <button class="btn" type="submit">Add to backlog</button>
3401
+ </form>
3402
+ <div class="tasks-board" id="tasksBoard">
3403
+ ${TASK_COLS.map((col) => {
3404
+ const list = byStatus[col.id] || [];
3405
+ return `
3406
+ <section class="tasks-col" data-status="${col.id}">
3407
+ <header><h2>${esc(col.label)}</h2><span>${list.length}</span></header>
3408
+ <div class="tasks-col-body">${list.map(taskCardHtml).join("") || `<p class="tasks-empty">Empty</p>`}</div>
3409
+ </section>`;
3410
+ }).join("")}
3411
+ </div>
3412
+ </section>`;
3413
+
3414
+ main.querySelectorAll("[data-go]").forEach((b) => {
3415
+ b.addEventListener("click", () => {
3416
+ const tab = b.getAttribute("data-go");
3417
+ if (tab) setTab(tab);
3418
+ });
3419
+ });
3420
+
3421
+ $("#tasksScope")?.addEventListener("click", async () => {
3422
+ state.brainAll = !state.brainAll;
3423
+ persistBrainAll(state.brainAll);
3424
+ writeUrlState();
3425
+ try {
3426
+ await refreshTasks();
3427
+ } catch (err) {
3428
+ alert(err.message || err);
3429
+ }
3430
+ renderTasks();
3431
+ });
3432
+
3433
+ $("#tasksAddForm")?.addEventListener("submit", async (e) => {
3434
+ e.preventDefault();
3435
+ const title = $("#tasksTitle")?.value?.trim();
3436
+ if (!title) return;
3437
+ const body = $("#tasksBody")?.value?.trim() || "";
3438
+ try {
3439
+ const bound = state.status?.repo || matchBoundRepo();
3440
+ const repoId = bound?.id || state.repoId || undefined;
3441
+ await api("/api/tasks", {
3442
+ method: "POST",
3443
+ repoId,
3444
+ body: JSON.stringify({ title, body, status: "backlog", source: "ui" }),
3445
+ });
3446
+ const titleInput = $("#tasksTitle");
3447
+ const bodyInput = $("#tasksBody");
3448
+ if (titleInput) titleInput.value = "";
3449
+ if (bodyInput) bodyInput.value = "";
3450
+ await refreshTasks();
3451
+ renderTasks();
3452
+ } catch (err) {
3453
+ alert(err.message || err);
3454
+ }
3455
+ });
3456
+
3457
+ main.querySelectorAll(".tasks-card").forEach((card) => {
3458
+ const id = card.getAttribute("data-id");
3459
+ card.querySelector(".tasks-status")?.addEventListener("change", async (ev) => {
3460
+ const status = ev.target.value;
3461
+ try {
3462
+ const url = state.brainAll ? "/api/tasks?scope=all" : "/api/tasks";
3463
+ await (state.brainAll ? apiUnscoped : api)(url, {
3464
+ method: "PATCH",
3465
+ body: JSON.stringify({ id, status, scope: state.brainAll ? "all" : undefined }),
3466
+ });
3467
+ await refreshTasks();
3468
+ renderTasks();
3469
+ } catch (err) {
3470
+ alert(err.message || err);
3471
+ }
3472
+ });
3473
+ card.querySelector(".tasks-del")?.addEventListener("click", async () => {
3474
+ if (!confirm("Delete this task?")) return;
3475
+ try {
3476
+ const url = state.brainAll
3477
+ ? `/api/tasks?scope=all&id=${encodeURIComponent(id)}`
3478
+ : `/api/tasks?id=${encodeURIComponent(id)}`;
3479
+ await (state.brainAll ? apiUnscoped : api)(url, { method: "DELETE" });
3480
+ await refreshTasks();
3481
+ renderTasks();
3482
+ } catch (err) {
3483
+ alert(err.message || err);
3484
+ }
3485
+ });
3486
+ card.addEventListener("dragstart", (ev) => {
3487
+ ev.dataTransfer.setData("text/task-id", id);
3488
+ ev.dataTransfer.effectAllowed = "move";
3489
+ card.classList.add("dragging");
3490
+ });
3491
+ card.addEventListener("dragend", () => card.classList.remove("dragging"));
3492
+ });
3493
+
3494
+ main.querySelectorAll(".tasks-col").forEach((col) => {
3495
+ const status = col.getAttribute("data-status");
3496
+ col.addEventListener("dragover", (ev) => {
3497
+ ev.preventDefault();
3498
+ col.classList.add("drag-over");
3499
+ });
3500
+ col.addEventListener("dragleave", () => col.classList.remove("drag-over"));
3501
+ col.addEventListener("drop", async (ev) => {
3502
+ ev.preventDefault();
3503
+ col.classList.remove("drag-over");
3504
+ const id = ev.dataTransfer.getData("text/task-id");
3505
+ if (!id || !status) return;
3506
+ try {
3507
+ const url = state.brainAll ? "/api/tasks?scope=all" : "/api/tasks";
3508
+ await (state.brainAll ? apiUnscoped : api)(url, {
3509
+ method: "PATCH",
3510
+ body: JSON.stringify({ id, status, scope: state.brainAll ? "all" : undefined }),
3511
+ });
3512
+ await refreshTasks();
3513
+ renderTasks();
3514
+ } catch (err) {
3515
+ alert(err.message || err);
3516
+ }
3517
+ });
3518
+ });
3519
+ }
3520
+
3325
3521
  function renderDashboard() {
3326
3522
  const main = $("#main");
3327
3523
  setMainMode("page");
@@ -3355,6 +3551,7 @@ function renderDashboard() {
3355
3551
  </div>
3356
3552
  <div class="dash-actions">
3357
3553
  <button class="btn secondary small" type="button" data-go="brain">Open Memory</button>
3554
+ <button class="btn secondary small" type="button" data-go="tasks">Open Tasks</button>
3358
3555
  <button class="btn secondary small" type="button" data-go="analytics">Open Analytics</button>
3359
3556
  </div>
3360
3557
  </header>
@@ -3376,7 +3573,7 @@ function renderDashboard() {
3376
3573
  <b>${esc(String(tier))}</b><span>License</span>
3377
3574
  </div>
3378
3575
  <div class="dash-kpi static">
3379
- <b>${formatUsd(totals.estimatedUsdSaved)}</b><span>Est. $ saved (proxy)</span>
3576
+ <b>${formatUsd(totals.estimatedUsdSaved)}</b><span>Est. $ saved · ${esc(savingsCaveat(agg))}</span>
3380
3577
  </div>
3381
3578
  </div>
3382
3579
 
@@ -3547,7 +3744,7 @@ function renderAnalytics() {
3547
3744
  const speedCards = $("#speedCards");
3548
3745
  speedCards.innerHTML = `
3549
3746
  <div class="platform-card"><div class="label">Estimated time saved</div><div class="value">~${formatDuration(totals.estimatedMsSaved)}</div><div class="meta">proxy vs tool round-trips · not model latency</div></div>
3550
- <div class="platform-card"><div class="label">Estimated money saved</div><div class="value">${formatUsd(totals.estimatedUsdSaved)}</div><div class="meta">at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens · proxy, not a bill</div></div>
3747
+ <div class="platform-card"><div class="label">Estimated money saved</div><div class="value">${formatUsd(totals.estimatedUsdSaved)}</div><div class="meta">at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens · ${esc(savingsCaveat(agg))}</div></div>
3551
3748
  <div class="platform-card"><div class="label">Local lookup</div><div class="value">${totals.avgLocalMs != null ? formatDuration(totals.avgLocalMs) : "—"}</div><div class="meta">measured SQLite / localhost avg</div></div>
3552
3749
  <div class="platform-card"><div class="label">Hit rate</div><div class="value">${formatPct(totals.hitRate)}</div><div class="meta">${totals.localHits ?? 0} keyword hits · ${totals.serverTrips ?? 0} misses · not model API calls</div></div>
3553
3750
  <div class="platform-card"><div class="label">Avoided file reads</div><div class="value">${formatTokens(totals.anchorsAvoided ?? 0)}</div><div class="meta">unique anchors returned in packets</div></div>`;
@@ -3557,7 +3754,7 @@ function renderAnalytics() {
3557
3754
  const trend = monthly.trendDays || 0;
3558
3755
  monthlyCards.innerHTML = `
3559
3756
  <div class="platform-card accented"><div class="label">Est. tokens / month</div><div class="value">~${formatTokens(monthly.estimatedTokensSaved ?? 0)}</div><div class="meta">${trend ? `from ${monthly.sampleQueries} calls over ${trend} day${trend === 1 ? "" : "s"} × 30` : "no usage yet"} · proxy, not a bill</div></div>
3560
- <div class="platform-card accented"><div class="label">Est. $ / month</div><div class="value">${formatUsd(monthly.estimatedUsdSaved)}</div><div class="meta">same token proxy at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input</div></div>
3757
+ <div class="platform-card accented"><div class="label">Est. $ / month</div><div class="value">${formatUsd(monthly.estimatedUsdSaved)}</div><div class="meta">same token proxy at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input · ${esc(savingsCaveat(agg))}</div></div>
3561
3758
  <div class="platform-card accented"><div class="label">Est. time / month</div><div class="value">~${formatDuration(monthly.estimatedMsSaved)}</div><div class="meta">avoided tool round-trips at current pace</div></div>
3562
3759
  <div class="platform-card accented"><div class="label">Est. calls / month</div><div class="value">${formatTokens(monthly.queries ?? 0)}</div><div class="meta">amem context hits if this rate holds</div></div>
3563
3760
  <div class="platform-card accented"><div class="label">Est. file reads / month</div><div class="value">${formatTokens(monthly.anchorsAvoided ?? 0)}</div><div class="meta">anchors that would be skipped</div></div>`;
@@ -3806,6 +4003,34 @@ async function render() {
3806
4003
  ]);
3807
4004
  if (stale()) return;
3808
4005
  renderDashboard();
4006
+ } else if (state.tab === "tasks") {
4007
+ stopAllViz();
4008
+ try {
4009
+ await refreshTasks();
4010
+ } catch (e) {
4011
+ if (!stale()) {
4012
+ setMainMode("page");
4013
+ $("#main").innerHTML = `<section class="hero"><div class="hero-inner"><h1>Tasks</h1><p>${esc(e.message || e)}</p></div></section>`;
4014
+ renderPageInsight();
4015
+ }
4016
+ return;
4017
+ }
4018
+ if (stale()) return;
4019
+ renderTasks();
4020
+ } else if (state.tab === "skills") {
4021
+ stopAllViz();
4022
+ try {
4023
+ await refreshSkills();
4024
+ } catch (e) {
4025
+ if (!stale()) {
4026
+ setMainMode("page");
4027
+ $("#main").innerHTML = `<section class="hero"><div class="hero-inner"><h1>Skills</h1><p>${esc(e.message || e)}</p></div></section>`;
4028
+ renderPageInsight();
4029
+ }
4030
+ return;
4031
+ }
4032
+ if (stale()) return;
4033
+ renderSkills();
3809
4034
  } else {
3810
4035
  stopAllViz();
3811
4036
  if (state.tab === "stats") state.tab = "analytics";