@iamem/amem 0.1.0 → 0.1.2

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 CHANGED
@@ -52,7 +52,8 @@ Guarantees:
52
52
 
53
53
  - `~/.amem` is created with mode `0700`
54
54
  - Local UI binds to `127.0.0.1` only
55
- - No telemetry, no managed sync, no “share with org” mode
55
+ - Memory never leaves the machine — no managed sync, no “share with org” mode
56
+ - Optional anonymous install ping only (see [Telemetry](#telemetry)); opt out anytime
56
57
  - Agents are instructed to store **repo facts**, not proprietary prompting strategy
57
58
  - Optional AES-256-GCM lock and encrypted local backups — still no cloud
58
59
 
@@ -87,6 +88,22 @@ See [docs/npm-release.md](docs/npm-release.md). CI runs `npm test` and `npm run
87
88
 
88
89
  If `npm install` fails compiling native code, install Xcode CLT (macOS) or `build-essential` (Linux) and retry, or use a Node 20/22 official binary that matches the prebuild matrix.
89
90
 
91
+ ### Telemetry
92
+
93
+ On `npm install` / `npx` (outside CI and tests), amem may send a single anonymous POST to `https://getamem.com/api/beacon/npm-install` with:
94
+
95
+ - package name and version
96
+ - Node.js version
97
+ - OS platform and CPU architecture
98
+
99
+ No code, paths, usernames, emails, IPs, or memory contents are included. Opt out:
100
+
101
+ ```bash
102
+ AMEM_TELEMETRY_DISABLED=1 npm i -g @iamem/amem
103
+ ```
104
+
105
+ Memory under `~/.amem` still never leaves your machine.
106
+
90
107
  ### Quick paths
91
108
 
92
109
  ```bash
@@ -142,6 +159,17 @@ amem ui
142
159
 
143
160
  That opens `http://127.0.0.1:7843` on the **Setup** tab. It scans your home folder for git repos (skips `Library`, `node_modules`, `Downloads`, and similar noise). Check the ones you want, pick clients (Cursor, Claude Code, Windsurf, Continue, Aider, Zed, …), then **Start tracking selected**. Each pick is bound in `~/.amem` and gets the matching installer when available.
144
161
 
162
+ Prefer a desktop window instead of a browser tab (same localhost server, same privacy):
163
+
164
+ ```bash
165
+ # once per machine/checkout (downloads Electron — not included in npm i -g)
166
+ npm run app:setup
167
+
168
+ amem app
169
+ ```
170
+
171
+ `amem ui` keeps opening the browser; `amem app` opens Electron. Both talk to `127.0.0.1` only. If the UI server is already running, `amem app` attaches to it. Global installs: run `npm run app:setup` from the package directory (or clone), then `amem app`.
172
+
145
173
  The header has a **Personal** switcher (cross-repo prefs) and **Lock / backup** chrome — lock status, last backup, and a daily local schedule. Memory shows the same lock/backup chips. The Setup tab includes a copyable **remember contract** for any MCP host (`amem recipe`).
146
174
 
147
175
  Optional: check **Start amem ui when this computer logs in** so the localhost server comes back after a reboot:
@@ -388,6 +416,7 @@ amem usage export [--format json|md|pdf] [--days 30] [--scope current|all] [--ou
388
416
  amem license status|apply|activate|clear|issue|keys
389
417
  amem embed status|use hash|use ngram|reindex
390
418
  amem ui [--port 7843] [--no-open]
419
+ amem app [--port 7843]
391
420
  amem service install|uninstall|status
392
421
  ```
393
422
 
@@ -403,7 +432,8 @@ amem service install|uninstall|status
403
432
  | `propose apply` | Upsert structured memory locally |
404
433
  | `lock` / `unlock` | Optional AES-256-GCM encrypt-at-rest for `graph.db` |
405
434
  | `backup` | Local snapshot (optionally encrypted); `schedule` for daily timer |
406
- | `ui` | Setup wizard + Memory + Stats on localhost |
435
+ | `ui` | Setup wizard + Memory + Stats in the browser (localhost) |
436
+ | `app` | Same UI in an Electron window (`npm run app:setup` once) |
407
437
  | `service` | Login item so `amem ui` starts after reboot |
408
438
  | `doctor --attest` | Privacy/policy attestation for IT tickets |
409
439
  | `export` / `wipe` | Personal backup or delete (still local) |
@@ -490,7 +520,8 @@ IT / DevEx can govern the **fleet**: approved install, policy, attestation, offb
490
520
 
491
521
  Hard guarantees (not configurable away):
492
522
 
493
- - No telemetry
523
+ - No memory telemetry or claim upload — `~/.amem` stays local
524
+ - Optional anonymous npm install ping only (opt out: `AMEM_TELEMETRY_DISABLED=1`)
494
525
  - UI binds to loopback only (`127.0.0.1`)
495
526
  - Memory stays under `~/.amem` (mode `0700`)
496
527
 
Binary file
Binary file
Binary file
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Thin Electron shell: loads the existing amem localhost UI.
3
+ * No Node integration in the renderer — API stays on 127.0.0.1.
4
+ *
5
+ * macOS note: BrowserWindow `icon` does not change the Dock for an unpackaged
6
+ * Electron.app. We must call app.dock.setIcon() with a PNG (icns often loads empty).
7
+ */
8
+ const { app, BrowserWindow, nativeImage } = require("electron");
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+
12
+ function uiUrl() {
13
+ const fromEnv = String(process.env.AMEM_UI_URL || "").trim();
14
+ if (fromEnv) return fromEnv;
15
+ const arg = process.argv.find((a) => /^https?:\/\//.test(a));
16
+ return arg || "http://127.0.0.1:7843/";
17
+ }
18
+
19
+ function iconsDir() {
20
+ return path.join(__dirname, "icons");
21
+ }
22
+
23
+ /** Prefer PNG for Dock — nativeImage + .icns is unreliable for unpackaged Electron. */
24
+ function loadAppIcon() {
25
+ const dir = iconsDir();
26
+ const candidates =
27
+ process.platform === "darwin"
28
+ ? ["icon-1024.png", "icon.png", "icon.icns"]
29
+ : process.platform === "win32"
30
+ ? ["icon.ico", "icon.png", "icon-1024.png"]
31
+ : ["icon.png", "icon-1024.png"];
32
+
33
+ for (const name of candidates) {
34
+ const iconPath = path.join(dir, name);
35
+ if (!fs.existsSync(iconPath)) continue;
36
+ let image = nativeImage.createFromPath(iconPath);
37
+ if (image.isEmpty()) continue;
38
+ // Dock looks sharper with a mid-size bitmap.
39
+ const { width } = image.getSize();
40
+ if (width > 512) {
41
+ image = image.resize({ width: 512, height: 512, quality: "best" });
42
+ }
43
+ return image;
44
+ }
45
+ console.warn("[amem-desktop] no usable icon in", dir);
46
+ return null;
47
+ }
48
+
49
+ function applyDockIcon(icon) {
50
+ if (!icon || process.platform !== "darwin" || !app.dock) return;
51
+ try {
52
+ app.dock.setIcon(icon);
53
+ app.dock.show();
54
+ } catch (err) {
55
+ console.warn("[amem-desktop] dock.setIcon failed:", err instanceof Error ? err.message : err);
56
+ }
57
+ }
58
+
59
+ function createWindow(icon) {
60
+ const win = new BrowserWindow({
61
+ width: 1280,
62
+ height: 860,
63
+ minWidth: 880,
64
+ minHeight: 560,
65
+ title: "amem",
66
+ show: false,
67
+ backgroundColor: "#0b0f12",
68
+ ...(icon ? { icon } : {}),
69
+ webPreferences: {
70
+ preload: path.join(__dirname, "preload.js"),
71
+ contextIsolation: true,
72
+ nodeIntegration: false,
73
+ sandbox: true,
74
+ },
75
+ });
76
+
77
+ win.once("ready-to-show", () => {
78
+ applyDockIcon(icon);
79
+ win.show();
80
+ });
81
+ win.loadURL(uiUrl()).catch((err) => {
82
+ console.error("[amem-desktop] failed to load UI:", err instanceof Error ? err.message : err);
83
+ });
84
+ }
85
+
86
+ app.setName("amem");
87
+
88
+ app.whenReady().then(() => {
89
+ const icon = loadAppIcon();
90
+ applyDockIcon(icon);
91
+ createWindow(icon);
92
+ app.on("activate", () => {
93
+ applyDockIcon(icon);
94
+ if (BrowserWindow.getAllWindows().length === 0) createWindow(icon);
95
+ });
96
+ });
97
+
98
+ app.on("window-all-closed", () => {
99
+ app.quit();
100
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "amem-desktop",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "description": "Electron shell for local amem Brain UI (localhost only).",
6
+ "main": "main.js",
7
+ "scripts": {
8
+ "start": "electron ."
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "dependencies": {
14
+ "electron": "^33.2.1"
15
+ }
16
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Preload is intentionally empty for v1 — the UI talks to localhost over fetch.
3
+ * Kept for secure defaults (contextIsolation + sandbox).
4
+ */
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ensure the Electron binary finished downloading after npm install
4
+ * (some environments skip lifecycle scripts / botch macOS framework unzip).
5
+ */
6
+ import { existsSync, rmSync, writeFileSync, statSync, mkdirSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ const desktopRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const electronPkg = join(desktopRoot, "node_modules", "electron");
14
+ const pathTxt = join(electronPkg, "path.txt");
15
+ const distDir = join(electronPkg, "dist");
16
+
17
+ if (!existsSync(join(electronPkg, "package.json"))) {
18
+ console.error("electron is not installed under desktop/. Run: npm install --prefix desktop");
19
+ process.exit(1);
20
+ }
21
+
22
+ function platformRelative() {
23
+ if (process.platform === "darwin") return "Electron.app/Contents/MacOS/Electron";
24
+ if (process.platform === "win32") return "electron.exe";
25
+ return "electron";
26
+ }
27
+
28
+ function binaryPath() {
29
+ return join(distDir, platformRelative());
30
+ }
31
+
32
+ function frameworkOk() {
33
+ if (process.platform !== "darwin") return true;
34
+ return existsSync(
35
+ join(distDir, "Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework"),
36
+ );
37
+ }
38
+
39
+ function looksComplete() {
40
+ const bin = binaryPath();
41
+ if (!existsSync(bin)) return false;
42
+ if (!frameworkOk()) return false;
43
+ return true;
44
+ }
45
+
46
+ function requireBin() {
47
+ try {
48
+ const require = createRequire(pathToFileURL(join(desktopRoot, "package.json")).href);
49
+ const bin = require("electron");
50
+ return typeof bin === "string" && existsSync(bin) ? bin : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function downloadZip() {
57
+ const require = createRequire(join(electronPkg, "package.json"));
58
+ const { downloadArtifact } = require("@electron/get");
59
+ const { version } = require(join(electronPkg, "package.json"));
60
+ const checksums = require(join(electronPkg, "checksums.json"));
61
+ return downloadArtifact({
62
+ version,
63
+ artifactName: "electron",
64
+ force: true,
65
+ platform: process.platform,
66
+ arch: process.arch,
67
+ checksums,
68
+ });
69
+ }
70
+
71
+ async function ensure() {
72
+ if (looksComplete()) {
73
+ if (!existsSync(pathTxt)) writeFileSync(pathTxt, platformRelative());
74
+ console.log(`Electron ready: ${requireBin() || binaryPath()}`);
75
+ return;
76
+ }
77
+
78
+ console.log("Downloading Electron binary…");
79
+ rmSync(distDir, { recursive: true, force: true });
80
+ try {
81
+ rmSync(pathTxt, { force: true });
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+
86
+ const zipPath = await downloadZip();
87
+ mkdirSync(distDir, { recursive: true });
88
+
89
+ // System unzip preserves macOS framework symlinks; extract-zip often does not.
90
+ const unzip = spawnSync("unzip", ["-o", "-q", zipPath, "-d", distDir], { stdio: "inherit" });
91
+ if (unzip.status !== 0) {
92
+ // Fallback for Windows / machines without unzip
93
+ const extract = requireFromElectron("extract-zip");
94
+ await extract(zipPath, { dir: distDir });
95
+ }
96
+
97
+ writeFileSync(pathTxt, platformRelative());
98
+ // Match electron/install.js version marker when present
99
+ try {
100
+ const { version } = createRequire(join(electronPkg, "package.json"))("./package.json");
101
+ writeFileSync(join(distDir, "version"), version);
102
+ } catch {
103
+ /* ignore */
104
+ }
105
+
106
+ if (!looksComplete()) {
107
+ console.error("Electron binary still incomplete after install.");
108
+ console.error("Try: rm -rf desktop/node_modules && npm run app:setup");
109
+ process.exit(1);
110
+ }
111
+
112
+ console.log(`Electron ready: ${requireBin() || binaryPath()}`);
113
+ }
114
+
115
+ function requireFromElectron(id) {
116
+ return createRequire(join(electronPkg, "package.json"))(id);
117
+ }
118
+
119
+ ensure().catch((err) => {
120
+ console.error(err instanceof Error ? err.stack : err);
121
+ process.exit(1);
122
+ });
@@ -9,6 +9,7 @@ import { installClaude, claudeInstallHealth } from "../install/claude.js";
9
9
  import { installCursor, cursorInstallHealth } from "../install/cursor.js";
10
10
  import { hostInstallHealth, installHost } from "../install/hosts.js";
11
11
  import { decorateDraft, decorateDrafts } from "../draft-quality.js";
12
+ import { isUsefulRememberText } from "../capture.js";
12
13
  import { buildSavingsExport, formatSavingsMarkdown, savingsPdf, } from "../savings-export.js";
13
14
  import { assertPlatformAllowed, assertRemoteAllowed, loadPolicy, } from "../policy.js";
14
15
  import { applyProposal, applySupersedes, parseProposalJson, validateProposal } from "../proposal.js";
@@ -412,6 +413,9 @@ function runRemember(repo, body) {
412
413
  const text = bodyField(body, "text");
413
414
  if (!text)
414
415
  return err(400, "text required");
416
+ if (!isUsefulRememberText(text)) {
417
+ return err(400, "text too trivial or secret-like — remember a durable fact with context");
418
+ }
415
419
  const kind = bodyField(body, "kind") ?? "session";
416
420
  const id = bodyField(body, "id") ??
417
421
  `claim.remember_${createHash("sha256").update(text).digest("hex").slice(0, 12)}`;
@@ -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/capture.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { type ProposalDraftRow, type RepoRow, type UsageEventRow } from "./db.js";
2
2
  import { type Proposal } from "./proposal.js";
3
+ /** Reject empty / chat-noise / secret-like text before it becomes a claim. */
3
4
  export declare function isUsefulCaptureText(text: string): boolean;
5
+ /** Same guard for explicit amem_remember / API writes (including short “test”). */
6
+ export declare function isUsefulRememberText(text: string): boolean;
4
7
  export declare function extractCaptureAnchors(text: string, repoRoot: string): string[];
5
8
  /** High-quality durable facts apply without waiting for amem_remember. */
6
9
  export declare function shouldAutoApplyProposal(proposal: Proposal): boolean;
package/dist/capture.js CHANGED
@@ -8,9 +8,10 @@ import { applyProposal } from "./proposal.js";
8
8
  import { scoreProposal } from "./draft-quality.js";
9
9
  import { tokenJaccard } from "./search.js";
10
10
  import { isAutoApplyAll } from "./prefs.js";
11
- const TRIVIAL = /^(ok|okay|yes|yep|no|nah|thanks|thank you|continue|go ahead|sure|please)\.?$/i;
11
+ const TRIVIAL = /^(ok|okay|yes|yep|no|nah|thanks|thank you|continue|go ahead|sure|please|test|testing|hello|hi|hey|ping|asdf|foo|bar)\.?$/i;
12
12
  const SECRET = /password|api[_-]?key|secret|token\s*[:=]|begin (rsa |openssh )?private/i;
13
13
  const PATH_RE = /\b(?:[\w.-]+\/)*[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|md|json|yml|yaml|sql)\b/g;
14
+ /** Reject empty / chat-noise / secret-like text before it becomes a claim. */
14
15
  export function isUsefulCaptureText(text) {
15
16
  const t = text.trim();
16
17
  if (t.length < 16)
@@ -21,6 +22,19 @@ export function isUsefulCaptureText(text) {
21
22
  return false;
22
23
  return true;
23
24
  }
25
+ /** Same guard for explicit amem_remember / API writes (including short “test”). */
26
+ export function isUsefulRememberText(text) {
27
+ const t = text.trim();
28
+ if (!t)
29
+ return false;
30
+ if (TRIVIAL.test(t))
31
+ return false;
32
+ if (t.length < 8)
33
+ return false;
34
+ if (SECRET.test(t))
35
+ return false;
36
+ return true;
37
+ }
24
38
  export function extractCaptureAnchors(text, repoRoot) {
25
39
  const found = [...text.matchAll(PATH_RE)].map((m) => m[0]);
26
40
  const unique = [...new Set(found)].slice(0, 6);
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { amemHome, dbPath, tryEnsureDir } from "./paths.js";
12
12
  import { assertExportAllowed, assertPlatformAllowed, assertRemoteAllowed, assertUiAllowed, loadPolicy, } from "./policy.js";
13
13
  import { applyProposal, diffProposal, exportRepoMemory, formatProposalDiff, loadProposalFile, validateProposal, } from "./proposal.js";
14
14
  import { detectRepoIdentity, parseWorkspaceSlug, workspaceIdentity } from "./repo-identity.js";
15
+ import { runDesktopApp } from "./app-shell.js";
15
16
  import { startUiServer, buildUiLandingUrl, openUiInBrowser, isAddrInUse, probeUiHealth, } from "./ui/server.js";
16
17
  import { installLoginService, isServiceInstalled, isServiceSupported, uninstallLoginService } from "./service.js";
17
18
  import { mcpClientConfig, runMcpServer } from "./mcp.js";
@@ -66,6 +67,7 @@ Usage:
66
67
  amem usage report --saved <n> [--platform cursor|claude] [--event-id <id>]
67
68
  amem usage export [--format json|md|pdf] [--days 30] [--scope current|all] [--out <file>]
68
69
  amem ui [--port 7843] [--no-open]
70
+ amem app [--port 7843]
69
71
  amem service install|uninstall|status
70
72
  amem mcp [--print-config] [--workspace <name>]
71
73
  amem license status|apply|clear|issue|keys
@@ -846,6 +848,21 @@ async function main() {
846
848
  }
847
849
  break;
848
850
  }
851
+ case "app": {
852
+ assertUiAllowed();
853
+ const port = Number(flagString(flags, "port") ?? "7843");
854
+ try {
855
+ await runDesktopApp({ port, cwd: process.cwd() });
856
+ }
857
+ catch (error) {
858
+ console.error(error instanceof Error ? error.message : String(error));
859
+ closeDb();
860
+ process.exitCode = 1;
861
+ break;
862
+ }
863
+ closeDb();
864
+ break;
865
+ }
849
866
  case "service": {
850
867
  const sub = positional[1];
851
868
  if (sub === "status") {
package/dist/hygiene.d.ts CHANGED
@@ -23,9 +23,18 @@ export type HygienePreview = {
23
23
  /** Estimated active count after decay + merge */
24
24
  afterCleanup: number;
25
25
  softPaywall: boolean;
26
+ /** Active claims with kind=session (chat noise indicator). */
27
+ sessionCount: number;
28
+ /** sessionCount / active, 0 when empty. */
29
+ sessionRatio: number;
26
30
  };
27
31
  export declare const SOFT_PAYWALL_FACTS = 200;
28
32
  export declare const SOFT_PAYWALL_NOISE = 15;
33
+ /** Soft-paywall when session chat takeaways dominate the graph. */
34
+ export declare const SOFT_PAYWALL_SESSION_RATIO = 0.55;
35
+ export declare const SOFT_PAYWALL_SESSION_MIN = 25;
36
+ /** Unused unpinned session claims become decay candidates sooner than durable kinds. */
37
+ export declare const SESSION_UNUSED_DAYS = 14;
29
38
  /** Free: counts only for soft paywall / banners. Never applies changes. */
30
39
  export declare function hygienePreview(repoId: string, unusedDays?: number): HygienePreview;
31
40
  /** Aggregate preview across all tracked repos (for All memory scope). */
package/dist/hygiene.js CHANGED
@@ -10,6 +10,11 @@ import { tokenJaccard } from "./search.js";
10
10
  import { parseAnchors } from "./freshness.js";
11
11
  export const SOFT_PAYWALL_FACTS = 200;
12
12
  export const SOFT_PAYWALL_NOISE = 15;
13
+ /** Soft-paywall when session chat takeaways dominate the graph. */
14
+ export const SOFT_PAYWALL_SESSION_RATIO = 0.55;
15
+ export const SOFT_PAYWALL_SESSION_MIN = 25;
16
+ /** Unused unpinned session claims become decay candidates sooner than durable kinds. */
17
+ export const SESSION_UNUSED_DAYS = 14;
13
18
  function usedClaimIds(repoId, days) {
14
19
  const ids = new Set();
15
20
  for (const event of listUsageEvents({ repoId, days })) {
@@ -29,14 +34,24 @@ function usedClaimIds(repoId, days) {
29
34
  function computeHygiene(repoId, unusedDays = 90) {
30
35
  const claims = listClaims(repoId);
31
36
  const used = usedClaimIds(repoId, unusedDays);
37
+ const usedSessions = usedClaimIds(repoId, SESSION_UNUSED_DAYS);
32
38
  const cutoff = Date.now() - unusedDays * 86_400_000;
39
+ const sessionCutoff = Date.now() - SESSION_UNUSED_DAYS * 86_400_000;
33
40
  const stale = claims.filter((c) => {
34
41
  if (Number(c.pinned || 0) > 0)
35
42
  return false;
43
+ const updated = Date.parse(c.updated_at);
44
+ if (!Number.isFinite(updated))
45
+ return false;
46
+ const isSession = (c.kind || "").toLowerCase() === "session";
47
+ if (isSession) {
48
+ if (usedSessions.has(c.id))
49
+ return false;
50
+ return updated < sessionCutoff;
51
+ }
36
52
  if (used.has(c.id))
37
53
  return false;
38
- const updated = Date.parse(c.updated_at);
39
- return Number.isFinite(updated) && updated < cutoff;
54
+ return updated < cutoff;
40
55
  });
41
56
  const duplicates = [];
42
57
  for (let i = 0; i < claims.length; i++) {
@@ -63,16 +78,40 @@ function computeHygiene(repoId, unusedDays = 90) {
63
78
  active: claims.length,
64
79
  };
65
80
  }
81
+ function sessionStats(repoId) {
82
+ const claims = listClaims(repoId);
83
+ const active = claims.length;
84
+ const sessionCount = claims.filter((c) => (c.kind || "").toLowerCase() === "session").length;
85
+ const sessionRatio = active > 0 ? sessionCount / active : 0;
86
+ return { sessionCount, sessionRatio, active };
87
+ }
88
+ function softPaywallFrom(preview) {
89
+ if (hasFeature(FEATURE_HYGIENE))
90
+ return false;
91
+ const noise = preview.staleCount + preview.duplicateCount;
92
+ if (preview.active >= SOFT_PAYWALL_FACTS || noise >= SOFT_PAYWALL_NOISE)
93
+ return true;
94
+ if (preview.sessionCount >= SOFT_PAYWALL_SESSION_MIN &&
95
+ preview.sessionRatio >= SOFT_PAYWALL_SESSION_RATIO) {
96
+ return true;
97
+ }
98
+ return false;
99
+ }
66
100
  /** Free: counts only for soft paywall / banners. Never applies changes. */
67
101
  export function hygienePreview(repoId, unusedDays = 90) {
68
102
  const report = computeHygiene(repoId, unusedDays);
103
+ const { sessionCount, sessionRatio } = sessionStats(repoId);
69
104
  const staleCount = report.stale.length;
70
105
  const duplicateCount = report.duplicates.length;
71
106
  const removable = Math.min(report.active, staleCount + duplicateCount);
72
107
  const afterCleanup = Math.max(0, report.active - removable);
73
- const thresholdHit = report.active >= SOFT_PAYWALL_FACTS || staleCount + duplicateCount >= SOFT_PAYWALL_NOISE;
74
- // Paid (hygiene unlocked) — never surface an unpaid soft-paywall signal.
75
- const softPaywall = thresholdHit && !hasFeature(FEATURE_HYGIENE);
108
+ const softPaywall = softPaywallFrom({
109
+ active: report.active,
110
+ staleCount,
111
+ duplicateCount,
112
+ sessionCount,
113
+ sessionRatio,
114
+ });
76
115
  return {
77
116
  active: report.active,
78
117
  staleCount,
@@ -80,6 +119,8 @@ export function hygienePreview(repoId, unusedDays = 90) {
80
119
  pendingDrafts: report.pendingDrafts,
81
120
  afterCleanup,
82
121
  softPaywall,
122
+ sessionCount,
123
+ sessionRatio,
83
124
  };
84
125
  }
85
126
  /** Aggregate preview across all tracked repos (for All memory scope). */
@@ -88,18 +129,35 @@ export function hygienePreviewAll(unusedDays = 90) {
88
129
  let staleCount = 0;
89
130
  let duplicateCount = 0;
90
131
  let pendingDrafts = 0;
132
+ let sessionCount = 0;
91
133
  for (const repo of listRepos()) {
92
134
  const p = hygienePreview(repo.id, unusedDays);
93
135
  active += p.active;
94
136
  staleCount += p.staleCount;
95
137
  duplicateCount += p.duplicateCount;
96
138
  pendingDrafts += p.pendingDrafts;
139
+ sessionCount += p.sessionCount;
97
140
  }
98
141
  const removable = Math.min(active, staleCount + duplicateCount);
99
142
  const afterCleanup = Math.max(0, active - removable);
100
- const thresholdHit = active >= SOFT_PAYWALL_FACTS || staleCount + duplicateCount >= SOFT_PAYWALL_NOISE;
101
- const softPaywall = thresholdHit && !hasFeature(FEATURE_HYGIENE);
102
- return { active, staleCount, duplicateCount, pendingDrafts, afterCleanup, softPaywall };
143
+ const sessionRatio = active > 0 ? sessionCount / active : 0;
144
+ const softPaywall = softPaywallFrom({
145
+ active,
146
+ staleCount,
147
+ duplicateCount,
148
+ sessionCount,
149
+ sessionRatio,
150
+ });
151
+ return {
152
+ active,
153
+ staleCount,
154
+ duplicateCount,
155
+ pendingDrafts,
156
+ afterCleanup,
157
+ softPaywall,
158
+ sessionCount,
159
+ sessionRatio,
160
+ };
103
161
  }
104
162
  export function hygieneReport(repoId, unusedDays = 90) {
105
163
  requireFeature(FEATURE_HYGIENE, "Memory hygiene");
package/dist/publish.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Paths that must be inside the published tarball for `npx @iamem/amem setup`. */
2
- export declare const REQUIRED_PACK_PATHS: readonly ["dist/cli.js", "dist/mcp.js", "dist/remember-contract.js", "ui-static/index.html", "ui-static/app.js", "ui-static/styles.css", "docs/remember-contract.md", "docs/agent-install-prompt.md", "docs/npm-release.md", "skills/amem-update-working-memory/SKILL.md", "scripts/mdm-offboard.sh"];
2
+ export declare const REQUIRED_PACK_PATHS: readonly ["dist/cli.js", "dist/mcp.js", "dist/remember-contract.js", "ui-static/index.html", "ui-static/app.js", "ui-static/styles.css", "docs/remember-contract.md", "docs/agent-install-prompt.md", "docs/npm-release.md", "skills/amem-update-working-memory/SKILL.md", "scripts/mdm-offboard.sh", "scripts/postinstall.js"];
3
3
  export declare const PUBLISH_PACKAGE_NAME = "@iamem/amem";
4
4
  export type PublishReady = {
5
5
  root: string;
package/dist/publish.js CHANGED
@@ -14,6 +14,7 @@ export const REQUIRED_PACK_PATHS = [
14
14
  "docs/npm-release.md",
15
15
  "skills/amem-update-working-memory/SKILL.md",
16
16
  "scripts/mdm-offboard.sh",
17
+ "scripts/postinstall.js",
17
18
  ];
18
19
  export const PUBLISH_PACKAGE_NAME = "@iamem/amem";
19
20
  export function packageRoot() {
package/docs/backlog.md CHANGED
@@ -37,16 +37,16 @@ Updated after completing the Feature Map **Later** phase (local embedding model
37
37
 
38
38
  ## Open
39
39
 
40
- - First npm publish of `@iamem/amem` (org `@iamem` already exists; need valid `NPM_TOKEN` / `npm login`, then tag `v0.1.0` or `npm publish --access public`).
41
40
  - Prompt-pack before/after Stats benchmark; restore wizard polish; IT seat pack.
42
41
  - Decide one-time vs subscription (offline files cannot revoke on cancel unless you add `expires_at` and re-issue).
43
42
  - Optional vendored ONNX/MiniLM weights in a paid pack (external command is the local hook today).
43
+ - Move CI publish to npm Trusted Publishing (OIDC) before Jan 2027 GAT bypass-2FA publish sunset.
44
44
 
45
45
  ## Shipped (go-to-market / upsell)
46
46
 
47
47
  - Public Checkout at **getamem.com** (tryamem redirects); Stripe live webhook.
48
48
  - Mailtrap **live send** (`MAILTRAP_USE_TESTING=false`); thank-you download still works if mail fails.
49
- - Package renamed to `@iamem/amem` (CLI binary still `amem`); shop install copy updated.
49
+ - **`@iamem/amem@0.1.0` on npm** — `npx @iamem/amem setup` / `npm i -g @iamem/amem` (CLI binary `amem`).
50
50
  - UI **Apply license** (paste/drop) + **Turn on Pro retrieval** checklist.
51
51
  - Memory **retrieval showdown** (free hash vs Pro n-gram) + top-bar **Try retrieval**.
52
52
  - Remember-contract guidance: prefer durable kinds; avoid `session` spam.
@@ -30,7 +30,7 @@ The pack includes deny-by-default `policy.toml`, an MDM plist stub, `mdm-offboar
30
30
 
31
31
  ## Install (DevEx / IT)
32
32
 
33
- 1. Ship a pinned amem build (internal npm, pkg, or `npm link` from a mirrored clone). Node 20+ required (`better-sqlite3`).
33
+ 1. Ship a pinned amem build: `npm i -g @iamem/amem` (or internal mirror / pkg). Node 20+ required (`better-sqlite3` prebuilds on common macOS/Linux).
34
34
  2. Deploy policy:
35
35
 
36
36
  ```bash
@@ -78,6 +78,7 @@ That is not a remote public API:
78
78
  - Nothing in `~/.amem` is uploaded or synced.
79
79
  - Binding is loopback-only — LAN/WAN clients cannot reach the UI by default.
80
80
  - Treat any process on the same machine as potentially able to call `127.0.0.1:7843` while the UI is running (same as any local MCP server).
81
+ - Do not expose the UI on a non-loopback bind; policy forces loopback. Tightening `Access-Control-Allow-Origin` to specific Origins is optional and can break some MCP hosts — leave `*` unless you control every client Origin.
81
82
 
82
83
  ## Offboarding
83
84
 
@@ -35,4 +35,4 @@ This repo does **not** auto-publish on every push.
35
35
 
36
36
  ## Pack contents
37
37
 
38
- `package.json#files` includes `dist`, `ui-static`, `docs`, `skills`, and `scripts/mdm-offboard.sh`. Native code is pulled in as the `better-sqlite3` dependency — do not vendor it.
38
+ `package.json#files` includes `dist`, `ui-static`, `docs`, `skills`, `scripts/mdm-offboard.sh`, and `scripts/postinstall.js` (anonymous install ping; opt out with `AMEM_TELEMETRY_DISABLED=1`). Native code is pulled in as the `better-sqlite3` dependency — do not vendor it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamem/amem",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local personal agent memory for Cursor and Claude Code. Private to your machine — never shared.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,17 +16,22 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
+ "desktop",
19
20
  "skills",
20
21
  "templates",
21
22
  "docs",
22
23
  "ui-static",
23
- "scripts/mdm-offboard.sh"
24
+ "scripts/mdm-offboard.sh",
25
+ "scripts/postinstall.js"
24
26
  ],
25
27
  "scripts": {
26
28
  "build": "tsc",
27
29
  "dev": "tsc --watch",
28
30
  "prepare": "npm run build",
31
+ "postinstall": "node ./scripts/postinstall.js",
29
32
  "amem": "node dist/cli.js",
33
+ "app": "node dist/cli.js app",
34
+ "app:setup": "npm install --prefix desktop && node desktop/scripts/ensure-electron.mjs",
30
35
  "smoke": "node scripts/smoke.mjs",
31
36
  "pack:check": "npm run build && node scripts/check-pack.mjs",
32
37
  "prepublishOnly": "npm test && node scripts/check-pack.mjs",
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Anonymous install ping for @iamem/amem (package version, Node, OS/arch only).
4
+ * Opt out: AMEM_TELEMETRY_DISABLED=1 — skipped automatically in CI / NODE_ENV=test.
5
+ * Never throws; always exits 0 so install is never blocked.
6
+ */
7
+ import https from "node:https";
8
+ import os from "node:os";
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ function done() {
14
+ process.exit(0);
15
+ }
16
+
17
+ try {
18
+ if (
19
+ process.env.AMEM_TELEMETRY_DISABLED ||
20
+ process.env.CI ||
21
+ process.env.NODE_ENV === "test"
22
+ ) {
23
+ done();
24
+ }
25
+
26
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
28
+ const body = JSON.stringify({
29
+ event: "npm_install",
30
+ package: pkg.name || "@iamem/amem",
31
+ version: pkg.version || "",
32
+ node: process.version,
33
+ platform: os.platform(),
34
+ arch: os.arch(),
35
+ ts: Date.now(),
36
+ });
37
+
38
+ const req = https.request(
39
+ {
40
+ hostname: "getamem.com",
41
+ path: "/api/beacon/npm-install",
42
+ method: "POST",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ "Content-Length": Buffer.byteLength(body),
46
+ "User-Agent": `amem-postinstall/${pkg.name || "amem"}@${pkg.version || "0"}`,
47
+ },
48
+ timeout: 2000,
49
+ },
50
+ () => done(),
51
+ );
52
+ req.on("error", () => done());
53
+ req.on("timeout", () => {
54
+ req.destroy();
55
+ done();
56
+ });
57
+ setTimeout(done, 2000);
58
+ req.write(body);
59
+ req.end();
60
+ } catch {
61
+ done();
62
+ }
package/ui-static/app.js CHANGED
@@ -320,6 +320,8 @@ function writeUrlState() {
320
320
 
321
321
  function setTab(tab) {
322
322
  if (tab === "stats") tab = "analytics";
323
+ // Paid licenses: Plans is a sell page — don't open it; Setup still has Apply license.
324
+ if (tab === "welcome" && isPaidLicense()) tab = "dashboard";
323
325
  if (tab === "brain") {
324
326
  state.brainAll = true;
325
327
  persistBrainAll(true);
@@ -405,6 +407,7 @@ async function refreshVault() {
405
407
  } catch {
406
408
  state.license = state.status?.license || null;
407
409
  }
410
+ paintPlansNavVisibility(isPaidLicense());
408
411
  try {
409
412
  state.embed = await apiUnscoped("/api/embed");
410
413
  } catch {
@@ -1051,9 +1054,17 @@ function isPaidLicense() {
1051
1054
  const tier = String(state.license?.tier || state.status?.license?.tier || "free").toLowerCase();
1052
1055
  const paid = state.license?.valid !== false && (tier === "pro" || tier === "it");
1053
1056
  if (paid) persistLicenseTier(tier);
1057
+ paintPlansNavVisibility(paid);
1054
1058
  return paid;
1055
1059
  }
1056
1060
 
1061
+ /** Paid users don't need the Plans sell tab in the sidebar (Setup still has Apply license). */
1062
+ function paintPlansNavVisibility(paid) {
1063
+ const btn = document.querySelector('#tabs button[data-tab="welcome"]');
1064
+ if (!btn) return;
1065
+ btn.classList.toggle("hidden", Boolean(paid));
1066
+ }
1067
+
1057
1068
  function licenseApplyHtml(idPrefix = "lic") {
1058
1069
  return `
1059
1070
  <div class="license-apply" id="${idPrefix}ApplyBox">
@@ -2098,14 +2109,20 @@ async function loadSoftPaywall() {
2098
2109
  return;
2099
2110
  }
2100
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;
2101
2115
  const shop = state.shop || {};
2102
2116
  const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
2103
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.`;
2104
2121
  el.innerHTML = `
2105
2122
  <div class="soft-paywall-inner">
2106
2123
  <div>
2107
- <strong>Pro can clean this</strong>
2108
- <p class="note" style="margin:0.25rem 0 0">${preview.active} facts · ~${noise} unused/duplicates → about <b>${preview.afterCleanup}</b> after cleanup. Free still works — this is optional.</p>
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>
2109
2126
  </div>
2110
2127
  <div class="soft-paywall-actions">
2111
2128
  <a class="btn small" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro · Apply cleanup</a>
@@ -3810,7 +3827,7 @@ async function render() {
3810
3827
  document.querySelectorAll("#tabs button[data-tab]").forEach((b) => {
3811
3828
  b.addEventListener("click", () => setTab(b.dataset.tab));
3812
3829
  });
3813
- $("#brandBtn")?.addEventListener("click", () => setTab("welcome"));
3830
+ $("#brandBtn")?.addEventListener("click", () => setTab(isPaidLicense() ? "dashboard" : "welcome"));
3814
3831
 
3815
3832
  $("#brainSearch")?.addEventListener("input", (e) => {
3816
3833
  if (state.tab !== "brain") return;
@@ -1306,6 +1306,10 @@ a.btn {
1306
1306
  display: none;
1307
1307
  }
1308
1308
 
1309
+ .side-tabs button.hidden {
1310
+ display: none !important;
1311
+ }
1312
+
1309
1313
  .hidden {
1310
1314
  display: none !important;
1311
1315
  }