@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.
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/dist/activity.d.ts +27 -0
- package/dist/activity.js +202 -0
- package/dist/api/routes.d.ts +31 -0
- package/dist/api/routes.js +1345 -0
- package/dist/attest.d.ts +52 -0
- package/dist/attest.js +192 -0
- package/dist/backup-schedule.d.ts +25 -0
- package/dist/backup-schedule.js +216 -0
- package/dist/capture.d.ts +34 -0
- package/dist/capture.js +257 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1007 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +334 -0
- package/dist/crypto.d.ts +39 -0
- package/dist/crypto.js +166 -0
- package/dist/db.d.ts +192 -0
- package/dist/db.js +666 -0
- package/dist/draft-quality.d.ts +19 -0
- package/dist/draft-quality.js +85 -0
- package/dist/embed.d.ts +76 -0
- package/dist/embed.js +331 -0
- package/dist/estimate.d.ts +32 -0
- package/dist/estimate.js +69 -0
- package/dist/freshness.d.ts +15 -0
- package/dist/freshness.js +93 -0
- package/dist/hook.d.ts +16 -0
- package/dist/hook.js +177 -0
- package/dist/hygiene-schedule.d.ts +28 -0
- package/dist/hygiene-schedule.js +221 -0
- package/dist/hygiene.d.ts +61 -0
- package/dist/hygiene.js +196 -0
- package/dist/install/claude.d.ts +6 -0
- package/dist/install/claude.js +69 -0
- package/dist/install/cursor.d.ts +7 -0
- package/dist/install/cursor.js +80 -0
- package/dist/install/hosts.d.ts +21 -0
- package/dist/install/hosts.js +186 -0
- package/dist/install/skills.d.ts +8 -0
- package/dist/install/skills.js +67 -0
- package/dist/it-pack.d.ts +20 -0
- package/dist/it-pack.js +84 -0
- package/dist/kinds.d.ts +18 -0
- package/dist/kinds.js +106 -0
- package/dist/license.d.ts +48 -0
- package/dist/license.js +172 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +435 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +55 -0
- package/dist/personal.d.ts +7 -0
- package/dist/personal.js +44 -0
- package/dist/platforms.d.ts +11 -0
- package/dist/platforms.js +32 -0
- package/dist/policy.d.ts +46 -0
- package/dist/policy.js +254 -0
- package/dist/prefs.d.ts +6 -0
- package/dist/prefs.js +11 -0
- package/dist/proposal.d.ts +90 -0
- package/dist/proposal.js +376 -0
- package/dist/publish.d.ts +28 -0
- package/dist/publish.js +57 -0
- package/dist/remember-contract.d.ts +23 -0
- package/dist/remember-contract.js +117 -0
- package/dist/repo-identity.d.ts +15 -0
- package/dist/repo-identity.js +82 -0
- package/dist/rules-sync.d.ts +7 -0
- package/dist/rules-sync.js +47 -0
- package/dist/savings-export.d.ts +49 -0
- package/dist/savings-export.js +141 -0
- package/dist/scan.d.ts +16 -0
- package/dist/scan.js +109 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.js +150 -0
- package/dist/service.d.ts +20 -0
- package/dist/service.js +254 -0
- package/dist/shop.d.ts +9 -0
- package/dist/shop.js +15 -0
- package/dist/ui/server.d.ts +21 -0
- package/dist/ui/server.js +268 -0
- package/dist/vault.d.ts +25 -0
- package/dist/vault.js +42 -0
- package/dist/workspace-setup.d.ts +8 -0
- package/dist/workspace-setup.js +55 -0
- package/docs/agent-install-prompt.md +41 -0
- package/docs/backlog.md +59 -0
- package/docs/enterprise-endpoint.md +98 -0
- package/docs/license.md +54 -0
- package/docs/npm-release.md +38 -0
- package/docs/remember-contract.md +42 -0
- package/package.json +64 -0
- package/scripts/mdm-offboard.sh +14 -0
- package/skills/amem-bootstrap/SKILL.md +83 -0
- package/skills/amem-update-working-memory/SKILL.md +54 -0
- package/templates/cursor-rule.mdc +23 -0
- package/templates/mdm/co.amem.managed.plist +33 -0
- package/templates/policy.deny-default.toml +20 -0
- package/templates/policy.example.toml +22 -0
- package/ui-static/app.js +3966 -0
- package/ui-static/index.html +178 -0
- package/ui-static/orbit.js +389 -0
- 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, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """);
|
|
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
|
+
};
|