@iamem/amem 0.1.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/activity.d.ts +27 -0
  4. package/dist/activity.js +202 -0
  5. package/dist/api/routes.d.ts +31 -0
  6. package/dist/api/routes.js +1345 -0
  7. package/dist/attest.d.ts +52 -0
  8. package/dist/attest.js +192 -0
  9. package/dist/backup-schedule.d.ts +25 -0
  10. package/dist/backup-schedule.js +216 -0
  11. package/dist/capture.d.ts +34 -0
  12. package/dist/capture.js +257 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +1007 -0
  15. package/dist/context.d.ts +46 -0
  16. package/dist/context.js +334 -0
  17. package/dist/crypto.d.ts +39 -0
  18. package/dist/crypto.js +166 -0
  19. package/dist/db.d.ts +192 -0
  20. package/dist/db.js +666 -0
  21. package/dist/draft-quality.d.ts +19 -0
  22. package/dist/draft-quality.js +85 -0
  23. package/dist/embed.d.ts +76 -0
  24. package/dist/embed.js +331 -0
  25. package/dist/estimate.d.ts +32 -0
  26. package/dist/estimate.js +69 -0
  27. package/dist/freshness.d.ts +15 -0
  28. package/dist/freshness.js +93 -0
  29. package/dist/hook.d.ts +16 -0
  30. package/dist/hook.js +177 -0
  31. package/dist/hygiene-schedule.d.ts +28 -0
  32. package/dist/hygiene-schedule.js +221 -0
  33. package/dist/hygiene.d.ts +61 -0
  34. package/dist/hygiene.js +196 -0
  35. package/dist/install/claude.d.ts +6 -0
  36. package/dist/install/claude.js +69 -0
  37. package/dist/install/cursor.d.ts +7 -0
  38. package/dist/install/cursor.js +80 -0
  39. package/dist/install/hosts.d.ts +21 -0
  40. package/dist/install/hosts.js +186 -0
  41. package/dist/install/skills.d.ts +8 -0
  42. package/dist/install/skills.js +67 -0
  43. package/dist/it-pack.d.ts +20 -0
  44. package/dist/it-pack.js +84 -0
  45. package/dist/kinds.d.ts +18 -0
  46. package/dist/kinds.js +106 -0
  47. package/dist/license.d.ts +48 -0
  48. package/dist/license.js +172 -0
  49. package/dist/mcp.d.ts +40 -0
  50. package/dist/mcp.js +435 -0
  51. package/dist/paths.d.ts +11 -0
  52. package/dist/paths.js +55 -0
  53. package/dist/personal.d.ts +7 -0
  54. package/dist/personal.js +44 -0
  55. package/dist/platforms.d.ts +11 -0
  56. package/dist/platforms.js +32 -0
  57. package/dist/policy.d.ts +46 -0
  58. package/dist/policy.js +254 -0
  59. package/dist/prefs.d.ts +6 -0
  60. package/dist/prefs.js +11 -0
  61. package/dist/proposal.d.ts +90 -0
  62. package/dist/proposal.js +376 -0
  63. package/dist/publish.d.ts +28 -0
  64. package/dist/publish.js +57 -0
  65. package/dist/remember-contract.d.ts +23 -0
  66. package/dist/remember-contract.js +117 -0
  67. package/dist/repo-identity.d.ts +15 -0
  68. package/dist/repo-identity.js +82 -0
  69. package/dist/rules-sync.d.ts +7 -0
  70. package/dist/rules-sync.js +47 -0
  71. package/dist/savings-export.d.ts +49 -0
  72. package/dist/savings-export.js +141 -0
  73. package/dist/scan.d.ts +16 -0
  74. package/dist/scan.js +109 -0
  75. package/dist/search.d.ts +25 -0
  76. package/dist/search.js +150 -0
  77. package/dist/service.d.ts +20 -0
  78. package/dist/service.js +254 -0
  79. package/dist/shop.d.ts +9 -0
  80. package/dist/shop.js +15 -0
  81. package/dist/ui/server.d.ts +21 -0
  82. package/dist/ui/server.js +268 -0
  83. package/dist/vault.d.ts +25 -0
  84. package/dist/vault.js +42 -0
  85. package/dist/workspace-setup.d.ts +8 -0
  86. package/dist/workspace-setup.js +55 -0
  87. package/docs/agent-install-prompt.md +41 -0
  88. package/docs/backlog.md +59 -0
  89. package/docs/enterprise-endpoint.md +98 -0
  90. package/docs/license.md +54 -0
  91. package/docs/npm-release.md +38 -0
  92. package/docs/remember-contract.md +42 -0
  93. package/package.json +64 -0
  94. package/scripts/mdm-offboard.sh +14 -0
  95. package/skills/amem-bootstrap/SKILL.md +83 -0
  96. package/skills/amem-update-working-memory/SKILL.md +54 -0
  97. package/templates/cursor-rule.mdc +23 -0
  98. package/templates/mdm/co.amem.managed.plist +33 -0
  99. package/templates/policy.deny-default.toml +20 -0
  100. package/templates/policy.example.toml +22 -0
  101. package/ui-static/app.js +3966 -0
  102. package/ui-static/index.html +178 -0
  103. package/ui-static/orbit.js +389 -0
  104. package/ui-static/styles.css +2411 -0
@@ -0,0 +1,93 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ export function parseAnchors(codeAnchorsJson) {
4
+ try {
5
+ const parsed = JSON.parse(codeAnchorsJson);
6
+ if (!Array.isArray(parsed))
7
+ return [];
8
+ return parsed.filter((a) => typeof a === "string" && Boolean(a.trim()));
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ }
14
+ function resolveAnchorPath(rootPath, anchor) {
15
+ if (isAbsolute(anchor))
16
+ return anchor;
17
+ return join(rootPath, anchor);
18
+ }
19
+ /**
20
+ * Compare claim.updated_at to filesystem mtimes of code_anchors.
21
+ * Missing paths → missing_anchor; any newer file → stale.
22
+ */
23
+ export function assessClaimFreshness(rootPath, claim) {
24
+ const anchors = parseAnchors(claim.code_anchors);
25
+ if (anchors.length === 0) {
26
+ return { status: "unanchored", staleAnchors: [], missingAnchors: [] };
27
+ }
28
+ if (!rootPath) {
29
+ return { status: "unknown", staleAnchors: [], missingAnchors: [] };
30
+ }
31
+ const claimUpdatedMs = Date.parse(claim.updated_at);
32
+ if (Number.isNaN(claimUpdatedMs)) {
33
+ return { status: "unknown", staleAnchors: [], missingAnchors: [] };
34
+ }
35
+ const staleAnchors = [];
36
+ const missingAnchors = [];
37
+ let sawFile = false;
38
+ for (const anchor of anchors) {
39
+ const full = resolveAnchorPath(rootPath, anchor);
40
+ if (!existsSync(full)) {
41
+ // Workspace-style anchors (slug names) are not real paths — ignore those.
42
+ if (!anchor.includes("/") && !anchor.includes("\\") && !anchor.includes(".")) {
43
+ continue;
44
+ }
45
+ missingAnchors.push(anchor);
46
+ continue;
47
+ }
48
+ try {
49
+ const st = statSync(full);
50
+ if (!st.isFile() && !st.isDirectory())
51
+ continue;
52
+ sawFile = true;
53
+ // 2s skew: avoid false stale right after apply on slow FS
54
+ if (st.mtimeMs > claimUpdatedMs + 2000) {
55
+ staleAnchors.push(anchor);
56
+ }
57
+ }
58
+ catch {
59
+ missingAnchors.push(anchor);
60
+ }
61
+ }
62
+ if (staleAnchors.length > 0) {
63
+ return { status: "stale", staleAnchors, missingAnchors };
64
+ }
65
+ if (missingAnchors.length > 0 && !sawFile) {
66
+ return { status: "missing_anchor", staleAnchors, missingAnchors };
67
+ }
68
+ if (missingAnchors.length > 0 && sawFile) {
69
+ // Some anchors ok, some missing — treat as soft missing, still usable
70
+ return { status: "fresh", staleAnchors, missingAnchors };
71
+ }
72
+ if (!sawFile && missingAnchors.length === 0) {
73
+ // Only non-path anchors (workspace labels)
74
+ return { status: "unanchored", staleAnchors: [], missingAnchors: [] };
75
+ }
76
+ return { status: "fresh", staleAnchors: [], missingAnchors: [] };
77
+ }
78
+ /** Multiplier applied to retrieval score (1 = full trust). */
79
+ export function freshnessScoreMultiplier(status) {
80
+ switch (status) {
81
+ case "stale":
82
+ return 0.35;
83
+ case "missing_anchor":
84
+ return 0.5;
85
+ case "unknown":
86
+ return 0.85;
87
+ case "unanchored":
88
+ return 0.9;
89
+ case "fresh":
90
+ default:
91
+ return 1;
92
+ }
93
+ }
package/dist/hook.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type HookPayload = {
2
+ hook_event_name?: string;
3
+ /** Claude Code uses `hook_event_name` or top-level event aliases */
4
+ event?: string;
5
+ prompt?: string;
6
+ text?: string;
7
+ conversation_id?: string;
8
+ session_id?: string;
9
+ workspace_roots?: string[];
10
+ cwd?: string;
11
+ transcript_path?: string | null;
12
+ };
13
+ export type HookResponse = Record<string, unknown>;
14
+ /** Normalize Cursor + Claude Code event names into the Cursor-style set. */
15
+ export declare function normalizeHookEvent(raw: string): string;
16
+ export declare function handleHookPayload(raw: string): HookResponse;
package/dist/hook.js ADDED
@@ -0,0 +1,177 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { logContextUsage } from "./api/routes.js";
4
+ import { captureMissLearnDraft, captureSessionDraft, findRecentContextMisses, isUsefulCaptureText, } from "./capture.js";
5
+ import { getRepoByCwd, insertConversationNote, listConversationNotes, upsertRepo, } from "./db.js";
6
+ import { detectRepoIdentity } from "./repo-identity.js";
7
+ const SECRET = /password|api[_-]?key|secret|token\s*[:=]|begin (rsa |openssh )?private/i;
8
+ function workspaceRoot(payload) {
9
+ const roots = payload.workspace_roots;
10
+ if (Array.isArray(roots) && typeof roots[0] === "string" && roots[0]) {
11
+ return resolve(roots[0]);
12
+ }
13
+ if (typeof payload.cwd === "string" && payload.cwd.trim()) {
14
+ return resolve(payload.cwd);
15
+ }
16
+ const envRoot = process.env.CURSOR_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR;
17
+ if (envRoot)
18
+ return resolve(envRoot);
19
+ return process.cwd();
20
+ }
21
+ function sessionId(payload) {
22
+ return payload.conversation_id || payload.session_id || process.env.CURSOR_SESSION_ID || "unknown";
23
+ }
24
+ function bindRepo(cwd) {
25
+ if (!existsSync(cwd))
26
+ return null;
27
+ const existing = getRepoByCwd(cwd);
28
+ if (existing)
29
+ return existing;
30
+ const identity = detectRepoIdentity(cwd);
31
+ return upsertRepo(identity, hookPlatform());
32
+ }
33
+ function cap(text, max = 2400) {
34
+ if (text.length <= max)
35
+ return text;
36
+ return `${text.slice(0, max)}\n\n_[amem truncated]_`;
37
+ }
38
+ function hookPlatform() {
39
+ if (process.env.CLAUDE_PROJECT_DIR && !process.env.CURSOR_PROJECT_DIR)
40
+ return "claude";
41
+ return "cursor";
42
+ }
43
+ /** Normalize Cursor + Claude Code event names into the Cursor-style set. */
44
+ export function normalizeHookEvent(raw) {
45
+ const e = (raw || "").trim();
46
+ if (!e)
47
+ return "";
48
+ const lower = e.toLowerCase();
49
+ if (lower === "userpromptsubmit" || e === "UserPromptSubmit")
50
+ return "beforeSubmitPrompt";
51
+ if (lower === "stop" || e === "Stop")
52
+ return "stop";
53
+ if (lower === "sessionstart" || e === "SessionStart")
54
+ return "sessionStart";
55
+ if (lower === "sessionend" || e === "SessionEnd")
56
+ return "sessionEnd";
57
+ return e;
58
+ }
59
+ function injectPacket(repo, query, session, platform) {
60
+ const { markdown, packet } = logContextUsage({
61
+ repoId: repo.id,
62
+ platform,
63
+ sessionId: session,
64
+ query: query || "(session start)",
65
+ });
66
+ if (packet.claims.length === 0 && packet.notes.length === 0)
67
+ return null;
68
+ return cap(markdown);
69
+ }
70
+ export function handleHookPayload(raw) {
71
+ try {
72
+ return handleHookPayloadInner(raw);
73
+ }
74
+ catch {
75
+ return { continue: true };
76
+ }
77
+ }
78
+ function handleHookPayloadInner(raw) {
79
+ let payload = {};
80
+ const trimmed = raw.trim();
81
+ if (trimmed) {
82
+ try {
83
+ payload = JSON.parse(trimmed);
84
+ }
85
+ catch {
86
+ return { continue: true };
87
+ }
88
+ }
89
+ const event = normalizeHookEvent(payload.hook_event_name || payload.event || "");
90
+ const cwd = workspaceRoot(payload);
91
+ const repo = bindRepo(cwd);
92
+ if (!repo)
93
+ return { continue: true };
94
+ const sid = sessionId(payload);
95
+ const platform = hookPlatform();
96
+ if (event === "sessionStart") {
97
+ const context = injectPacket(repo, "", sid, platform);
98
+ return context
99
+ ? {
100
+ continue: true,
101
+ additional_context: `amem local memory for ${repo.repo_name}. Prefer these file anchors over broad exploration.\n\n${context}`,
102
+ }
103
+ : { continue: true };
104
+ }
105
+ if (event === "beforeSubmitPrompt") {
106
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
107
+ if (isUsefulCaptureText(prompt)) {
108
+ insertConversationNote({
109
+ repoId: repo.id,
110
+ platform,
111
+ sessionId: sid,
112
+ role: "user",
113
+ text: prompt,
114
+ });
115
+ }
116
+ const context = isUsefulCaptureText(prompt) ? injectPacket(repo, prompt, sid, platform) : null;
117
+ return context
118
+ ? {
119
+ continue: true,
120
+ additional_context: `amem already knows this about ${repo.repo_name}. Use it before grepping broadly.\n\n${context}`,
121
+ }
122
+ : { continue: true };
123
+ }
124
+ if (event === "afterAgentResponse") {
125
+ const text = typeof payload.text === "string" ? payload.text : "";
126
+ if (text.trim().length > 40 && !SECRET.test(text)) {
127
+ insertConversationNote({
128
+ repoId: repo.id,
129
+ platform,
130
+ sessionId: sid,
131
+ role: "assistant",
132
+ text,
133
+ });
134
+ // Miss → learn: if a recent context lookup returned nothing, draft from this answer.
135
+ const misses = findRecentContextMisses(repo.id, { sessionId: sid, limit: 3 });
136
+ for (const miss of misses) {
137
+ captureMissLearnDraft({
138
+ repo,
139
+ platform,
140
+ sessionId: sid,
141
+ miss,
142
+ answer: text,
143
+ });
144
+ }
145
+ }
146
+ return {};
147
+ }
148
+ if (event === "stop" || event === "sessionEnd") {
149
+ const recent = listConversationNotes(repo.id, 12);
150
+ const lastUser = recent.find((n) => n.role === "user");
151
+ const lastAssistant = recent.find((n) => n.role === "assistant");
152
+ if (lastAssistant?.text) {
153
+ const misses = findRecentContextMisses(repo.id, { sessionId: sid, limit: 3 });
154
+ for (const miss of misses) {
155
+ captureMissLearnDraft({
156
+ repo,
157
+ platform,
158
+ sessionId: sid,
159
+ miss,
160
+ answer: lastAssistant.text,
161
+ });
162
+ }
163
+ }
164
+ if (lastUser) {
165
+ captureSessionDraft({
166
+ repo,
167
+ platform,
168
+ sessionId: sid,
169
+ prompt: lastUser.text,
170
+ answer: lastAssistant?.text,
171
+ notes: recent.slice(0, 8).map((n) => ({ role: n.role, text: n.text })),
172
+ });
173
+ }
174
+ return {};
175
+ }
176
+ return { continue: true };
177
+ }
@@ -0,0 +1,28 @@
1
+ export declare const HYGIENE_LABEL = "co.amem.hygiene";
2
+ export declare const HYGIENE_SYSTEMD_SERVICE = "amem-hygiene.service";
3
+ export declare const HYGIENE_SYSTEMD_TIMER = "amem-hygiene.timer";
4
+ export type HygieneScheduleOpts = {
5
+ /** Hour of day local time 0–23 (default 4). */
6
+ hour?: number;
7
+ };
8
+ export declare function hygieneLaunchAgentPath(): string;
9
+ export declare function hygieneSystemdServicePath(): string;
10
+ export declare function hygieneSystemdTimerPath(): string;
11
+ export declare function hygieneWindowsStartupPath(): string;
12
+ export declare function isHygieneScheduleInstalled(): boolean;
13
+ export declare function hygieneSchedulePath(): string;
14
+ export declare function installHygieneSchedule(opts?: HygieneScheduleOpts): {
15
+ path: string;
16
+ platform: string;
17
+ hour: number;
18
+ };
19
+ export declare function uninstallHygieneSchedule(): {
20
+ path: string;
21
+ platform: string;
22
+ };
23
+ export declare function writeHygieneHelperScript(): string;
24
+ export declare function hygieneScheduleStatus(): {
25
+ installed: boolean;
26
+ path: string;
27
+ platform: string;
28
+ };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Schedule weekly local hygiene (decay + merge). Pro/IT at run time.
3
+ * Uses LaunchAgent (macOS), systemd user timer (Linux), or Startup cmd (Windows).
4
+ */
5
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import { amemHome } from "./paths.js";
10
+ import { resolveAmemProgramArgs } from "./install/skills.js";
11
+ export const HYGIENE_LABEL = "co.amem.hygiene";
12
+ export const HYGIENE_SYSTEMD_SERVICE = "amem-hygiene.service";
13
+ export const HYGIENE_SYSTEMD_TIMER = "amem-hygiene.timer";
14
+ function escapeXml(value) {
15
+ return value
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
21
+ function hygieneArgs() {
22
+ return resolveAmemProgramArgs("hygiene", "--scheduled");
23
+ }
24
+ function shellQuote(args) {
25
+ return args.map((a) => (/\s/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ");
26
+ }
27
+ export function hygieneLaunchAgentPath() {
28
+ return join(homedir(), "Library", "LaunchAgents", `${HYGIENE_LABEL}.plist`);
29
+ }
30
+ export function hygieneSystemdServicePath() {
31
+ return join(homedir(), ".config", "systemd", "user", HYGIENE_SYSTEMD_SERVICE);
32
+ }
33
+ export function hygieneSystemdTimerPath() {
34
+ return join(homedir(), ".config", "systemd", "user", HYGIENE_SYSTEMD_TIMER);
35
+ }
36
+ export function hygieneWindowsStartupPath() {
37
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
38
+ return join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "amem-hygiene.cmd");
39
+ }
40
+ export function isHygieneScheduleInstalled() {
41
+ if (process.platform === "darwin")
42
+ return existsSync(hygieneLaunchAgentPath());
43
+ if (process.platform === "linux")
44
+ return existsSync(hygieneSystemdTimerPath());
45
+ if (process.platform === "win32")
46
+ return existsSync(hygieneWindowsStartupPath());
47
+ return false;
48
+ }
49
+ export function hygieneSchedulePath() {
50
+ if (process.platform === "darwin")
51
+ return hygieneLaunchAgentPath();
52
+ if (process.platform === "linux")
53
+ return hygieneSystemdTimerPath();
54
+ if (process.platform === "win32")
55
+ return hygieneWindowsStartupPath();
56
+ return "";
57
+ }
58
+ function uid() {
59
+ return String(process.getuid?.() ?? "");
60
+ }
61
+ export function installHygieneSchedule(opts = {}) {
62
+ const hour = Math.min(23, Math.max(0, opts.hour ?? 4));
63
+ const args = hygieneArgs();
64
+ if (process.platform === "darwin") {
65
+ const path = hygieneLaunchAgentPath();
66
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
67
+ const argXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
68
+ // Weekly: Sunday at hour:20
69
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
70
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
71
+ <plist version="1.0">
72
+ <dict>
73
+ <key>Label</key>
74
+ <string>${HYGIENE_LABEL}</string>
75
+ <key>ProgramArguments</key>
76
+ <array>
77
+ ${argXml}
78
+ </array>
79
+ <key>StartCalendarInterval</key>
80
+ <dict>
81
+ <key>Weekday</key>
82
+ <integer>0</integer>
83
+ <key>Hour</key>
84
+ <integer>${hour}</integer>
85
+ <key>Minute</key>
86
+ <integer>20</integer>
87
+ </dict>
88
+ <key>StandardOutPath</key>
89
+ <string>${escapeXml(join(amemHome(), "hygiene.log"))}</string>
90
+ <key>StandardErrorPath</key>
91
+ <string>${escapeXml(join(amemHome(), "hygiene.err.log"))}</string>
92
+ <key>EnvironmentVariables</key>
93
+ <dict>
94
+ <key>PATH</key>
95
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
96
+ </dict>
97
+ </dict>
98
+ </plist>
99
+ `;
100
+ writeFileSync(path, body, "utf8");
101
+ try {
102
+ execFileSync("launchctl", ["bootout", `gui/${uid()}`, path], {
103
+ stdio: ["ignore", "pipe", "pipe"],
104
+ });
105
+ }
106
+ catch {
107
+ // not loaded
108
+ }
109
+ try {
110
+ execFileSync("launchctl", ["bootstrap", `gui/${uid()}`, path], {
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ });
113
+ }
114
+ catch {
115
+ // next login picks it up
116
+ }
117
+ return { path, platform: "darwin", hour };
118
+ }
119
+ if (process.platform === "linux") {
120
+ const unitDir = join(homedir(), ".config", "systemd", "user");
121
+ mkdirSync(unitDir, { recursive: true });
122
+ const svc = hygieneSystemdServicePath();
123
+ const timer = hygieneSystemdTimerPath();
124
+ writeFileSync(svc, `[Unit]
125
+ Description=amem local memory hygiene
126
+ [Service]
127
+ Type=oneshot
128
+ ExecStart=${shellQuote(args)}
129
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homedir()}/.local/bin
130
+ `, "utf8");
131
+ writeFileSync(timer, `[Unit]
132
+ Description=Weekly amem memory hygiene
133
+ [Timer]
134
+ OnCalendar=Sun *-*-* ${String(hour).padStart(2, "0")}:20:00
135
+ Persistent=true
136
+ [Install]
137
+ WantedBy=timers.target
138
+ `, "utf8");
139
+ try {
140
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
141
+ execFileSync("systemctl", ["--user", "enable", "--now", HYGIENE_SYSTEMD_TIMER], {
142
+ stdio: "ignore",
143
+ });
144
+ }
145
+ catch {
146
+ // unit files are on disk
147
+ }
148
+ return { path: timer, platform: "linux", hour };
149
+ }
150
+ if (process.platform === "win32") {
151
+ const path = hygieneWindowsStartupPath();
152
+ mkdirSync(join(path, ".."), { recursive: true });
153
+ writeFileSync(path, `@echo off\r\nrem amem weekly hygiene (runs at login; Pro/IT required)\r\n${shellQuote(args)}\r\n`, "utf8");
154
+ return { path, platform: "win32", hour };
155
+ }
156
+ throw new Error(`Hygiene scheduling not supported on ${process.platform}`);
157
+ }
158
+ export function uninstallHygieneSchedule() {
159
+ if (process.platform === "darwin") {
160
+ const path = hygieneLaunchAgentPath();
161
+ if (existsSync(path)) {
162
+ try {
163
+ execFileSync("launchctl", ["bootout", `gui/${uid()}`, path], {
164
+ stdio: ["ignore", "pipe", "pipe"],
165
+ });
166
+ }
167
+ catch {
168
+ // ignore
169
+ }
170
+ unlinkSync(path);
171
+ }
172
+ return { path, platform: "darwin" };
173
+ }
174
+ if (process.platform === "linux") {
175
+ const timer = hygieneSystemdTimerPath();
176
+ const svc = hygieneSystemdServicePath();
177
+ try {
178
+ execFileSync("systemctl", ["--user", "disable", "--now", HYGIENE_SYSTEMD_TIMER], {
179
+ stdio: "ignore",
180
+ });
181
+ }
182
+ catch {
183
+ // ignore
184
+ }
185
+ if (existsSync(timer))
186
+ unlinkSync(timer);
187
+ if (existsSync(svc))
188
+ unlinkSync(svc);
189
+ return { path: timer, platform: "linux" };
190
+ }
191
+ if (process.platform === "win32") {
192
+ const path = hygieneWindowsStartupPath();
193
+ if (existsSync(path))
194
+ unlinkSync(path);
195
+ return { path, platform: "win32" };
196
+ }
197
+ throw new Error(`Hygiene scheduling not supported on ${process.platform}`);
198
+ }
199
+ export function writeHygieneHelperScript() {
200
+ const script = join(amemHome(), "bin", "amem-hygiene.sh");
201
+ mkdirSync(join(amemHome(), "bin"), { recursive: true, mode: 0o700 });
202
+ const body = `#!/usr/bin/env bash
203
+ set -euo pipefail
204
+ exec ${shellQuote(hygieneArgs())}
205
+ `;
206
+ writeFileSync(script, body, { mode: 0o700 });
207
+ try {
208
+ chmodSync(script, 0o700);
209
+ }
210
+ catch {
211
+ // ignore
212
+ }
213
+ return script;
214
+ }
215
+ export function hygieneScheduleStatus() {
216
+ return {
217
+ installed: isHygieneScheduleInstalled(),
218
+ path: hygieneSchedulePath(),
219
+ platform: process.platform,
220
+ };
221
+ }
@@ -0,0 +1,61 @@
1
+ /**
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.
5
+ */
6
+ import { type ClaimRow } from "./db.js";
7
+ export type HygieneDuplicate = {
8
+ keepId: string;
9
+ dropId: string;
10
+ similarity: number;
11
+ };
12
+ export type HygieneReport = {
13
+ stale: ClaimRow[];
14
+ duplicates: HygieneDuplicate[];
15
+ pendingDrafts: number;
16
+ active: number;
17
+ };
18
+ export type HygienePreview = {
19
+ active: number;
20
+ staleCount: number;
21
+ duplicateCount: number;
22
+ pendingDrafts: number;
23
+ /** Estimated active count after decay + merge */
24
+ afterCleanup: number;
25
+ softPaywall: boolean;
26
+ };
27
+ export declare const SOFT_PAYWALL_FACTS = 200;
28
+ export declare const SOFT_PAYWALL_NOISE = 15;
29
+ /** Free: counts only for soft paywall / banners. Never applies changes. */
30
+ export declare function hygienePreview(repoId: string, unusedDays?: number): HygienePreview;
31
+ /** Aggregate preview across all tracked repos (for All memory scope). */
32
+ export declare function hygienePreviewAll(unusedDays?: number): HygienePreview;
33
+ export declare function hygieneReport(repoId: string, unusedDays?: number): HygieneReport;
34
+ export declare function decayStaleClaims(repoId: string, unusedDays?: number): {
35
+ decayed: string[];
36
+ };
37
+ export declare function mergeDuplicate(repoId: string, keepId: string, dropId: string): {
38
+ keepId: string;
39
+ dropId: string;
40
+ };
41
+ /** Decay unused + merge near-duplicates in one step (Pro/IT). */
42
+ export declare function acceptSafeCleanups(repoId: string, unusedDays?: number): {
43
+ decayed: string[];
44
+ merged: Array<{
45
+ keepId: string;
46
+ dropId: string;
47
+ similarity: number;
48
+ }>;
49
+ };
50
+ /** Scheduled job: clean every tracked repo when licensed. */
51
+ export declare function runScheduledHygiene(unusedDays?: number): {
52
+ skipped?: boolean;
53
+ reason?: string;
54
+ repos: Array<{
55
+ repoId: string;
56
+ name: string;
57
+ decayed: number;
58
+ merged: number;
59
+ error?: string;
60
+ }>;
61
+ };