@iamem/amem 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +64 -1
  2. package/dist/api/routes.js +337 -3
  3. package/dist/attest.d.ts +13 -0
  4. package/dist/attest.js +44 -0
  5. package/dist/capture.js +14 -5
  6. package/dist/cli.js +291 -6
  7. package/dist/context.d.ts +10 -1
  8. package/dist/context.js +105 -3
  9. package/dist/db.d.ts +141 -0
  10. package/dist/db.js +398 -0
  11. package/dist/embed.js +5 -14
  12. package/dist/estimate.d.ts +25 -1
  13. package/dist/estimate.js +36 -3
  14. package/dist/freshness.d.ts +7 -0
  15. package/dist/freshness.js +8 -1
  16. package/dist/hook.js +8 -1
  17. package/dist/hygiene.d.ts +26 -2
  18. package/dist/hygiene.js +42 -3
  19. package/dist/install/hosts.d.ts +15 -0
  20. package/dist/install/hosts.js +82 -4
  21. package/dist/install/skills.js +10 -5
  22. package/dist/kinds.d.ts +18 -0
  23. package/dist/kinds.js +80 -3
  24. package/dist/license.d.ts +1 -0
  25. package/dist/license.js +21 -19
  26. package/dist/mcp.js +221 -0
  27. package/dist/platforms.js +6 -0
  28. package/dist/policy.d.ts +6 -0
  29. package/dist/policy.js +16 -1
  30. package/dist/remember-contract.js +13 -4
  31. package/dist/repo-identity.d.ts +10 -0
  32. package/dist/repo-identity.js +20 -1
  33. package/dist/skill-capture.d.ts +43 -0
  34. package/dist/skill-capture.js +146 -0
  35. package/dist/skills.d.ts +106 -0
  36. package/dist/skills.js +422 -0
  37. package/docs/backlog.md +9 -0
  38. package/package.json +2 -1
  39. package/scripts/mcp-launch.sh +26 -0
  40. package/skills/amem-tasks/SKILL.md +100 -0
  41. package/skills/amem-write-skill/SKILL.md +99 -0
  42. package/templates/cursor-rule.mdc +16 -6
  43. package/templates/policy.deny-default.toml +5 -0
  44. package/templates/policy.example.toml +8 -0
  45. package/ui-static/app.js +458 -233
  46. package/ui-static/index.html +11 -34
  47. package/ui-static/styles.css +310 -1
package/dist/estimate.js CHANGED
@@ -3,11 +3,42 @@ export function estimateTokensFromText(text) {
3
3
  return Math.max(1, Math.ceil(text.length / 4));
4
4
  }
5
5
  /**
6
- * Proxy for exploration avoided:
6
+ * Assumed cost of one file the agent did not have to open. This is a MODELLED
7
+ * constant, not a measurement: it credits a saving whether or not the agent
8
+ * would actually have read that file. It dominates the headline number, so
9
+ * treat any total built from it as an upper bound until calibrated against
10
+ * real reported savings (`amem usage report --saved <n>`).
11
+ */
12
+ export const ASSUMED_TOKENS_PER_FILE = 4000;
13
+ export const ASSUMED_TOKENS_PER_CLAIM = 200;
14
+ /**
15
+ * Proxy for exploration avoided, net of what the packet itself cost:
7
16
  * anchors_returned * 4000 + claims_returned * 200 - packet_tokens
17
+ *
18
+ * Deliberately NOT clamped at zero. A packet that returns little and still
19
+ * costs input tokens is a net loss, and the metric has to be able to say so —
20
+ * clamping made the dashboard structurally incapable of reporting that amem
21
+ * ever cost anything, which is not a property you want in your own numbers.
8
22
  */
9
23
  export function estimateTokensSaved(input) {
10
- return Math.max(0, input.anchorsCount * 4000 + input.claimsCount * 200 - input.packetTokens);
24
+ return (input.anchorsCount * ASSUMED_TOKENS_PER_FILE +
25
+ input.claimsCount * ASSUMED_TOKENS_PER_CLAIM -
26
+ input.packetTokens);
27
+ }
28
+ /**
29
+ * How a savings figure should be presented. "measured" only once real reported
30
+ * savings exist; until then the number is a model and must be labelled as one.
31
+ */
32
+ export function savingsBasis(reportedTokensSaved) {
33
+ const calibrated = Number(reportedTokensSaved) > 0;
34
+ return {
35
+ // Distinct from pricing.basis ("input"), which is about which side of the
36
+ // token bill is being priced, not about how trustworthy the figure is.
37
+ savingsBasis: calibrated ? "measured" : "modelled",
38
+ calibrated,
39
+ assumedTokensPerFile: ASSUMED_TOKENS_PER_FILE,
40
+ assumedTokensPerClaim: ASSUMED_TOKENS_PER_CLAIM,
41
+ };
11
42
  }
12
43
  /** Typical Cursor/Claude tool round-trip to read a file (~1.2s) plus a little per claim. */
13
44
  export const MS_PER_FILE_ROUNDTRIP = 1200;
@@ -18,7 +49,9 @@ export function estimateMsSaved(input) {
18
49
  /** Mid-range frontier *input* $/1M tokens (Sonnet-class). Avoided exploration is input-side. Not a bill. */
19
50
  export const USD_PER_MILLION_INPUT_TOKENS = 3;
20
51
  export function estimateUsdSaved(tokens, usdPerMillion = USD_PER_MILLION_INPUT_TOKENS) {
21
- return Math.max(0, (Number(tokens) / 1_000_000) * usdPerMillion);
52
+ // Unclamped for the same reason as estimateTokensSaved: a negative here is
53
+ // real information, not an error to be hidden.
54
+ return (Number(tokens) / 1_000_000) * usdPerMillion;
22
55
  }
23
56
  export function eventKind(claimsCount, notesCount = 0) {
24
57
  return claimsCount > 0 || notesCount > 0 ? "local_hit" : "server_trip";
@@ -5,6 +5,13 @@ export type ClaimFreshness = {
5
5
  staleAnchors: string[];
6
6
  missingAnchors: string[];
7
7
  };
8
+ /**
9
+ * capture.ts falls back to this when it cannot extract a real file anchor.
10
+ * README churns constantly, so honouring it as a real anchor marks those claims
11
+ * stale forever and drowns the genuine staleness signal. Treat it as no anchor.
12
+ * (capture.ts already filters it out of "does this claim have real anchors".)
13
+ */
14
+ export declare const PLACEHOLDER_ANCHOR = "README.md";
8
15
  export declare function parseAnchors(codeAnchorsJson: string): string[];
9
16
  /**
10
17
  * Compare claim.updated_at to filesystem mtimes of code_anchors.
package/dist/freshness.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
+ /**
4
+ * capture.ts falls back to this when it cannot extract a real file anchor.
5
+ * README churns constantly, so honouring it as a real anchor marks those claims
6
+ * stale forever and drowns the genuine staleness signal. Treat it as no anchor.
7
+ * (capture.ts already filters it out of "does this claim have real anchors".)
8
+ */
9
+ export const PLACEHOLDER_ANCHOR = "README.md";
3
10
  export function parseAnchors(codeAnchorsJson) {
4
11
  try {
5
12
  const parsed = JSON.parse(codeAnchorsJson);
@@ -21,7 +28,7 @@ function resolveAnchorPath(rootPath, anchor) {
21
28
  * Missing paths → missing_anchor; any newer file → stale.
22
29
  */
23
30
  export function assessClaimFreshness(rootPath, claim) {
24
- const anchors = parseAnchors(claim.code_anchors);
31
+ const anchors = parseAnchors(claim.code_anchors).filter((a) => a !== PLACEHOLDER_ANCHOR);
25
32
  if (anchors.length === 0) {
26
33
  return { status: "unanchored", staleAnchors: [], missingAnchors: [] };
27
34
  }
package/dist/hook.js CHANGED
@@ -4,6 +4,7 @@ import { logContextUsage } from "./api/routes.js";
4
4
  import { captureMissLearnDraft, captureSessionDraft, findRecentContextMisses, isUsefulCaptureText, } from "./capture.js";
5
5
  import { getRepoByCwd, insertConversationNote, listConversationNotes, upsertRepo, } from "./db.js";
6
6
  import { detectRepoIdentity } from "./repo-identity.js";
7
+ import { captureSkillRevision, captureSkillSuggestion } from "./skill-capture.js";
7
8
  const SECRET = /password|api[_-]?key|secret|token\s*[:=]|begin (rsa |openssh )?private/i;
8
9
  function workspaceRoot(payload) {
9
10
  const roots = payload.workspace_roots;
@@ -63,8 +64,9 @@ function injectPacket(repo, query, session, platform) {
63
64
  sessionId: session,
64
65
  query: query || "(session start)",
65
66
  });
66
- if (packet.claims.length === 0 && packet.notes.length === 0)
67
+ if (packet.claims.length === 0 && packet.notes.length === 0 && (packet.tasks?.length ?? 0) === 0) {
67
68
  return null;
69
+ }
68
70
  return cap(markdown);
69
71
  }
70
72
  export function handleHookPayload(raw) {
@@ -171,6 +173,11 @@ function handleHookPayloadInner(raw) {
171
173
  notes: recent.slice(0, 8).map((n) => ({ role: n.role, text: n.text })),
172
174
  });
173
175
  }
176
+ // Procedural memory: was this session a workflow worth writing up, or evidence that a
177
+ // skill we already followed is wrong? Chronological order matters to the heuristics.
178
+ const ordered = [...recent].reverse().map((n) => ({ role: n.role, text: n.text }));
179
+ captureSkillRevision({ repo, sessionId: sid, notes: ordered }) ??
180
+ captureSkillSuggestion({ repo, sessionId: sid, notes: ordered });
174
181
  return {};
175
182
  }
176
183
  return { continue: true };
package/dist/hygiene.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Local memory hygiene: decay unused facts, find near-duplicates, review inbox.
3
- * Apply/schedule is Pro/IT only. Preview counts are free (soft paywall).
4
- * Nothing is uploaded.
3
+ * Completely free and open runs on-device, nothing uploaded.
5
4
  */
6
5
  import { type ClaimRow } from "./db.js";
7
6
  export type HygieneDuplicate = {
@@ -68,3 +67,28 @@ export declare function runScheduledHygiene(unusedDays?: number): {
68
67
  error?: string;
69
68
  }>;
70
69
  };
70
+ export type NonFactClaim = {
71
+ repoId: string;
72
+ repoName: string;
73
+ id: string;
74
+ kind: string;
75
+ reason: string;
76
+ preview: string;
77
+ };
78
+ /** Stored claims that are clear junk. Pinned claims are never included. */
79
+ export declare function findNonFactClaims(opts?: {
80
+ repoId?: string;
81
+ }): NonFactClaim[];
82
+ /**
83
+ * Delete clear-junk claims. Always run with dryRun first — deletion is not
84
+ * reversible from inside amem, only from a backup.
85
+ */
86
+ export declare function purgeNonFactClaims(opts?: {
87
+ repoId?: string;
88
+ dryRun?: boolean;
89
+ }): {
90
+ scanned: number;
91
+ matched: NonFactClaim[];
92
+ deleted: number;
93
+ dryRun: boolean;
94
+ };
package/dist/hygiene.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Local memory hygiene: decay unused facts, find near-duplicates, review inbox.
3
- * Apply/schedule is Pro/IT only. Preview counts are free (soft paywall).
4
- * Nothing is uploaded.
3
+ * Completely free and open runs on-device, nothing uploaded.
5
4
  */
6
- import { getClaim, listClaims, listProposalDrafts, listRepos, listUsageEvents, setClaimStatus, } from "./db.js";
5
+ import { deleteClaim, getClaim, listClaims, listClaimsAll, listProposalDrafts, listRepos, listUsageEvents, setClaimStatus, } from "./db.js";
7
6
  import { FEATURE_HYGIENE, hasFeature, requireFeature } from "./license.js";
8
7
  import { applyProposal, applySupersedes } from "./proposal.js";
9
8
  import { tokenJaccard } from "./search.js";
10
9
  import { parseAnchors } from "./freshness.js";
10
+ import { nonFactReason } from "./kinds.js";
11
11
  export const SOFT_PAYWALL_FACTS = 200;
12
12
  export const SOFT_PAYWALL_NOISE = 15;
13
13
  /** Soft-paywall when session chat takeaways dominate the graph. */
@@ -252,3 +252,42 @@ export function runScheduledHygiene(unusedDays = 90) {
252
252
  }
253
253
  return { repos };
254
254
  }
255
+ /** Stored claims that are clear junk. Pinned claims are never included. */
256
+ export function findNonFactClaims(opts = {}) {
257
+ const names = new Map(listRepos().map((r) => [r.id, r.repo_name]));
258
+ const claims = opts.repoId ? listClaims(opts.repoId) : listClaimsAll();
259
+ const out = [];
260
+ for (const claim of claims) {
261
+ if (claim.pinned)
262
+ continue;
263
+ const reason = nonFactReason(claim.text);
264
+ if (!reason)
265
+ continue;
266
+ out.push({
267
+ repoId: claim.repo_id,
268
+ repoName: names.get(claim.repo_id) ?? claim.repo_id,
269
+ id: claim.id,
270
+ kind: claim.kind,
271
+ reason,
272
+ preview: claim.text.replace(/\s+/g, " ").slice(0, 120),
273
+ });
274
+ }
275
+ return out;
276
+ }
277
+ /**
278
+ * Delete clear-junk claims. Always run with dryRun first — deletion is not
279
+ * reversible from inside amem, only from a backup.
280
+ */
281
+ export function purgeNonFactClaims(opts = {}) {
282
+ const dryRun = opts.dryRun !== false;
283
+ const scanned = (opts.repoId ? listClaims(opts.repoId) : listClaimsAll()).length;
284
+ const matched = findNonFactClaims({ repoId: opts.repoId });
285
+ let deleted = 0;
286
+ if (!dryRun) {
287
+ for (const row of matched) {
288
+ if (deleteClaim(row.repoId, row.id))
289
+ deleted += 1;
290
+ }
291
+ }
292
+ return { scanned, matched, deleted, dryRun };
293
+ }
@@ -11,6 +11,21 @@ export declare function installContinue(workspace?: string): HostInstallResult;
11
11
  export declare function installAider(repoRoot: string): HostInstallResult;
12
12
  /** Zed: settings.json context_servers / mcp style. */
13
13
  export declare function installZed(workspace?: string): HostInstallResult;
14
+ /** Absolute path to the POSIX launcher shipped with the package. */
15
+ export declare function mcpLauncherPath(): string;
16
+ /** Claude Desktop stores MCP config here (also read by Cowork). */
17
+ export declare function claudeDesktopConfigPath(): string;
18
+ /**
19
+ * Claude Desktop / Cowork: stdio MCP entry in claude_desktop_config.json.
20
+ *
21
+ * GUI apps are not launched from a login shell, so they inherit a minimal PATH
22
+ * with no Homebrew and no nvm. A bare `amem` — or even `node` — registers as a
23
+ * connector but never completes tool discovery, and the host reports no reason.
24
+ * Point at the launcher, which re-resolves node at spawn time; fall back to an
25
+ * absolute node + cli.js pair where a shell script cannot run (Windows).
26
+ */
27
+ export declare function installClaudeDesktop(workspace?: string): HostInstallResult;
28
+ export declare function claudeDesktopInstallHealth(): string[];
14
29
  export declare function continueInstallHealth(): string[];
15
30
  export declare function zedInstallHealth(): string[];
16
31
  export declare function windsurfInstallHealth(): string[];
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { mcpClientConfig } from "../mcp.js";
5
- import { resolveAmemBin, writeJson, readJsonObject } from "./skills.js";
3
+ import { dirname, join } from "node:path";
4
+ import { mcpClientConfig, stdioMcpLaunch } from "../mcp.js";
5
+ import { getPackageRoot, resolveAmemBin, writeJson, readJsonObject } from "./skills.js";
6
6
  function ensureDir(path) {
7
7
  mkdirSync(path, { recursive: true });
8
8
  }
@@ -74,6 +74,14 @@ Before large exploration, run:
74
74
  amem context "\${user question}"
75
75
  \`\`\`
76
76
 
77
+ Deferred tasks & Kanban:
78
+
79
+ \`\`\`bash
80
+ amem task list # list pending tasks
81
+ amem task add "..." --body "..." # add task to backlog
82
+ amem task complete <id> # mark finished task as done
83
+ \`\`\`
84
+
77
85
  After durable discoveries:
78
86
 
79
87
  \`\`\`bash
@@ -122,6 +130,72 @@ export function installZed(workspace = "personal") {
122
130
  ],
123
131
  };
124
132
  }
133
+ /** Absolute path to the POSIX launcher shipped with the package. */
134
+ export function mcpLauncherPath() {
135
+ return join(getPackageRoot(), "scripts", "mcp-launch.sh");
136
+ }
137
+ /** Claude Desktop stores MCP config here (also read by Cowork). */
138
+ export function claudeDesktopConfigPath() {
139
+ if (process.platform === "darwin") {
140
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
141
+ }
142
+ if (process.platform === "win32") {
143
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
144
+ return join(appData, "Claude", "claude_desktop_config.json");
145
+ }
146
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
147
+ }
148
+ /**
149
+ * Claude Desktop / Cowork: stdio MCP entry in claude_desktop_config.json.
150
+ *
151
+ * GUI apps are not launched from a login shell, so they inherit a minimal PATH
152
+ * with no Homebrew and no nvm. A bare `amem` — or even `node` — registers as a
153
+ * connector but never completes tool discovery, and the host reports no reason.
154
+ * Point at the launcher, which re-resolves node at spawn time; fall back to an
155
+ * absolute node + cli.js pair where a shell script cannot run (Windows).
156
+ */
157
+ export function installClaudeDesktop(workspace = "personal") {
158
+ const path = claudeDesktopConfigPath();
159
+ ensureDir(dirname(path));
160
+ const existing = readJsonObject(path);
161
+ const servers = existing.mcpServers ?? {};
162
+ const launcher = mcpLauncherPath();
163
+ const usable = process.platform !== "win32" && existsSync(launcher);
164
+ if (usable) {
165
+ try {
166
+ chmodSync(launcher, 0o755);
167
+ }
168
+ catch {
169
+ // npm preserves the exec bit; a read-only install is not fatal here
170
+ }
171
+ }
172
+ const stdio = stdioMcpLaunch(workspace);
173
+ servers.amem = usable
174
+ ? { command: launcher, args: [], env: { AMEM_WORKSPACE: workspace } }
175
+ : {
176
+ command: stdio.command,
177
+ args: stdio.args,
178
+ env: { ...stdio.env, AMEM_WORKSPACE: workspace },
179
+ };
180
+ writeJson(path, { ...existing, mcpServers: servers });
181
+ const shown = usable ? launcher : `${stdio.command} ${stdio.args.join(" ")}`;
182
+ return {
183
+ host: "claude-desktop",
184
+ paths: [path],
185
+ notes: [
186
+ `Claude Desktop MCP amem \u2192 ${shown} (workspace ${workspace}).`,
187
+ "Quit Claude Desktop fully (Cmd+Q) and relaunch \u2014 config is read at startup, closing the window is not enough.",
188
+ "stdio needs no daemon: amem ui does not have to be running.",
189
+ ],
190
+ };
191
+ }
192
+ export function claudeDesktopInstallHealth() {
193
+ const path = claudeDesktopConfigPath();
194
+ if (!fileMentionsAmem(path)) {
195
+ return ["Claude Desktop has no amem MCP entry \u2014 run amem init --platform claude-desktop"];
196
+ }
197
+ return [];
198
+ }
125
199
  function fileMentionsAmem(path) {
126
200
  if (!existsSync(path))
127
201
  return false;
@@ -159,6 +233,8 @@ export function windsurfInstallHealth() {
159
233
  }
160
234
  export function hostInstallHealth(host) {
161
235
  switch (host) {
236
+ case "claude-desktop":
237
+ return claudeDesktopInstallHealth();
162
238
  case "continue":
163
239
  return continueInstallHealth();
164
240
  case "zed":
@@ -172,6 +248,8 @@ export function hostInstallHealth(host) {
172
248
  export function installHost(host, opts = {}) {
173
249
  const ws = opts.workspace || "personal";
174
250
  switch (host) {
251
+ case "claude-desktop":
252
+ return installClaudeDesktop(ws);
175
253
  case "windsurf":
176
254
  return installWindsurf(ws);
177
255
  case "continue":
@@ -17,14 +17,19 @@ export function copyBundledSkills(targetSkillsDir) {
17
17
  mkdirSync(targetSkillsDir, { recursive: true });
18
18
  const source = skillsSourceDir();
19
19
  const installed = [];
20
- for (const name of ["amem-bootstrap", "amem-update-working-memory"]) {
20
+ const skillNames = [
21
+ "amem-bootstrap",
22
+ "amem-update-working-memory",
23
+ "amem-tasks",
24
+ "amem-write-skill",
25
+ ];
26
+ for (const name of skillNames) {
21
27
  const from = join(source, name);
22
28
  const to = join(targetSkillsDir, name);
23
- if (!existsSync(from)) {
24
- throw new Error(`Missing bundled skill: ${from}`);
29
+ if (existsSync(from)) {
30
+ cpSync(from, to, { recursive: true });
31
+ installed.push(to);
25
32
  }
26
- cpSync(from, to, { recursive: true });
27
- installed.push(to);
28
33
  }
29
34
  return installed;
30
35
  }
package/dist/kinds.d.ts CHANGED
@@ -15,4 +15,22 @@ export declare function compactFromNotes(notes: Array<{
15
15
  prompt: string;
16
16
  answer: string;
17
17
  } | null;
18
+ /**
19
+ * Is this a durable statement about the code, or conversational residue?
20
+ *
21
+ * Auto-capture previously accepted any sentence >= 48 chars that mentioned a
22
+ * path, which stored the user's own questions as facts ("so use luna mcp to
23
+ * connect to amem and that will do the trick?"). A question is a request, not
24
+ * a fact, however many files it names.
25
+ */
26
+ /**
27
+ * Why an ALREADY STORED claim is clear junk, or null to keep it.
28
+ *
29
+ * Deliberately narrower than isFactLike: that gate decides what to admit and
30
+ * can afford false negatives, this one decides what to DELETE and a false
31
+ * positive destroys memory. Short, lowercase or oddly-formatted claims are
32
+ * kept here even though capture would now reject them.
33
+ */
34
+ export declare function nonFactReason(text: string): string | null;
35
+ export declare function isFactLike(text: string): boolean;
18
36
  export declare function isDurableCapture(prompt: string, answer: string | undefined, anchorCount: number): boolean;
package/dist/kinds.js CHANGED
@@ -47,8 +47,14 @@ export function compactClaimText(prompt, answer) {
47
47
  const head = sentences.slice(0, 2).join(" ").slice(0, 400);
48
48
  return head || takeaway.slice(0, 400);
49
49
  }
50
- const q = prompt.replace(/\s+/g, " ").trim().slice(0, 280);
51
- return takeaway ? `${q}\n\nPrior outcome: ${takeaway.slice(0, 200)}` : q.slice(0, 400);
50
+ // Short answer: the fact is still in the ANSWER, so lead with it and keep the
51
+ // prompt only as trailing context. Leading with the prompt is how questions
52
+ // ended up stored as durable claims; the trailing "?" is dropped so the
53
+ // result reads as a statement rather than tripping the question filter.
54
+ const q = prompt.replace(/\s+/g, " ").trim().replace(/\?+\s*$/, "").slice(0, 280);
55
+ if (!takeaway)
56
+ return q.slice(0, 400);
57
+ return `${takeaway.slice(0, 200)}${q ? ` (context: ${q})` : ""}`.slice(0, 400);
52
58
  }
53
59
  /** Multi-turn: fold several notes into one compact fact string. */
54
60
  export function compactFromNotes(notes) {
@@ -72,10 +78,81 @@ function isUsefulish(text) {
72
78
  function scrubCodeNoise(text) {
73
79
  return text
74
80
  .replace(/```[\s\S]*?```/g, " ")
75
- .replace(/`[^`]+`/g, " ")
81
+ // Keep what is INSIDE an inline span. Deleting it removed the file paths
82
+ // and identifiers that make a claim worth storing ("lives in `src/x.ts`"
83
+ // became "lives in and"), and it ran before pickFactSentences, which
84
+ // scores sentences on exactly those paths.
85
+ .replace(/`([^`]+)`/g, "$1")
76
86
  .replace(/\s+/g, " ")
77
87
  .trim();
78
88
  }
89
+ const QUESTION_LEAD = /^(can|could|would|should|is|are|was|were|do|does|did|how|what|why|when|where|who|which|will|shall|may|might|am|have|has|had)\b/i;
90
+ const CHAT_LEAD = /^(so|ok|okay|and|but|also|im|i'm|let's|lets|please|thanks|thx|yeah|yep|nope|hmm|wait|actually|btw|oh|hey)\b/i;
91
+ /** Residue of an earlier scrub: "lives in and is", "wired via and included". */
92
+ const DANGLING = /\b(in|at|from|to|via|inside|under|with|into)\s+(and|is|was|the file|what)\b/i;
93
+ /** Interrogative opener plus a subject — a question missing its "?". */
94
+ const QUESTION_SHAPE = /^(can|could|should|would|do|does|did|is|are|will|shall|how|what|why|when|where|who|which)\s+(?:(?:do|does|did|can|could|should|would|is|are|will|shall)\s+)?(we|you|i|amem)\b/i;
95
+ /** Preposition running into punctuation or end of string: the object was deleted. */
96
+ const TRAILING_PREPOSITION = /\b(in|at|from|to|via|inside|under|with|into|through)\s*([.,;:]|$)/i;
97
+ /** A leading token that looks like code (path, dotted or snake_case name). */
98
+ const CODE_LEAD = /^[\w@./-]*[._/][\w@./-]*(\s|$)/;
99
+ /**
100
+ * Is this a durable statement about the code, or conversational residue?
101
+ *
102
+ * Auto-capture previously accepted any sentence >= 48 chars that mentioned a
103
+ * path, which stored the user's own questions as facts ("so use luna mcp to
104
+ * connect to amem and that will do the trick?"). A question is a request, not
105
+ * a fact, however many files it names.
106
+ */
107
+ /**
108
+ * Why an ALREADY STORED claim is clear junk, or null to keep it.
109
+ *
110
+ * Deliberately narrower than isFactLike: that gate decides what to admit and
111
+ * can afford false negatives, this one decides what to DELETE and a false
112
+ * positive destroys memory. Short, lowercase or oddly-formatted claims are
113
+ * kept here even though capture would now reject them.
114
+ */
115
+ export function nonFactReason(text) {
116
+ const t = (text ?? "").replace(/\s+/g, " ").trim();
117
+ if (t.length === 0)
118
+ return "empty";
119
+ // A question is a request someone typed, not a fact about the code.
120
+ if (t.includes("?"))
121
+ return "question";
122
+ // ...and plenty were typed without the question mark ("can we expose amem
123
+ // data via mcp"). Require an interrogative opener FOLLOWED by a subject, so
124
+ // this cannot swallow a declarative sentence that merely starts with "Is".
125
+ if (QUESTION_SHAPE.test(t))
126
+ return "question";
127
+ // Left behind by the old scrub that deleted inline code spans.
128
+ if (DANGLING.test(t))
129
+ return "scrub-residue";
130
+ // Same damage, different shape: the span sat at the end of the clause, so a
131
+ // preposition now runs straight into punctuation ("memory lives under .").
132
+ if (TRAILING_PREPOSITION.test(t))
133
+ return "scrub-residue";
134
+ if (CHAT_LEAD.test(t))
135
+ return "chat-fragment";
136
+ return null;
137
+ }
138
+ export function isFactLike(text) {
139
+ const t = (text ?? "").replace(/\s+/g, " ").trim();
140
+ if (t.length < 40)
141
+ return false;
142
+ if (t.includes("?"))
143
+ return false;
144
+ if (QUESTION_LEAD.test(t))
145
+ return false;
146
+ if (CHAT_LEAD.test(t))
147
+ return false;
148
+ if (DANGLING.test(t))
149
+ return false;
150
+ // Mid-thought lowercase openings are transcript fragments — unless the
151
+ // sentence opens on an identifier, which is normal for a real fact.
152
+ if (/^[a-z]/.test(t) && !CODE_LEAD.test(t))
153
+ return false;
154
+ return true;
155
+ }
79
156
  function pickFactSentences(text) {
80
157
  const sentences = text.split(/(?<=[.!?])\s+/).filter((s) => s.length > 25);
81
158
  const scored = sentences.map((s) => {
package/dist/license.d.ts CHANGED
@@ -27,6 +27,7 @@ export declare const FEATURE_LOCAL_EMBED = "local_embed_model";
27
27
  export declare const FEATURE_ATTEST_SKU = "attest_sku";
28
28
  export declare const FEATURE_HYGIENE = "hygiene";
29
29
  export declare const FEATURE_RULES_SYNC = "rules_sync";
30
+ export declare const ALL_FEATURES: string[];
30
31
  /** Vendor verify key (SPKI DER hex). Issue signed files with AMEM_LICENSE_PRIVKEY. */
31
32
  export declare const DEFAULT_LICENSE_PUBKEY_HEX = "302a300506032b6570032100b1e01cdb2d1ec60b372bb4307fa48ed743caba09a67633e4689aaa22221080fa";
32
33
  export declare function licensePath(): string;
package/dist/license.js CHANGED
@@ -10,19 +10,25 @@ export const FEATURE_LOCAL_EMBED = "local_embed_model";
10
10
  export const FEATURE_ATTEST_SKU = "attest_sku";
11
11
  export const FEATURE_HYGIENE = "hygiene";
12
12
  export const FEATURE_RULES_SYNC = "rules_sync";
13
+ export const ALL_FEATURES = [
14
+ FEATURE_LOCAL_EMBED,
15
+ FEATURE_HYGIENE,
16
+ FEATURE_RULES_SYNC,
17
+ FEATURE_ATTEST_SKU,
18
+ ];
13
19
  /** Vendor verify key (SPKI DER hex). Issue signed files with AMEM_LICENSE_PRIVKEY. */
14
20
  export const DEFAULT_LICENSE_PUBKEY_HEX = "302a300506032b6570032100b1e01cdb2d1ec60b372bb4307fa48ed743caba09a67633e4689aaa22221080fa";
15
- const BUY_HINT = "Buy at https://getamem.com then: amem license apply --file ~/Downloads/amem-license.json";
21
+ const BUY_HINT = "amem is completely free and open with all features included.";
16
22
  const TIER_FEATURES = {
17
- free: [],
18
- pro: [FEATURE_LOCAL_EMBED, FEATURE_HYGIENE, FEATURE_RULES_SYNC],
19
- it: [FEATURE_LOCAL_EMBED, FEATURE_HYGIENE, FEATURE_RULES_SYNC, FEATURE_ATTEST_SKU],
23
+ free: ALL_FEATURES,
24
+ pro: ALL_FEATURES,
25
+ it: ALL_FEATURES,
20
26
  };
21
27
  export function licensePath() {
22
28
  return join(amemHome(), "license.json");
23
29
  }
24
30
  export function featuresForTier(tier) {
25
- return [...TIER_FEATURES[tier]];
31
+ return [...ALL_FEATURES];
26
32
  }
27
33
  export function generateLicenseKeys() {
28
34
  const pair = generateKeyPairSync("ed25519");
@@ -102,7 +108,7 @@ export function licenseStatus(now = new Date()) {
102
108
  return {
103
109
  tier: "free",
104
110
  kind: "none",
105
- features: [],
111
+ features: [...ALL_FEATURES],
106
112
  valid: true,
107
113
  transferable: false,
108
114
  path,
@@ -112,36 +118,34 @@ export function licenseStatus(now = new Date()) {
112
118
  if (expired(file.payload, now))
113
119
  issues.push("license expired");
114
120
  issues.push(...verifySignedLicense(file));
115
- const tier = issues.length ? "free" : file.payload.tier;
116
- const features = [...new Set([...(file.payload.features ?? []), ...featuresForTier(tier)])];
121
+ const tier = file.payload.tier || "free";
122
+ const features = [...new Set([...(file.payload.features ?? []), ...ALL_FEATURES])];
117
123
  return {
118
124
  tier,
119
125
  kind: "signed",
120
126
  subject: file.payload.subject,
121
127
  expires_at: file.payload.expires_at,
122
128
  features,
123
- valid: issues.length === 0,
129
+ valid: true,
124
130
  transferable: true,
125
131
  path,
126
- issues,
132
+ issues: [],
127
133
  };
128
134
  }
129
135
  catch (error) {
130
- issues.push(error instanceof Error ? error.message : String(error));
131
136
  return {
132
137
  tier: "free",
133
138
  kind: "none",
134
- features: [],
135
- valid: false,
139
+ features: [...ALL_FEATURES],
140
+ valid: true,
136
141
  transferable: false,
137
142
  path,
138
- issues,
143
+ issues: [],
139
144
  };
140
145
  }
141
146
  }
142
147
  export function hasFeature(feature) {
143
- const status = licenseStatus();
144
- return status.valid && status.features.includes(feature);
148
+ return true;
145
149
  }
146
150
  export function writeLicense(file, path = licensePath()) {
147
151
  mkdirSync(amemHome(), { recursive: true, mode: 0o700 });
@@ -166,7 +170,5 @@ export function clearLicense() {
166
170
  unlinkSync(path);
167
171
  }
168
172
  export function requireFeature(feature, label = feature) {
169
- if (hasFeature(feature))
170
- return;
171
- throw new Error(`${label} needs an amem Pro or IT license. ${BUY_HINT}`);
173
+ // All features are included and free by default
172
174
  }