@iamem/amem 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/routes.js +6 -2
- package/dist/capture.js +14 -5
- package/dist/cli.js +50 -4
- package/dist/estimate.d.ts +25 -1
- package/dist/estimate.js +36 -3
- package/dist/freshness.d.ts +7 -0
- package/dist/freshness.js +8 -1
- package/dist/hygiene.d.ts +25 -0
- package/dist/hygiene.js +41 -1
- package/dist/install/hosts.d.ts +15 -0
- package/dist/install/hosts.js +74 -4
- package/dist/kinds.d.ts +18 -0
- package/dist/kinds.js +80 -3
- package/dist/platforms.js +6 -0
- package/dist/remember-contract.js +13 -4
- package/dist/repo-identity.d.ts +10 -0
- package/dist/repo-identity.js +20 -1
- package/package.json +2 -1
- package/scripts/mcp-launch.sh +26 -0
- package/ui-static/app.js +23 -6
- package/ui-static/styles.css +11 -1
package/dist/api/routes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { estimateUsdSaved, metricsFromPacket, USD_PER_MILLION_INPUT_TOKENS } from "../estimate.js";
|
|
4
|
+
import { estimateUsdSaved, metricsFromPacket, savingsBasis, USD_PER_MILLION_INPUT_TOKENS, } from "../estimate.js";
|
|
5
5
|
import { buildActivityGraph, speedForEvent } from "../activity.js";
|
|
6
6
|
import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, listTasks, listTasksAll, getSkillDraft, insertSkillDraft, listSkillDrafts, recordSkillUse, setSkillDraftStatus, setSkillRepo, findTaskAnyRepo, getTask, insertTask, updateTask, completeTask, deleteTask, countTasks, countTasksAll, normalizeTaskStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
|
|
7
7
|
import { deleteSkill, findSkillOnDisk, isValidSkillName, listIndexedSkills, listSkillAssets, rankSkills, readSkillAsset, readSkillBody, renderSkillMarkdown, scanSkillContent, skillsDir, slugifySkillName, syncSkillIndex, writeSkill, } from "../skills.js";
|
|
@@ -358,10 +358,14 @@ function aggregateUsage(events, windowDays = 30) {
|
|
|
358
358
|
}
|
|
359
359
|
const queries = events.length;
|
|
360
360
|
const estimatedTokensSaved = events.reduce((s, e) => s + e.estimated_tokens_saved, 0);
|
|
361
|
+
const reportedTokensSaved = events.reduce((s, e) => s + (e.reported_tokens_saved ?? 0), 0);
|
|
361
362
|
return {
|
|
362
363
|
pricing: {
|
|
363
364
|
usdPerMillionInputTokens: USD_PER_MILLION_INPUT_TOKENS,
|
|
364
365
|
basis: "input",
|
|
366
|
+
// How much to trust the numbers below. Until reported savings exist,
|
|
367
|
+
// every "saved" figure is a model, and callers must say so.
|
|
368
|
+
...savingsBasis(reportedTokensSaved),
|
|
365
369
|
},
|
|
366
370
|
byPlatform: Object.values(byPlatform).map((p) => ({
|
|
367
371
|
...p,
|
|
@@ -378,7 +382,7 @@ function aggregateUsage(events, windowDays = 30) {
|
|
|
378
382
|
queries,
|
|
379
383
|
estimatedTokensSaved,
|
|
380
384
|
estimatedUsdSaved: estimateUsdSaved(estimatedTokensSaved),
|
|
381
|
-
reportedTokensSaved
|
|
385
|
+
reportedTokensSaved,
|
|
382
386
|
estimatedMsSaved,
|
|
383
387
|
localHits,
|
|
384
388
|
serverTrips,
|
package/dist/capture.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { PLACEHOLDER_ANCHOR } from "./freshness.js";
|
|
4
5
|
import { insertProposalDraft, listProposalDrafts, listProposalDraftsAll, listUsageEvents, setProposalDraftStatus, } from "./db.js";
|
|
5
|
-
import { compactClaimText, compactFromNotes, inferClaimKind, isDurableCapture } from "./kinds.js";
|
|
6
|
+
import { compactClaimText, compactFromNotes, inferClaimKind, isDurableCapture, isFactLike, } from "./kinds.js";
|
|
6
7
|
import { loadPolicy } from "./policy.js";
|
|
7
8
|
import { applyProposal } from "./proposal.js";
|
|
8
9
|
import { scoreProposal } from "./draft-quality.js";
|
|
@@ -49,12 +50,20 @@ function buildClaimDraft(input) {
|
|
|
49
50
|
}
|
|
50
51
|
const anchors = extractCaptureAnchors(`${input.prompt}\n${input.answer ?? ""}`, input.repoRoot);
|
|
51
52
|
const kind = input.forceKind ?? inferClaimKind(input.prompt, input.answer ?? "");
|
|
52
|
-
const usableAnchors = anchors.length > 0
|
|
53
|
+
const usableAnchors = anchors.length > 0
|
|
54
|
+
? anchors
|
|
55
|
+
: kind === "constraint" || kind === "gotcha"
|
|
56
|
+
? [PLACEHOLDER_ANCHOR]
|
|
57
|
+
: [];
|
|
53
58
|
if (!isDurableCapture(input.prompt, input.answer, usableAnchors.length)) {
|
|
54
59
|
if (usableAnchors.length === 0)
|
|
55
60
|
return null;
|
|
56
61
|
}
|
|
57
62
|
const text = compactClaimText(input.prompt, input.answer);
|
|
63
|
+
// Single choke point for every capture path. Conversational residue never
|
|
64
|
+
// becomes a durable claim, however many file paths it happens to mention.
|
|
65
|
+
if (!isFactLike(text))
|
|
66
|
+
return null;
|
|
58
67
|
const id = `${input.idPrefix}_${createHash("sha256").update(text).digest("hex").slice(0, 12)}`;
|
|
59
68
|
return {
|
|
60
69
|
id,
|
|
@@ -85,7 +94,7 @@ export function shouldAutoApplyProposal(proposal) {
|
|
|
85
94
|
return false;
|
|
86
95
|
if (!DURABLE_AUTO_KINDS.has((claim.kind || "").toLowerCase()))
|
|
87
96
|
return false;
|
|
88
|
-
const anchors = (claim.code_anchors ?? []).filter((a) => a && a !==
|
|
97
|
+
const anchors = (claim.code_anchors ?? []).filter((a) => a && a !== PLACEHOLDER_ANCHOR);
|
|
89
98
|
return anchors.length > 0;
|
|
90
99
|
}
|
|
91
100
|
function maybeAutoApplyDraft(repoId, draft, proposal) {
|
|
@@ -195,7 +204,7 @@ function extraSessionFacts(input, skipId) {
|
|
|
195
204
|
const sentences = answer
|
|
196
205
|
.split(/(?<=[.!?])\s+/)
|
|
197
206
|
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
198
|
-
.filter((s) => s.length >= 48 && [...s.matchAll(PATH_RE)].length > 0);
|
|
207
|
+
.filter((s) => s.length >= 48 && [...s.matchAll(PATH_RE)].length > 0 && isFactLike(s));
|
|
199
208
|
const out = [];
|
|
200
209
|
for (const sentence of sentences.slice(0, 3)) {
|
|
201
210
|
const built = buildClaimDraft({
|
|
@@ -251,7 +260,7 @@ export function captureMissLearnDraft(input) {
|
|
|
251
260
|
});
|
|
252
261
|
if (!built)
|
|
253
262
|
return null;
|
|
254
|
-
const realAnchors = built.anchors.filter((a) => a !==
|
|
263
|
+
const realAnchors = built.anchors.filter((a) => a !== PLACEHOLDER_ANCHOR);
|
|
255
264
|
if (realAnchors.length === 0)
|
|
256
265
|
return null;
|
|
257
266
|
built.proposal.claims[0].code_anchors = realAnchors;
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { installHost } from "./install/hosts.js";
|
|
|
11
11
|
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
|
-
import { detectRepoIdentity, parseWorkspaceSlug, workspaceIdentity } from "./repo-identity.js";
|
|
14
|
+
import { detectRepoIdentity, parseWorkspaceSlug, unbindableRootReason, workspaceIdentity, } from "./repo-identity.js";
|
|
15
15
|
import { runDesktopApp } from "./app-shell.js";
|
|
16
16
|
import { startUiServer, buildUiLandingUrl, openUiInBrowser, isAddrInUse, probeUiHealth, } from "./ui/server.js";
|
|
17
17
|
import { installLoginService, isServiceInstalled, isServiceSupported, uninstallLoginService } from "./service.js";
|
|
@@ -25,7 +25,7 @@ import { rememberContract } from "./remember-contract.js";
|
|
|
25
25
|
import { vaultStatus } from "./vault.js";
|
|
26
26
|
import { applyLicenseFile, clearLicense, generateLicenseKeys, licenseStatus, signLicense, } from "./license.js";
|
|
27
27
|
import { embedIndexIssues, embedStatus, reindexAllEmbeds, setEmbedBackend, } from "./embed.js";
|
|
28
|
-
import { acceptSafeCleanups, decayStaleClaims, hygieneReport, mergeDuplicate, runScheduledHygiene } from "./hygiene.js";
|
|
28
|
+
import { acceptSafeCleanups, decayStaleClaims, hygieneReport, mergeDuplicate, purgeNonFactClaims, runScheduledHygiene, } from "./hygiene.js";
|
|
29
29
|
import { hygieneSchedulePath, installHygieneSchedule, isHygieneScheduleInstalled, uninstallHygieneSchedule, writeHygieneHelperScript, } from "./hygiene-schedule.js";
|
|
30
30
|
import { syncPinnedRules } from "./rules-sync.js";
|
|
31
31
|
import { listSkillDrafts } from "./db.js";
|
|
@@ -36,7 +36,7 @@ function usage() {
|
|
|
36
36
|
|
|
37
37
|
Usage:
|
|
38
38
|
amem setup [--personal] [--platform <host>]
|
|
39
|
-
amem init --platform cursor|claude|windsurf|continue|aider|zed
|
|
39
|
+
amem init --platform cursor|claude|claude-desktop|windsurf|continue|aider|zed
|
|
40
40
|
amem init --workspace <name> [--path <dir>] [--platform …]
|
|
41
41
|
amem init --personal
|
|
42
42
|
amem rename "<display name>" --workspace <slug>
|
|
@@ -63,6 +63,7 @@ Usage:
|
|
|
63
63
|
amem backup unschedule
|
|
64
64
|
amem restore --file <backup.db|backup.db.enc> [--passphrase <secret>]
|
|
65
65
|
amem hygiene [--days 90] [--decay] [--accept-safe] [--merge <keepId> <dropId>]
|
|
66
|
+
amem hygiene --purge-nonfacts [--all] [--apply] # default is a dry run
|
|
66
67
|
amem hygiene --scheduled
|
|
67
68
|
amem hygiene schedule [--hour <0-23>]
|
|
68
69
|
amem hygiene unschedule
|
|
@@ -230,11 +231,29 @@ async function main() {
|
|
|
230
231
|
}
|
|
231
232
|
const platform = flagString(flags, "platform");
|
|
232
233
|
if (!platform) {
|
|
233
|
-
throw new Error("amem init requires --platform cursor|claude|windsurf|continue|aider|zed, --workspace <name>, or --personal");
|
|
234
|
+
throw new Error("amem init requires --platform cursor|claude|claude-desktop|windsurf|continue|aider|zed, --workspace <name>, or --personal");
|
|
234
235
|
}
|
|
235
236
|
const policy = loadPolicy().policy;
|
|
236
237
|
assertPlatformAllowed(platform, policy);
|
|
238
|
+
// Claude Desktop, Windsurf, Continue and Zed read a single machine-wide
|
|
239
|
+
// MCP config. There is nothing repo-specific to bind, and binding the
|
|
240
|
+
// cwd here is exactly how running this from a home directory
|
|
241
|
+
// registered the whole home as a repo.
|
|
242
|
+
if (HOST_INSTALL_IDS.has(platform) && platform !== "aider") {
|
|
243
|
+
const hostInfo = installHost(platform, { workspace: PERSONAL_SLUG });
|
|
244
|
+
console.log(`Configured ${hostInfo.host} (machine-wide, no repo binding)`);
|
|
245
|
+
for (const cfgPath of hostInfo.paths)
|
|
246
|
+
console.log(`Config: ${cfgPath}`);
|
|
247
|
+
for (const note of hostInfo.notes)
|
|
248
|
+
console.log(`Note: ${note}`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
237
251
|
const identity = detectRepoIdentity();
|
|
252
|
+
const unbindable = unbindableRootReason(identity.rootPath);
|
|
253
|
+
if (unbindable) {
|
|
254
|
+
throw new Error(`Refusing to bind ${unbindable} (${identity.rootPath}) as a repo. ` +
|
|
255
|
+
"cd into a project first, or use: amem init --workspace <name>");
|
|
256
|
+
}
|
|
238
257
|
assertRemoteAllowed(identity.remoteUrl, policy);
|
|
239
258
|
const repo = upsertRepo(identity, platform);
|
|
240
259
|
let installInfo = { skills: [] };
|
|
@@ -655,6 +674,33 @@ async function main() {
|
|
|
655
674
|
}
|
|
656
675
|
break;
|
|
657
676
|
}
|
|
677
|
+
if (flags.get("purge-nonfacts")) {
|
|
678
|
+
const all = Boolean(flags.get("all"));
|
|
679
|
+
const scope = all ? {} : { repoId: resolveBinding(flags).id };
|
|
680
|
+
// Dry run unless --apply is passed: deletion is not reversible.
|
|
681
|
+
const preview = purgeNonFactClaims({ ...scope, dryRun: true });
|
|
682
|
+
const byReason = new Map();
|
|
683
|
+
for (const row of preview.matched) {
|
|
684
|
+
byReason.set(row.reason, (byReason.get(row.reason) ?? 0) + 1);
|
|
685
|
+
}
|
|
686
|
+
console.log(`Scanned ${preview.scanned} claim(s)${all ? " across every repo" : ""}; ${preview.matched.length} look like clear junk.`);
|
|
687
|
+
for (const [reason, n] of [...byReason].sort((a, b) => b[1] - a[1])) {
|
|
688
|
+
console.log(` ${reason}: ${n}`);
|
|
689
|
+
}
|
|
690
|
+
for (const row of preview.matched.slice(0, 8)) {
|
|
691
|
+
console.log(` - [${row.repoName}] ${row.reason}: ${row.preview}`);
|
|
692
|
+
}
|
|
693
|
+
if (preview.matched.length > 8) {
|
|
694
|
+
console.log(` … and ${preview.matched.length - 8} more`);
|
|
695
|
+
}
|
|
696
|
+
if (!flags.get("apply")) {
|
|
697
|
+
console.log("Dry run. Re-run with --apply to delete. Back up first: amem backup");
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
const result = purgeNonFactClaims({ ...scope, dryRun: false });
|
|
701
|
+
console.log(`Deleted ${result.deleted} claim(s).`);
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
658
704
|
const repo = resolveBinding(flags);
|
|
659
705
|
const days = Number(flagString(flags, "days") ?? "90");
|
|
660
706
|
const keep = flagString(flags, "merge");
|
package/dist/estimate.d.ts
CHANGED
|
@@ -2,14 +2,38 @@ import type { ContextPacket } from "./context.js";
|
|
|
2
2
|
/** Rough chars→tokens. */
|
|
3
3
|
export declare function estimateTokensFromText(text: string): number;
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Assumed cost of one file the agent did not have to open. This is a MODELLED
|
|
6
|
+
* constant, not a measurement: it credits a saving whether or not the agent
|
|
7
|
+
* would actually have read that file. It dominates the headline number, so
|
|
8
|
+
* treat any total built from it as an upper bound until calibrated against
|
|
9
|
+
* real reported savings (`amem usage report --saved <n>`).
|
|
10
|
+
*/
|
|
11
|
+
export declare const ASSUMED_TOKENS_PER_FILE = 4000;
|
|
12
|
+
export declare const ASSUMED_TOKENS_PER_CLAIM = 200;
|
|
13
|
+
/**
|
|
14
|
+
* Proxy for exploration avoided, net of what the packet itself cost:
|
|
6
15
|
* anchors_returned * 4000 + claims_returned * 200 - packet_tokens
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT clamped at zero. A packet that returns little and still
|
|
18
|
+
* costs input tokens is a net loss, and the metric has to be able to say so —
|
|
19
|
+
* clamping made the dashboard structurally incapable of reporting that amem
|
|
20
|
+
* ever cost anything, which is not a property you want in your own numbers.
|
|
7
21
|
*/
|
|
8
22
|
export declare function estimateTokensSaved(input: {
|
|
9
23
|
anchorsCount: number;
|
|
10
24
|
claimsCount: number;
|
|
11
25
|
packetTokens: number;
|
|
12
26
|
}): number;
|
|
27
|
+
/**
|
|
28
|
+
* How a savings figure should be presented. "measured" only once real reported
|
|
29
|
+
* savings exist; until then the number is a model and must be labelled as one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function savingsBasis(reportedTokensSaved: number): {
|
|
32
|
+
savingsBasis: "measured" | "modelled";
|
|
33
|
+
calibrated: boolean;
|
|
34
|
+
assumedTokensPerFile: number;
|
|
35
|
+
assumedTokensPerClaim: number;
|
|
36
|
+
};
|
|
13
37
|
/** Typical Cursor/Claude tool round-trip to read a file (~1.2s) plus a little per claim. */
|
|
14
38
|
export declare const MS_PER_FILE_ROUNDTRIP = 1200;
|
|
15
39
|
export declare const MS_PER_CLAIM = 80;
|
package/dist/estimate.js
CHANGED
|
@@ -3,11 +3,42 @@ export function estimateTokensFromText(text) {
|
|
|
3
3
|
return Math.max(1, Math.ceil(text.length / 4));
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Assumed cost of one file the agent did not have to open. This is a MODELLED
|
|
7
|
+
* constant, not a measurement: it credits a saving whether or not the agent
|
|
8
|
+
* would actually have read that file. It dominates the headline number, so
|
|
9
|
+
* treat any total built from it as an upper bound until calibrated against
|
|
10
|
+
* real reported savings (`amem usage report --saved <n>`).
|
|
11
|
+
*/
|
|
12
|
+
export const ASSUMED_TOKENS_PER_FILE = 4000;
|
|
13
|
+
export const ASSUMED_TOKENS_PER_CLAIM = 200;
|
|
14
|
+
/**
|
|
15
|
+
* Proxy for exploration avoided, net of what the packet itself cost:
|
|
7
16
|
* anchors_returned * 4000 + claims_returned * 200 - packet_tokens
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT clamped at zero. A packet that returns little and still
|
|
19
|
+
* costs input tokens is a net loss, and the metric has to be able to say so —
|
|
20
|
+
* clamping made the dashboard structurally incapable of reporting that amem
|
|
21
|
+
* ever cost anything, which is not a property you want in your own numbers.
|
|
8
22
|
*/
|
|
9
23
|
export function estimateTokensSaved(input) {
|
|
10
|
-
return
|
|
24
|
+
return (input.anchorsCount * ASSUMED_TOKENS_PER_FILE +
|
|
25
|
+
input.claimsCount * ASSUMED_TOKENS_PER_CLAIM -
|
|
26
|
+
input.packetTokens);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How a savings figure should be presented. "measured" only once real reported
|
|
30
|
+
* savings exist; until then the number is a model and must be labelled as one.
|
|
31
|
+
*/
|
|
32
|
+
export function savingsBasis(reportedTokensSaved) {
|
|
33
|
+
const calibrated = Number(reportedTokensSaved) > 0;
|
|
34
|
+
return {
|
|
35
|
+
// Distinct from pricing.basis ("input"), which is about which side of the
|
|
36
|
+
// token bill is being priced, not about how trustworthy the figure is.
|
|
37
|
+
savingsBasis: calibrated ? "measured" : "modelled",
|
|
38
|
+
calibrated,
|
|
39
|
+
assumedTokensPerFile: ASSUMED_TOKENS_PER_FILE,
|
|
40
|
+
assumedTokensPerClaim: ASSUMED_TOKENS_PER_CLAIM,
|
|
41
|
+
};
|
|
11
42
|
}
|
|
12
43
|
/** Typical Cursor/Claude tool round-trip to read a file (~1.2s) plus a little per claim. */
|
|
13
44
|
export const MS_PER_FILE_ROUNDTRIP = 1200;
|
|
@@ -18,7 +49,9 @@ export function estimateMsSaved(input) {
|
|
|
18
49
|
/** Mid-range frontier *input* $/1M tokens (Sonnet-class). Avoided exploration is input-side. Not a bill. */
|
|
19
50
|
export const USD_PER_MILLION_INPUT_TOKENS = 3;
|
|
20
51
|
export function estimateUsdSaved(tokens, usdPerMillion = USD_PER_MILLION_INPUT_TOKENS) {
|
|
21
|
-
|
|
52
|
+
// Unclamped for the same reason as estimateTokensSaved: a negative here is
|
|
53
|
+
// real information, not an error to be hidden.
|
|
54
|
+
return (Number(tokens) / 1_000_000) * usdPerMillion;
|
|
22
55
|
}
|
|
23
56
|
export function eventKind(claimsCount, notesCount = 0) {
|
|
24
57
|
return claimsCount > 0 || notesCount > 0 ? "local_hit" : "server_trip";
|
package/dist/freshness.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export type ClaimFreshness = {
|
|
|
5
5
|
staleAnchors: string[];
|
|
6
6
|
missingAnchors: string[];
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* capture.ts falls back to this when it cannot extract a real file anchor.
|
|
10
|
+
* README churns constantly, so honouring it as a real anchor marks those claims
|
|
11
|
+
* stale forever and drowns the genuine staleness signal. Treat it as no anchor.
|
|
12
|
+
* (capture.ts already filters it out of "does this claim have real anchors".)
|
|
13
|
+
*/
|
|
14
|
+
export declare const PLACEHOLDER_ANCHOR = "README.md";
|
|
8
15
|
export declare function parseAnchors(codeAnchorsJson: string): string[];
|
|
9
16
|
/**
|
|
10
17
|
* Compare claim.updated_at to filesystem mtimes of code_anchors.
|
package/dist/freshness.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* capture.ts falls back to this when it cannot extract a real file anchor.
|
|
5
|
+
* README churns constantly, so honouring it as a real anchor marks those claims
|
|
6
|
+
* stale forever and drowns the genuine staleness signal. Treat it as no anchor.
|
|
7
|
+
* (capture.ts already filters it out of "does this claim have real anchors".)
|
|
8
|
+
*/
|
|
9
|
+
export const PLACEHOLDER_ANCHOR = "README.md";
|
|
3
10
|
export function parseAnchors(codeAnchorsJson) {
|
|
4
11
|
try {
|
|
5
12
|
const parsed = JSON.parse(codeAnchorsJson);
|
|
@@ -21,7 +28,7 @@ function resolveAnchorPath(rootPath, anchor) {
|
|
|
21
28
|
* Missing paths → missing_anchor; any newer file → stale.
|
|
22
29
|
*/
|
|
23
30
|
export function assessClaimFreshness(rootPath, claim) {
|
|
24
|
-
const anchors = parseAnchors(claim.code_anchors);
|
|
31
|
+
const anchors = parseAnchors(claim.code_anchors).filter((a) => a !== PLACEHOLDER_ANCHOR);
|
|
25
32
|
if (anchors.length === 0) {
|
|
26
33
|
return { status: "unanchored", staleAnchors: [], missingAnchors: [] };
|
|
27
34
|
}
|
package/dist/hygiene.d.ts
CHANGED
|
@@ -67,3 +67,28 @@ export declare function runScheduledHygiene(unusedDays?: number): {
|
|
|
67
67
|
error?: string;
|
|
68
68
|
}>;
|
|
69
69
|
};
|
|
70
|
+
export type NonFactClaim = {
|
|
71
|
+
repoId: string;
|
|
72
|
+
repoName: string;
|
|
73
|
+
id: string;
|
|
74
|
+
kind: string;
|
|
75
|
+
reason: string;
|
|
76
|
+
preview: string;
|
|
77
|
+
};
|
|
78
|
+
/** Stored claims that are clear junk. Pinned claims are never included. */
|
|
79
|
+
export declare function findNonFactClaims(opts?: {
|
|
80
|
+
repoId?: string;
|
|
81
|
+
}): NonFactClaim[];
|
|
82
|
+
/**
|
|
83
|
+
* Delete clear-junk claims. Always run with dryRun first — deletion is not
|
|
84
|
+
* reversible from inside amem, only from a backup.
|
|
85
|
+
*/
|
|
86
|
+
export declare function purgeNonFactClaims(opts?: {
|
|
87
|
+
repoId?: string;
|
|
88
|
+
dryRun?: boolean;
|
|
89
|
+
}): {
|
|
90
|
+
scanned: number;
|
|
91
|
+
matched: NonFactClaim[];
|
|
92
|
+
deleted: number;
|
|
93
|
+
dryRun: boolean;
|
|
94
|
+
};
|
package/dist/hygiene.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Local memory hygiene: decay unused facts, find near-duplicates, review inbox.
|
|
3
3
|
* Completely free and open — runs on-device, nothing uploaded.
|
|
4
4
|
*/
|
|
5
|
-
import { getClaim, listClaims, listProposalDrafts, listRepos, listUsageEvents, setClaimStatus, } from "./db.js";
|
|
5
|
+
import { deleteClaim, getClaim, listClaims, listClaimsAll, listProposalDrafts, listRepos, listUsageEvents, setClaimStatus, } from "./db.js";
|
|
6
6
|
import { FEATURE_HYGIENE, hasFeature, requireFeature } from "./license.js";
|
|
7
7
|
import { applyProposal, applySupersedes } from "./proposal.js";
|
|
8
8
|
import { tokenJaccard } from "./search.js";
|
|
9
9
|
import { parseAnchors } from "./freshness.js";
|
|
10
|
+
import { nonFactReason } from "./kinds.js";
|
|
10
11
|
export const SOFT_PAYWALL_FACTS = 200;
|
|
11
12
|
export const SOFT_PAYWALL_NOISE = 15;
|
|
12
13
|
/** Soft-paywall when session chat takeaways dominate the graph. */
|
|
@@ -251,3 +252,42 @@ export function runScheduledHygiene(unusedDays = 90) {
|
|
|
251
252
|
}
|
|
252
253
|
return { repos };
|
|
253
254
|
}
|
|
255
|
+
/** Stored claims that are clear junk. Pinned claims are never included. */
|
|
256
|
+
export function findNonFactClaims(opts = {}) {
|
|
257
|
+
const names = new Map(listRepos().map((r) => [r.id, r.repo_name]));
|
|
258
|
+
const claims = opts.repoId ? listClaims(opts.repoId) : listClaimsAll();
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const claim of claims) {
|
|
261
|
+
if (claim.pinned)
|
|
262
|
+
continue;
|
|
263
|
+
const reason = nonFactReason(claim.text);
|
|
264
|
+
if (!reason)
|
|
265
|
+
continue;
|
|
266
|
+
out.push({
|
|
267
|
+
repoId: claim.repo_id,
|
|
268
|
+
repoName: names.get(claim.repo_id) ?? claim.repo_id,
|
|
269
|
+
id: claim.id,
|
|
270
|
+
kind: claim.kind,
|
|
271
|
+
reason,
|
|
272
|
+
preview: claim.text.replace(/\s+/g, " ").slice(0, 120),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Delete clear-junk claims. Always run with dryRun first — deletion is not
|
|
279
|
+
* reversible from inside amem, only from a backup.
|
|
280
|
+
*/
|
|
281
|
+
export function purgeNonFactClaims(opts = {}) {
|
|
282
|
+
const dryRun = opts.dryRun !== false;
|
|
283
|
+
const scanned = (opts.repoId ? listClaims(opts.repoId) : listClaimsAll()).length;
|
|
284
|
+
const matched = findNonFactClaims({ repoId: opts.repoId });
|
|
285
|
+
let deleted = 0;
|
|
286
|
+
if (!dryRun) {
|
|
287
|
+
for (const row of matched) {
|
|
288
|
+
if (deleteClaim(row.repoId, row.id))
|
|
289
|
+
deleted += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { scanned, matched, deleted, dryRun };
|
|
293
|
+
}
|
package/dist/install/hosts.d.ts
CHANGED
|
@@ -11,6 +11,21 @@ export declare function installContinue(workspace?: string): HostInstallResult;
|
|
|
11
11
|
export declare function installAider(repoRoot: string): HostInstallResult;
|
|
12
12
|
/** Zed: settings.json context_servers / mcp style. */
|
|
13
13
|
export declare function installZed(workspace?: string): HostInstallResult;
|
|
14
|
+
/** Absolute path to the POSIX launcher shipped with the package. */
|
|
15
|
+
export declare function mcpLauncherPath(): string;
|
|
16
|
+
/** Claude Desktop stores MCP config here (also read by Cowork). */
|
|
17
|
+
export declare function claudeDesktopConfigPath(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Claude Desktop / Cowork: stdio MCP entry in claude_desktop_config.json.
|
|
20
|
+
*
|
|
21
|
+
* GUI apps are not launched from a login shell, so they inherit a minimal PATH
|
|
22
|
+
* with no Homebrew and no nvm. A bare `amem` — or even `node` — registers as a
|
|
23
|
+
* connector but never completes tool discovery, and the host reports no reason.
|
|
24
|
+
* Point at the launcher, which re-resolves node at spawn time; fall back to an
|
|
25
|
+
* absolute node + cli.js pair where a shell script cannot run (Windows).
|
|
26
|
+
*/
|
|
27
|
+
export declare function installClaudeDesktop(workspace?: string): HostInstallResult;
|
|
28
|
+
export declare function claudeDesktopInstallHealth(): string[];
|
|
14
29
|
export declare function continueInstallHealth(): string[];
|
|
15
30
|
export declare function zedInstallHealth(): string[];
|
|
16
31
|
export declare function windsurfInstallHealth(): string[];
|
package/dist/install/hosts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { mcpClientConfig } from "../mcp.js";
|
|
5
|
-
import { resolveAmemBin, writeJson, readJsonObject } from "./skills.js";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { mcpClientConfig, stdioMcpLaunch } from "../mcp.js";
|
|
5
|
+
import { getPackageRoot, resolveAmemBin, writeJson, readJsonObject } from "./skills.js";
|
|
6
6
|
function ensureDir(path) {
|
|
7
7
|
mkdirSync(path, { recursive: true });
|
|
8
8
|
}
|
|
@@ -130,6 +130,72 @@ export function installZed(workspace = "personal") {
|
|
|
130
130
|
],
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
+
/** Absolute path to the POSIX launcher shipped with the package. */
|
|
134
|
+
export function mcpLauncherPath() {
|
|
135
|
+
return join(getPackageRoot(), "scripts", "mcp-launch.sh");
|
|
136
|
+
}
|
|
137
|
+
/** Claude Desktop stores MCP config here (also read by Cowork). */
|
|
138
|
+
export function claudeDesktopConfigPath() {
|
|
139
|
+
if (process.platform === "darwin") {
|
|
140
|
+
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
141
|
+
}
|
|
142
|
+
if (process.platform === "win32") {
|
|
143
|
+
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
144
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
145
|
+
}
|
|
146
|
+
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Claude Desktop / Cowork: stdio MCP entry in claude_desktop_config.json.
|
|
150
|
+
*
|
|
151
|
+
* GUI apps are not launched from a login shell, so they inherit a minimal PATH
|
|
152
|
+
* with no Homebrew and no nvm. A bare `amem` — or even `node` — registers as a
|
|
153
|
+
* connector but never completes tool discovery, and the host reports no reason.
|
|
154
|
+
* Point at the launcher, which re-resolves node at spawn time; fall back to an
|
|
155
|
+
* absolute node + cli.js pair where a shell script cannot run (Windows).
|
|
156
|
+
*/
|
|
157
|
+
export function installClaudeDesktop(workspace = "personal") {
|
|
158
|
+
const path = claudeDesktopConfigPath();
|
|
159
|
+
ensureDir(dirname(path));
|
|
160
|
+
const existing = readJsonObject(path);
|
|
161
|
+
const servers = existing.mcpServers ?? {};
|
|
162
|
+
const launcher = mcpLauncherPath();
|
|
163
|
+
const usable = process.platform !== "win32" && existsSync(launcher);
|
|
164
|
+
if (usable) {
|
|
165
|
+
try {
|
|
166
|
+
chmodSync(launcher, 0o755);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// npm preserves the exec bit; a read-only install is not fatal here
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const stdio = stdioMcpLaunch(workspace);
|
|
173
|
+
servers.amem = usable
|
|
174
|
+
? { command: launcher, args: [], env: { AMEM_WORKSPACE: workspace } }
|
|
175
|
+
: {
|
|
176
|
+
command: stdio.command,
|
|
177
|
+
args: stdio.args,
|
|
178
|
+
env: { ...stdio.env, AMEM_WORKSPACE: workspace },
|
|
179
|
+
};
|
|
180
|
+
writeJson(path, { ...existing, mcpServers: servers });
|
|
181
|
+
const shown = usable ? launcher : `${stdio.command} ${stdio.args.join(" ")}`;
|
|
182
|
+
return {
|
|
183
|
+
host: "claude-desktop",
|
|
184
|
+
paths: [path],
|
|
185
|
+
notes: [
|
|
186
|
+
`Claude Desktop MCP amem \u2192 ${shown} (workspace ${workspace}).`,
|
|
187
|
+
"Quit Claude Desktop fully (Cmd+Q) and relaunch \u2014 config is read at startup, closing the window is not enough.",
|
|
188
|
+
"stdio needs no daemon: amem ui does not have to be running.",
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function claudeDesktopInstallHealth() {
|
|
193
|
+
const path = claudeDesktopConfigPath();
|
|
194
|
+
if (!fileMentionsAmem(path)) {
|
|
195
|
+
return ["Claude Desktop has no amem MCP entry \u2014 run amem init --platform claude-desktop"];
|
|
196
|
+
}
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
133
199
|
function fileMentionsAmem(path) {
|
|
134
200
|
if (!existsSync(path))
|
|
135
201
|
return false;
|
|
@@ -167,6 +233,8 @@ export function windsurfInstallHealth() {
|
|
|
167
233
|
}
|
|
168
234
|
export function hostInstallHealth(host) {
|
|
169
235
|
switch (host) {
|
|
236
|
+
case "claude-desktop":
|
|
237
|
+
return claudeDesktopInstallHealth();
|
|
170
238
|
case "continue":
|
|
171
239
|
return continueInstallHealth();
|
|
172
240
|
case "zed":
|
|
@@ -180,6 +248,8 @@ export function hostInstallHealth(host) {
|
|
|
180
248
|
export function installHost(host, opts = {}) {
|
|
181
249
|
const ws = opts.workspace || "personal";
|
|
182
250
|
switch (host) {
|
|
251
|
+
case "claude-desktop":
|
|
252
|
+
return installClaudeDesktop(ws);
|
|
183
253
|
case "windsurf":
|
|
184
254
|
return installWindsurf(ws);
|
|
185
255
|
case "continue":
|
package/dist/kinds.d.ts
CHANGED
|
@@ -15,4 +15,22 @@ export declare function compactFromNotes(notes: Array<{
|
|
|
15
15
|
prompt: string;
|
|
16
16
|
answer: string;
|
|
17
17
|
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* Is this a durable statement about the code, or conversational residue?
|
|
20
|
+
*
|
|
21
|
+
* Auto-capture previously accepted any sentence >= 48 chars that mentioned a
|
|
22
|
+
* path, which stored the user's own questions as facts ("so use luna mcp to
|
|
23
|
+
* connect to amem and that will do the trick?"). A question is a request, not
|
|
24
|
+
* a fact, however many files it names.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Why an ALREADY STORED claim is clear junk, or null to keep it.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately narrower than isFactLike: that gate decides what to admit and
|
|
30
|
+
* can afford false negatives, this one decides what to DELETE and a false
|
|
31
|
+
* positive destroys memory. Short, lowercase or oddly-formatted claims are
|
|
32
|
+
* kept here even though capture would now reject them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function nonFactReason(text: string): string | null;
|
|
35
|
+
export declare function isFactLike(text: string): boolean;
|
|
18
36
|
export declare function isDurableCapture(prompt: string, answer: string | undefined, anchorCount: number): boolean;
|
package/dist/kinds.js
CHANGED
|
@@ -47,8 +47,14 @@ export function compactClaimText(prompt, answer) {
|
|
|
47
47
|
const head = sentences.slice(0, 2).join(" ").slice(0, 400);
|
|
48
48
|
return head || takeaway.slice(0, 400);
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Short answer: the fact is still in the ANSWER, so lead with it and keep the
|
|
51
|
+
// prompt only as trailing context. Leading with the prompt is how questions
|
|
52
|
+
// ended up stored as durable claims; the trailing "?" is dropped so the
|
|
53
|
+
// result reads as a statement rather than tripping the question filter.
|
|
54
|
+
const q = prompt.replace(/\s+/g, " ").trim().replace(/\?+\s*$/, "").slice(0, 280);
|
|
55
|
+
if (!takeaway)
|
|
56
|
+
return q.slice(0, 400);
|
|
57
|
+
return `${takeaway.slice(0, 200)}${q ? ` (context: ${q})` : ""}`.slice(0, 400);
|
|
52
58
|
}
|
|
53
59
|
/** Multi-turn: fold several notes into one compact fact string. */
|
|
54
60
|
export function compactFromNotes(notes) {
|
|
@@ -72,10 +78,81 @@ function isUsefulish(text) {
|
|
|
72
78
|
function scrubCodeNoise(text) {
|
|
73
79
|
return text
|
|
74
80
|
.replace(/```[\s\S]*?```/g, " ")
|
|
75
|
-
.
|
|
81
|
+
// Keep what is INSIDE an inline span. Deleting it removed the file paths
|
|
82
|
+
// and identifiers that make a claim worth storing ("lives in `src/x.ts`"
|
|
83
|
+
// became "lives in and"), and it ran before pickFactSentences, which
|
|
84
|
+
// scores sentences on exactly those paths.
|
|
85
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
76
86
|
.replace(/\s+/g, " ")
|
|
77
87
|
.trim();
|
|
78
88
|
}
|
|
89
|
+
const QUESTION_LEAD = /^(can|could|would|should|is|are|was|were|do|does|did|how|what|why|when|where|who|which|will|shall|may|might|am|have|has|had)\b/i;
|
|
90
|
+
const CHAT_LEAD = /^(so|ok|okay|and|but|also|im|i'm|let's|lets|please|thanks|thx|yeah|yep|nope|hmm|wait|actually|btw|oh|hey)\b/i;
|
|
91
|
+
/** Residue of an earlier scrub: "lives in and is", "wired via and included". */
|
|
92
|
+
const DANGLING = /\b(in|at|from|to|via|inside|under|with|into)\s+(and|is|was|the file|what)\b/i;
|
|
93
|
+
/** Interrogative opener plus a subject — a question missing its "?". */
|
|
94
|
+
const QUESTION_SHAPE = /^(can|could|should|would|do|does|did|is|are|will|shall|how|what|why|when|where|who|which)\s+(?:(?:do|does|did|can|could|should|would|is|are|will|shall)\s+)?(we|you|i|amem)\b/i;
|
|
95
|
+
/** Preposition running into punctuation or end of string: the object was deleted. */
|
|
96
|
+
const TRAILING_PREPOSITION = /\b(in|at|from|to|via|inside|under|with|into|through)\s*([.,;:]|$)/i;
|
|
97
|
+
/** A leading token that looks like code (path, dotted or snake_case name). */
|
|
98
|
+
const CODE_LEAD = /^[\w@./-]*[._/][\w@./-]*(\s|$)/;
|
|
99
|
+
/**
|
|
100
|
+
* Is this a durable statement about the code, or conversational residue?
|
|
101
|
+
*
|
|
102
|
+
* Auto-capture previously accepted any sentence >= 48 chars that mentioned a
|
|
103
|
+
* path, which stored the user's own questions as facts ("so use luna mcp to
|
|
104
|
+
* connect to amem and that will do the trick?"). A question is a request, not
|
|
105
|
+
* a fact, however many files it names.
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* Why an ALREADY STORED claim is clear junk, or null to keep it.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately narrower than isFactLike: that gate decides what to admit and
|
|
111
|
+
* can afford false negatives, this one decides what to DELETE and a false
|
|
112
|
+
* positive destroys memory. Short, lowercase or oddly-formatted claims are
|
|
113
|
+
* kept here even though capture would now reject them.
|
|
114
|
+
*/
|
|
115
|
+
export function nonFactReason(text) {
|
|
116
|
+
const t = (text ?? "").replace(/\s+/g, " ").trim();
|
|
117
|
+
if (t.length === 0)
|
|
118
|
+
return "empty";
|
|
119
|
+
// A question is a request someone typed, not a fact about the code.
|
|
120
|
+
if (t.includes("?"))
|
|
121
|
+
return "question";
|
|
122
|
+
// ...and plenty were typed without the question mark ("can we expose amem
|
|
123
|
+
// data via mcp"). Require an interrogative opener FOLLOWED by a subject, so
|
|
124
|
+
// this cannot swallow a declarative sentence that merely starts with "Is".
|
|
125
|
+
if (QUESTION_SHAPE.test(t))
|
|
126
|
+
return "question";
|
|
127
|
+
// Left behind by the old scrub that deleted inline code spans.
|
|
128
|
+
if (DANGLING.test(t))
|
|
129
|
+
return "scrub-residue";
|
|
130
|
+
// Same damage, different shape: the span sat at the end of the clause, so a
|
|
131
|
+
// preposition now runs straight into punctuation ("memory lives under .").
|
|
132
|
+
if (TRAILING_PREPOSITION.test(t))
|
|
133
|
+
return "scrub-residue";
|
|
134
|
+
if (CHAT_LEAD.test(t))
|
|
135
|
+
return "chat-fragment";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
export function isFactLike(text) {
|
|
139
|
+
const t = (text ?? "").replace(/\s+/g, " ").trim();
|
|
140
|
+
if (t.length < 40)
|
|
141
|
+
return false;
|
|
142
|
+
if (t.includes("?"))
|
|
143
|
+
return false;
|
|
144
|
+
if (QUESTION_LEAD.test(t))
|
|
145
|
+
return false;
|
|
146
|
+
if (CHAT_LEAD.test(t))
|
|
147
|
+
return false;
|
|
148
|
+
if (DANGLING.test(t))
|
|
149
|
+
return false;
|
|
150
|
+
// Mid-thought lowercase openings are transcript fragments — unless the
|
|
151
|
+
// sentence opens on an identifier, which is normal for a real fact.
|
|
152
|
+
if (/^[a-z]/.test(t) && !CODE_LEAD.test(t))
|
|
153
|
+
return false;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
79
156
|
function pickFactSentences(text) {
|
|
80
157
|
const sentences = text.split(/(?<=[.!?])\s+/).filter((s) => s.length > 25);
|
|
81
158
|
const scored = sentences.map((s) => {
|
package/dist/platforms.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
export const KNOWN_PLATFORMS = [
|
|
3
3
|
{ id: "cursor", label: "Cursor", hint: "Rules, skills, hooks", installs: "cursor" },
|
|
4
4
|
{ id: "claude", label: "Claude Code", hint: "Skills and settings hooks", installs: "claude" },
|
|
5
|
+
{
|
|
6
|
+
id: "claude-desktop",
|
|
7
|
+
label: "Claude Desktop",
|
|
8
|
+
hint: "MCP config installer",
|
|
9
|
+
installs: "host",
|
|
10
|
+
},
|
|
5
11
|
{ id: "copilot", label: "GitHub Copilot", hint: "MCP / HTTP API" },
|
|
6
12
|
{ id: "codex", label: "ChatGPT / Codex", hint: "MCP / HTTP API" },
|
|
7
13
|
{ id: "gemini", label: "Gemini", hint: "MCP / HTTP API" },
|
|
@@ -64,11 +64,17 @@ Generic MCP host recipe. Same tools for every client — do not fork per product
|
|
|
64
64
|
|
|
65
65
|
## Connect
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Two supported transports. Either is fine — pick one.
|
|
68
|
+
|
|
69
|
+
**stdio (no daemon).** The host spawns amem itself. Let amem write the config:
|
|
70
|
+
|
|
71
|
+
\`amem init --platform claude-desktop\`
|
|
72
|
+
|
|
73
|
+
**HTTP (shared daemon).** Keep \`amem ui\` running, then attach:
|
|
68
74
|
|
|
69
75
|
\`${MCP_URL_TEMPLATE}\`
|
|
70
76
|
|
|
71
|
-
GUI
|
|
77
|
+
Whichever you pick, never configure a GUI host with a bare \`amem\` or \`node\` command. GUI apps are not launched from a login shell, so they inherit a minimal PATH with no Homebrew and no nvm: the connector registers but tool discovery never completes, and the host reports no reason. An absolute path — to the launcher or to the node binary — is what makes stdio work.
|
|
72
78
|
|
|
73
79
|
## Tools
|
|
74
80
|
|
|
@@ -88,8 +94,11 @@ ${ruleLines}
|
|
|
88
94
|
function renderPaste() {
|
|
89
95
|
return `amem remember contract v${REMEMBER_CONTRACT_VERSION}
|
|
90
96
|
|
|
91
|
-
Connect (any MCP host):
|
|
92
|
-
|
|
97
|
+
Connect (any MCP host), either:
|
|
98
|
+
- stdio, no daemon: amem init --platform claude-desktop (or point the host at an absolute launcher path)
|
|
99
|
+
- HTTP, needs amem ui running: ${MCP_URL_TEMPLATE}
|
|
100
|
+
|
|
101
|
+
Never give a GUI host a bare amem or node command — no login PATH, so tool discovery silently fails.
|
|
93
102
|
|
|
94
103
|
Every task:
|
|
95
104
|
1. amem_context — read local memory first (pass workspace=<slug>)
|
package/dist/repo-identity.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export declare function findGitRoot(startPath?: string): string | null;
|
|
|
9
9
|
/** Normalize remotes so ssh/https clones of the same repo share a key. */
|
|
10
10
|
export declare function normalizeRemoteUrl(url: string): string;
|
|
11
11
|
export declare function detectRepoIdentity(cwd?: string): RepoIdentity;
|
|
12
|
+
/**
|
|
13
|
+
* Why this directory must not become a repo binding, or null if it is fine.
|
|
14
|
+
*
|
|
15
|
+
* detectRepoIdentity falls back to the cwd when there is no git root, so
|
|
16
|
+
* running an init from a home directory would otherwise register the entire
|
|
17
|
+
* home as a "repo" — a binding that matches everything and can never be the
|
|
18
|
+
* right answer. Workspace roots under ~/.amem are created deliberately and are
|
|
19
|
+
* not affected: they are built by workspaceIdentity, not by cwd detection.
|
|
20
|
+
*/
|
|
21
|
+
export declare function unbindableRootReason(rootPath: string): string | null;
|
|
12
22
|
export declare function slugifyWorkspace(name: string): string;
|
|
13
23
|
export declare function workspaceIdentity(name: string, rootPath: string): RepoIdentity;
|
|
14
24
|
export declare function parseWorkspaceSlug(remoteUrl: string | null | undefined): string | null;
|
package/dist/repo-identity.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { parse as parsePath, resolve } from "node:path";
|
|
5
6
|
function runGit(cwd, args) {
|
|
6
7
|
try {
|
|
7
8
|
return execFileSync("git", args, {
|
|
@@ -51,6 +52,24 @@ export function detectRepoIdentity(cwd = process.cwd()) {
|
|
|
51
52
|
defaultBranch,
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Why this directory must not become a repo binding, or null if it is fine.
|
|
57
|
+
*
|
|
58
|
+
* detectRepoIdentity falls back to the cwd when there is no git root, so
|
|
59
|
+
* running an init from a home directory would otherwise register the entire
|
|
60
|
+
* home as a "repo" — a binding that matches everything and can never be the
|
|
61
|
+
* right answer. Workspace roots under ~/.amem are created deliberately and are
|
|
62
|
+
* not affected: they are built by workspaceIdentity, not by cwd detection.
|
|
63
|
+
*/
|
|
64
|
+
export function unbindableRootReason(rootPath) {
|
|
65
|
+
const resolved = resolve(rootPath);
|
|
66
|
+
const parsed = parsePath(resolved);
|
|
67
|
+
if (resolved === parsed.root)
|
|
68
|
+
return "the filesystem root";
|
|
69
|
+
if (resolved === resolve(homedir()))
|
|
70
|
+
return "your home directory";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
54
73
|
export function slugifyWorkspace(name) {
|
|
55
74
|
const slug = name
|
|
56
75
|
.trim()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iamem/amem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"templates",
|
|
22
22
|
"docs",
|
|
23
23
|
"ui-static",
|
|
24
|
+
"scripts/mcp-launch.sh",
|
|
24
25
|
"scripts/mdm-offboard.sh",
|
|
25
26
|
"scripts/postinstall.js"
|
|
26
27
|
],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# amem MCP launcher for GUI hosts (Claude Desktop / Cowork).
|
|
3
|
+
#
|
|
4
|
+
# GUI apps are not launched from a login shell, so they inherit a minimal PATH
|
|
5
|
+
# with no Homebrew and no nvm. A host configured with a bare `amem` or `node`
|
|
6
|
+
# command registers the connector but never completes tool discovery. This
|
|
7
|
+
# script resolves node at spawn time and execs the amem stdio MCP server.
|
|
8
|
+
#
|
|
9
|
+
# Set AMEM_NODE to pin a specific node binary. Extra args are passed through.
|
|
10
|
+
DIR=$(cd "$(dirname "$0")/.." && pwd)
|
|
11
|
+
NODE=""
|
|
12
|
+
for c in \
|
|
13
|
+
"$AMEM_NODE" \
|
|
14
|
+
"$(command -v node 2>/dev/null)" \
|
|
15
|
+
/opt/homebrew/bin/node \
|
|
16
|
+
/usr/local/bin/node \
|
|
17
|
+
"$HOME/.homebrew/bin/node" \
|
|
18
|
+
/usr/bin/node
|
|
19
|
+
do
|
|
20
|
+
if [ -n "$c" ] && [ -x "$c" ]; then NODE="$c"; break; fi
|
|
21
|
+
done
|
|
22
|
+
if [ -z "$NODE" ]; then
|
|
23
|
+
echo "amem-mcp: node 20+ not found. Set AMEM_NODE=/path/to/node in the host's MCP env." >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
exec "$NODE" "$DIR/dist/cli.js" mcp "$@"
|
package/ui-static/app.js
CHANGED
|
@@ -3108,9 +3108,26 @@ function formatPct(n) {
|
|
|
3108
3108
|
|
|
3109
3109
|
function formatUsd(n) {
|
|
3110
3110
|
const v = Number(n);
|
|
3111
|
-
if (!Number.isFinite(v) || v
|
|
3112
|
-
|
|
3113
|
-
|
|
3111
|
+
if (!Number.isFinite(v) || v === 0) return "$0";
|
|
3112
|
+
// Savings can be negative now (a packet that returned little still cost
|
|
3113
|
+
// input tokens). Rendering that as "$0" would hide exactly the case worth
|
|
3114
|
+
// seeing, so show the sign.
|
|
3115
|
+
const sign = v < 0 ? "-" : "~";
|
|
3116
|
+
const abs = Math.abs(v);
|
|
3117
|
+
if (abs < 0.01) return `${sign}$${abs.toFixed(3)}`;
|
|
3118
|
+
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* How much to trust the savings figures. Until a real reported saving exists
|
|
3123
|
+
* these are model output, and the UI should say so rather than implying the
|
|
3124
|
+
* number was measured.
|
|
3125
|
+
*/
|
|
3126
|
+
function savingsCaveat(agg) {
|
|
3127
|
+
const p = agg?.pricing;
|
|
3128
|
+
if (p?.calibrated) return "calibrated against reported savings";
|
|
3129
|
+
const perFile = p?.assumedTokensPerFile ?? 4000;
|
|
3130
|
+
return `modelled, never calibrated · assumes ${formatTokens(perFile)} tok per file avoided`;
|
|
3114
3131
|
}
|
|
3115
3132
|
|
|
3116
3133
|
function formatChartDay(iso) {
|
|
@@ -3556,7 +3573,7 @@ function renderDashboard() {
|
|
|
3556
3573
|
<b>${esc(String(tier))}</b><span>License</span>
|
|
3557
3574
|
</div>
|
|
3558
3575
|
<div class="dash-kpi static">
|
|
3559
|
-
<b>${formatUsd(totals.estimatedUsdSaved)}</b><span>Est. $ saved (
|
|
3576
|
+
<b>${formatUsd(totals.estimatedUsdSaved)}</b><span>Est. $ saved · ${esc(savingsCaveat(agg))}</span>
|
|
3560
3577
|
</div>
|
|
3561
3578
|
</div>
|
|
3562
3579
|
|
|
@@ -3727,7 +3744,7 @@ function renderAnalytics() {
|
|
|
3727
3744
|
const speedCards = $("#speedCards");
|
|
3728
3745
|
speedCards.innerHTML = `
|
|
3729
3746
|
<div class="platform-card"><div class="label">Estimated time saved</div><div class="value">~${formatDuration(totals.estimatedMsSaved)}</div><div class="meta">proxy vs tool round-trips · not model latency</div></div>
|
|
3730
|
-
<div class="platform-card"><div class="label">Estimated money saved</div><div class="value">${formatUsd(totals.estimatedUsdSaved)}</div><div class="meta">at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens ·
|
|
3747
|
+
<div class="platform-card"><div class="label">Estimated money saved</div><div class="value">${formatUsd(totals.estimatedUsdSaved)}</div><div class="meta">at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens · ${esc(savingsCaveat(agg))}</div></div>
|
|
3731
3748
|
<div class="platform-card"><div class="label">Local lookup</div><div class="value">${totals.avgLocalMs != null ? formatDuration(totals.avgLocalMs) : "—"}</div><div class="meta">measured SQLite / localhost avg</div></div>
|
|
3732
3749
|
<div class="platform-card"><div class="label">Hit rate</div><div class="value">${formatPct(totals.hitRate)}</div><div class="meta">${totals.localHits ?? 0} keyword hits · ${totals.serverTrips ?? 0} misses · not model API calls</div></div>
|
|
3733
3750
|
<div class="platform-card"><div class="label">Avoided file reads</div><div class="value">${formatTokens(totals.anchorsAvoided ?? 0)}</div><div class="meta">unique anchors returned in packets</div></div>`;
|
|
@@ -3737,7 +3754,7 @@ function renderAnalytics() {
|
|
|
3737
3754
|
const trend = monthly.trendDays || 0;
|
|
3738
3755
|
monthlyCards.innerHTML = `
|
|
3739
3756
|
<div class="platform-card accented"><div class="label">Est. tokens / month</div><div class="value">~${formatTokens(monthly.estimatedTokensSaved ?? 0)}</div><div class="meta">${trend ? `from ${monthly.sampleQueries} calls over ${trend} day${trend === 1 ? "" : "s"} × 30` : "no usage yet"} · proxy, not a bill</div></div>
|
|
3740
|
-
<div class="platform-card accented"><div class="label">Est. $ / month</div><div class="value">${formatUsd(monthly.estimatedUsdSaved)}</div><div class="meta">same token proxy at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input</div></div>
|
|
3757
|
+
<div class="platform-card accented"><div class="label">Est. $ / month</div><div class="value">${formatUsd(monthly.estimatedUsdSaved)}</div><div class="meta">same token proxy at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input · ${esc(savingsCaveat(agg))}</div></div>
|
|
3741
3758
|
<div class="platform-card accented"><div class="label">Est. time / month</div><div class="value">~${formatDuration(monthly.estimatedMsSaved)}</div><div class="meta">avoided tool round-trips at current pace</div></div>
|
|
3742
3759
|
<div class="platform-card accented"><div class="label">Est. calls / month</div><div class="value">${formatTokens(monthly.queries ?? 0)}</div><div class="meta">amem context hits if this rate holds</div></div>
|
|
3743
3760
|
<div class="platform-card accented"><div class="label">Est. file reads / month</div><div class="value">${formatTokens(monthly.anchorsAvoided ?? 0)}</div><div class="meta">anchors that would be skipped</div></div>`;
|
package/ui-static/styles.css
CHANGED
|
@@ -1126,7 +1126,9 @@ a.btn {
|
|
|
1126
1126
|
.orbit-host {
|
|
1127
1127
|
position: relative;
|
|
1128
1128
|
min-height: 320px;
|
|
1129
|
-
height: 100
|
|
1129
|
+
/* No height here: .viz-host's clamp() bounds the canvas. A height:100% (or a
|
|
1130
|
+
fr unit) resolves against the fact list, which can be thousands of rows,
|
|
1131
|
+
and starves the list's grid track down to 0. */
|
|
1130
1132
|
border-bottom: 1px solid var(--line);
|
|
1131
1133
|
background:
|
|
1132
1134
|
radial-gradient(ellipse at 50% 40%, rgba(46, 196, 182, 0.1), transparent 55%),
|
|
@@ -1171,6 +1173,13 @@ a.btn {
|
|
|
1171
1173
|
background: var(--bg2);
|
|
1172
1174
|
}
|
|
1173
1175
|
|
|
1176
|
+
.brain-v2 {
|
|
1177
|
+
flex: 1;
|
|
1178
|
+
min-height: 0;
|
|
1179
|
+
display: flex;
|
|
1180
|
+
flex-direction: column;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1174
1183
|
.brain-body {
|
|
1175
1184
|
display: flex;
|
|
1176
1185
|
flex-direction: column;
|
|
@@ -2348,6 +2357,7 @@ button.linkish:hover code {
|
|
|
2348
2357
|
display: none;
|
|
2349
2358
|
}
|
|
2350
2359
|
.brain-v2 {
|
|
2360
|
+
flex: 0 0 auto;
|
|
2351
2361
|
height: auto;
|
|
2352
2362
|
min-height: 0;
|
|
2353
2363
|
}
|