@everme/claude-code 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/.claude-plugin/.mcp.json +9 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +201 -0
- package/README.md +100 -0
- package/commands/everme-help.md +31 -0
- package/commands/recall.md +26 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/inject-memories.js +126 -0
- package/hooks/scripts/lib/api.js +54 -0
- package/hooks/scripts/lib/config.js +129 -0
- package/hooks/scripts/lib/profile.js +75 -0
- package/hooks/scripts/lib/redact.js +43 -0
- package/hooks/scripts/lib/source-key.js +54 -0
- package/hooks/scripts/lib/transcript.js +263 -0
- package/hooks/scripts/mcp-server.js +130 -0
- package/hooks/scripts/session-start.js +70 -0
- package/hooks/scripts/session-summary.js +24 -0
- package/hooks/scripts/store-memories.js +113 -0
- package/install.sh +112 -0
- package/package.json +40 -0
- package/plugin.json +12 -0
- package/skills/memory-tools.md +35 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin config loader.
|
|
3
|
+
*
|
|
4
|
+
* Source precedence:
|
|
5
|
+
*
|
|
6
|
+
* For EVERME_AGENT_TOKEN and EVERME_AGENT_ID — the per-machine
|
|
7
|
+
* credentials evercli rotates — `~/.claude/everme.env` always wins.
|
|
8
|
+
* evercli is the canonical owner of these values; if anything else
|
|
9
|
+
* (a stale .claude.json mcp.env block, a leftover shell var) has a
|
|
10
|
+
* different value, it's stale and the freshly-rotated evt must win.
|
|
11
|
+
*
|
|
12
|
+
* For every other EVERME_* (EVERME_API_KEY for emk-mode debugging,
|
|
13
|
+
* EVERME_API_BASE for self-hosted EverMe, …) process.env still wins:
|
|
14
|
+
* users may legitimately want to override these from a shell or from
|
|
15
|
+
* Claude Code's mcp .env block, and evercli does not own them.
|
|
16
|
+
*
|
|
17
|
+
* Compiled defaults (everme.evermind.ai, no token) sit at the bottom.
|
|
18
|
+
*
|
|
19
|
+
* Auth modes (mutually exclusive, both wire-compatible):
|
|
20
|
+
* evt — set EVERME_AGENT_TOKEN (per-machine token from evercli)
|
|
21
|
+
* emk — set EVERME_API_KEY (account-level, from EverMe Web UI)
|
|
22
|
+
*
|
|
23
|
+
* If neither is set the plugin runs in disabled-mode: hooks short-
|
|
24
|
+
* circuit silently so the host (Claude Code) is never blocked.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { resolveConfig as sdkResolveConfig } from "@everme/agent-sdk";
|
|
31
|
+
|
|
32
|
+
// Env-file location. EVERME_ENV_FILE_PATH overrides for tests so they
|
|
33
|
+
// don't get polluted by a real file on the developer's box.
|
|
34
|
+
function evermeEnvFilePath() {
|
|
35
|
+
return process.env.EVERME_ENV_FILE_PATH || join(homedir(), ".claude", "everme.env");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cached = null;
|
|
39
|
+
let envFileLoaded = false;
|
|
40
|
+
|
|
41
|
+
// Keys that evercli rotates per machine. For these, the env file is
|
|
42
|
+
// the canonical source — if process.env carries a different value
|
|
43
|
+
// (stale .claude.json mcp.env block, leftover shell export from a
|
|
44
|
+
// previous account), the env file's value MUST overwrite it. Without
|
|
45
|
+
// this the freshly-rotated evt could be shadowed by a stale token,
|
|
46
|
+
// leaving every memory call 401.
|
|
47
|
+
const EVERME_ROTATED_KEYS = new Set([
|
|
48
|
+
"EVERME_AGENT_TOKEN",
|
|
49
|
+
"EVERME_AGENT_ID",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load ~/.claude/everme.env (KEY=value lines) into process.env.
|
|
54
|
+
* Idempotent — runs once per process.
|
|
55
|
+
*
|
|
56
|
+
* For EVERME_ROTATED_KEYS the env file always wins. For everything
|
|
57
|
+
* else (EVERME_API_KEY, EVERME_API_BASE, …) process.env wins so users
|
|
58
|
+
* can override via shell or mcp .env block.
|
|
59
|
+
*
|
|
60
|
+
* This is the path evercli uses to hand the freshly-minted evt to the
|
|
61
|
+
* plugin without editing the user's shell profile (which is brittle:
|
|
62
|
+
* profile name varies by shell, and a user might run `claude` from a
|
|
63
|
+
* non-interactive shell that doesn't load .zshrc).
|
|
64
|
+
*/
|
|
65
|
+
function loadEnvFile() {
|
|
66
|
+
if (envFileLoaded) return;
|
|
67
|
+
envFileLoaded = true;
|
|
68
|
+
const path = evermeEnvFilePath();
|
|
69
|
+
if (!existsSync(path)) return;
|
|
70
|
+
try {
|
|
71
|
+
const raw = readFileSync(path, "utf8");
|
|
72
|
+
for (const line of raw.split("\n")) {
|
|
73
|
+
const t = line.trim();
|
|
74
|
+
if (!t || t.startsWith("#")) continue;
|
|
75
|
+
const eq = t.indexOf("=");
|
|
76
|
+
if (eq < 1) continue;
|
|
77
|
+
const k = t.slice(0, eq).trim();
|
|
78
|
+
let v = t.slice(eq + 1).trim();
|
|
79
|
+
// Tolerate quoted values (single or double).
|
|
80
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
81
|
+
v = v.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
if (EVERME_ROTATED_KEYS.has(k)) {
|
|
84
|
+
// evercli owns this key — env file always wins.
|
|
85
|
+
process.env[k] = v;
|
|
86
|
+
} else if (!process.env[k]) {
|
|
87
|
+
// user-overridable key — fill gap only.
|
|
88
|
+
process.env[k] = v;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* unreadable file is not fatal — plugin runs disabled */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getConfig() {
|
|
97
|
+
if (cached) return cached;
|
|
98
|
+
loadEnvFile();
|
|
99
|
+
|
|
100
|
+
const agentToken = process.env.EVERME_AGENT_TOKEN || process.env.EVERME_API_KEY || "";
|
|
101
|
+
const authMode = process.env.EVERME_AGENT_TOKEN
|
|
102
|
+
? "evt"
|
|
103
|
+
: process.env.EVERME_API_KEY
|
|
104
|
+
? "emk"
|
|
105
|
+
: "none";
|
|
106
|
+
|
|
107
|
+
const sdkCfg = sdkResolveConfig({
|
|
108
|
+
apiBase: process.env.EVERME_API_BASE,
|
|
109
|
+
agentId: process.env.EVERME_AGENT_ID,
|
|
110
|
+
agentToken,
|
|
111
|
+
topK: 5,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
cached = {
|
|
115
|
+
...sdkCfg,
|
|
116
|
+
authMode,
|
|
117
|
+
isConfigured: !!agentToken,
|
|
118
|
+
};
|
|
119
|
+
return cached;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isConfigured() {
|
|
123
|
+
return getConfig().isConfigured;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function _resetCache() {
|
|
127
|
+
cached = null;
|
|
128
|
+
envFileLoaded = false;
|
|
129
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderers for the gateway's /mem/context profile snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Profile shape (from EverMe gateway):
|
|
5
|
+
* { explicit_info: [{ category, description, evidence?, sources?[] }],
|
|
6
|
+
* implicit_traits: [{ trait, description, basis?, evidence? }],
|
|
7
|
+
* scenario, memcell_count, ... }
|
|
8
|
+
*
|
|
9
|
+
* `renderProfileBlock` is shared by:
|
|
10
|
+
* - SessionStart hook (kicks off the conversation with a snapshot)
|
|
11
|
+
* - UserPromptSubmit hook (fallback when search comes up empty)
|
|
12
|
+
*
|
|
13
|
+
* Centralising here so a tweak — wider truncation, extra fields, an
|
|
14
|
+
* empty-state message — lands once instead of drifting between two
|
|
15
|
+
* copies. The previous setup duplicated the whole function and we'd
|
|
16
|
+
* already accumulated minor divergences (truncation lengths matched
|
|
17
|
+
* but the function-name comments did not).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render `profile` into a markdown block wrapped in <everme_profile>.
|
|
22
|
+
* Returns "" when there's nothing to show — callers skip injection in
|
|
23
|
+
* that case.
|
|
24
|
+
*/
|
|
25
|
+
export function renderProfileBlock(profile) {
|
|
26
|
+
if (!profile) return "";
|
|
27
|
+
const explicit = Array.isArray(profile.explicit_info) ? profile.explicit_info : [];
|
|
28
|
+
const implicit = Array.isArray(profile.implicit_traits) ? profile.implicit_traits : [];
|
|
29
|
+
if (explicit.length === 0 && implicit.length === 0) return "";
|
|
30
|
+
|
|
31
|
+
const lines = ["<everme_profile>"];
|
|
32
|
+
if (explicit.length > 0) {
|
|
33
|
+
lines.push("Profile facts:");
|
|
34
|
+
for (const e of explicit.slice(0, 12)) {
|
|
35
|
+
const cat = e.category ? `[${e.category}] ` : "";
|
|
36
|
+
const desc = e.description || e.evidence || "";
|
|
37
|
+
if (!desc) continue;
|
|
38
|
+
lines.push(`- ${cat}${truncate(desc, 240)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (implicit.length > 0) {
|
|
42
|
+
lines.push("Implicit traits:");
|
|
43
|
+
for (const t of implicit.slice(0, 6)) {
|
|
44
|
+
const name = t.trait || t.name || "trait";
|
|
45
|
+
const desc = t.description || "";
|
|
46
|
+
lines.push(`- ${name}: ${truncate(desc, 200)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push("</everme_profile>");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Number of items the gateway included in this profile — used by the
|
|
55
|
+
* hook output's `systemMessage` ("loaded N items"). Counts both the
|
|
56
|
+
* explicit and implicit lists; matches what renderProfileBlock will
|
|
57
|
+
* surface (modulo per-list truncation, which is fine for a count).
|
|
58
|
+
*/
|
|
59
|
+
export function profileItemCount(profile) {
|
|
60
|
+
if (!profile) return 0;
|
|
61
|
+
return (
|
|
62
|
+
(Array.isArray(profile.explicit_info) ? profile.explicit_info.length : 0) +
|
|
63
|
+
(Array.isArray(profile.implicit_traits) ? profile.implicit_traits.length : 0)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One-line truncation: collapse whitespace, cap to `n` chars, append
|
|
69
|
+
* ellipsis. Both block renderers use the same shape so the output is
|
|
70
|
+
* visually consistent across SessionStart and UserPromptSubmit.
|
|
71
|
+
*/
|
|
72
|
+
export function truncate(s, n) {
|
|
73
|
+
s = String(s).replace(/\s+/g, " ").trim();
|
|
74
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
75
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports the SDK's redactError so hook scripts have a single
|
|
3
|
+
* import target. `debug` is plugin-local because it formats output
|
|
4
|
+
* with the [everme:<prefix>] tag specific to Claude Code stderr.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { redactError } from "@everme/agent-sdk";
|
|
8
|
+
|
|
9
|
+
export { redactError };
|
|
10
|
+
|
|
11
|
+
const DEBUG = process.env.EVERME_DEBUG === "1";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Synchronous stderr trace, gated on EVERME_DEBUG=1. Earlier revisions
|
|
15
|
+
* dynamic-imported `redactError` here to "guard against tests mocking
|
|
16
|
+
* the SDK at module-load time" — but the static export above already
|
|
17
|
+
* load-fails in that scenario, AND the dynamic import is async, so
|
|
18
|
+
* any line written after `process.exit(0)` was silently dropped. Using
|
|
19
|
+
* the static `redactError` makes debug logging actually appear.
|
|
20
|
+
*/
|
|
21
|
+
export function debug(prefix, ...args) {
|
|
22
|
+
if (!DEBUG) return;
|
|
23
|
+
try {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
`[everme:${prefix}] ` +
|
|
26
|
+
args
|
|
27
|
+
.map((a) => (typeof a === "string" ? a : safeStringify(a)))
|
|
28
|
+
.map(redactError)
|
|
29
|
+
.join(" ") +
|
|
30
|
+
"\n",
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
/* never throw from debug */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeStringify(v) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(v);
|
|
40
|
+
} catch {
|
|
41
|
+
return String(v);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-machine source key for documentKey derivation.
|
|
3
|
+
*
|
|
4
|
+
* The hooks pass this into `buildDocumentKey(sourceKey, logicalPath)`
|
|
5
|
+
* to anchor a version chain. In evt mode the agentId (`agt_…`) is
|
|
6
|
+
* unique per machine + user + platform, so it works as the source key
|
|
7
|
+
* unmodified. In emk mode the user has no agentId, so the previous
|
|
8
|
+
* code fell back to the literal `"agt_claude_code"` — which means
|
|
9
|
+
* every user on every machine sharing the same EverMe account would
|
|
10
|
+
* write into the SAME version chain, overwriting each other's runtime
|
|
11
|
+
* docs.
|
|
12
|
+
*
|
|
13
|
+
* This module computes a stable replacement: `claude-code:<host>:<user>`,
|
|
14
|
+
* SHA256-truncated to a 24-hex prefix. Same machine + user → same
|
|
15
|
+
* key across processes and reboots; two machines under one account →
|
|
16
|
+
* two distinct chains.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { hostname, userInfo } from "node:os";
|
|
21
|
+
|
|
22
|
+
let cached = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the source key the runtime hooks should pass into
|
|
26
|
+
* buildDocumentKey. Honours `cfg.agentId` (evt mode) when present —
|
|
27
|
+
* agentId is already the canonical per-machine fingerprint that
|
|
28
|
+
* evercli writes — and falls back to a hashed host+user string when
|
|
29
|
+
* the user is on emk auth.
|
|
30
|
+
*/
|
|
31
|
+
export function getSourceKey(cfg) {
|
|
32
|
+
if (cfg?.agentId) return cfg.agentId;
|
|
33
|
+
if (cached) return cached;
|
|
34
|
+
let host = "unknown-host";
|
|
35
|
+
let user = "unknown-user";
|
|
36
|
+
try {
|
|
37
|
+
host = hostname() || host;
|
|
38
|
+
} catch {
|
|
39
|
+
/* fall through */
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
user = userInfo().username || user;
|
|
43
|
+
} catch {
|
|
44
|
+
/* fall through */
|
|
45
|
+
}
|
|
46
|
+
const sum = createHash("sha256").update(`claude-code:${host}:${user}`).digest("hex");
|
|
47
|
+
cached = "agt_emk_" + sum.slice(0, 24);
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Test seam — wipes the per-process cache. */
|
|
52
|
+
export function _reset() {
|
|
53
|
+
cached = null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code transcript reader.
|
|
3
|
+
*
|
|
4
|
+
* Stop / SessionEnd hooks receive `transcript_path` on stdin — a JSONL
|
|
5
|
+
* file Claude Code writes per session. Each line is one event; we
|
|
6
|
+
* parse selectively into the message shape EverMe wants:
|
|
7
|
+
*
|
|
8
|
+
* { role, text, ts, hasToolCall }
|
|
9
|
+
* { role, timestamp, content, toolCalls?, toolCallId? } for realtime writes
|
|
10
|
+
*
|
|
11
|
+
* `hasToolCall` is retained for legacy markdown rendering tests. Runtime
|
|
12
|
+
* persistence uses extractAgentMessages so tool calls/results can go directly
|
|
13
|
+
* to /mem/agent-memory.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { readFile } from "fs/promises";
|
|
18
|
+
import { AGENT_MEMORY_ROLES, AGENT_MEMORY_TOOL_CALL_TYPES } from "@everme/agent-sdk";
|
|
19
|
+
|
|
20
|
+
const READ_RETRIES = 5;
|
|
21
|
+
const RETRY_DELAY_MS = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the transcript with a small retry budget. Stop hook can fire
|
|
25
|
+
* before Claude Code has flushed the final line; we wait for the
|
|
26
|
+
* `turn_duration` marker which Claude Code writes when the turn is
|
|
27
|
+
* complete.
|
|
28
|
+
*
|
|
29
|
+
* Async IO: hooks run in their own short-lived Node process, but
|
|
30
|
+
* transcripts can grow to several MB and the previous synchronous
|
|
31
|
+
* `readFileSync` blocked the event loop for the entire read on each
|
|
32
|
+
* of the 5 retries. Using fs.promises.readFile lets the loop service
|
|
33
|
+
* other tasks (timer for sleep, GC) between retries. The retry budget
|
|
34
|
+
* itself is unchanged.
|
|
35
|
+
*/
|
|
36
|
+
export async function readTranscript(path) {
|
|
37
|
+
if (!path || !existsSync(path)) return [];
|
|
38
|
+
for (let i = 0; i < READ_RETRIES; i++) {
|
|
39
|
+
const raw = await readFile(path, "utf8");
|
|
40
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
41
|
+
if (lines.length === 0) {
|
|
42
|
+
await sleep(RETRY_DELAY_MS);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let complete = false;
|
|
46
|
+
try {
|
|
47
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
48
|
+
if (last?.type === "turn_duration" || last?.event === "turn_duration") {
|
|
49
|
+
complete = true;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
/* incomplete line — retry */
|
|
53
|
+
}
|
|
54
|
+
if (complete || i === READ_RETRIES - 1) return lines;
|
|
55
|
+
await sleep(RETRY_DELAY_MS);
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sleep(ms) {
|
|
61
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Walk the JSONL events and produce the {role, text, hasToolCall, ts}
|
|
66
|
+
* sequence the hooks need. Robust to Claude Code's evolving transcript
|
|
67
|
+
* shape — we only consume the fields we recognise and silently skip
|
|
68
|
+
* unknown event kinds.
|
|
69
|
+
*/
|
|
70
|
+
export function extractTurns(lines) {
|
|
71
|
+
const turns = [];
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
let ev;
|
|
74
|
+
try {
|
|
75
|
+
ev = JSON.parse(line);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// User-side prompt
|
|
80
|
+
if (ev.role === AGENT_MEMORY_ROLES.USER && typeof ev.content === "string") {
|
|
81
|
+
turns.push({
|
|
82
|
+
role: AGENT_MEMORY_ROLES.USER,
|
|
83
|
+
text: ev.content,
|
|
84
|
+
ts: ev.timestamp || Date.now(),
|
|
85
|
+
hasToolCall: false,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Assistant message — content can be a string or an array of
|
|
90
|
+
// content blocks (text / tool_use / tool_result).
|
|
91
|
+
if (ev.role === AGENT_MEMORY_ROLES.ASSISTANT) {
|
|
92
|
+
const { text, hasToolCall } = flattenAssistant(ev.content);
|
|
93
|
+
if (text) {
|
|
94
|
+
turns.push({
|
|
95
|
+
role: AGENT_MEMORY_ROLES.ASSISTANT,
|
|
96
|
+
text,
|
|
97
|
+
ts: ev.timestamp || Date.now(),
|
|
98
|
+
hasToolCall,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Tool result event — Claude Code emits these as a separate role.
|
|
104
|
+
if (ev.role === AGENT_MEMORY_ROLES.TOOL || ev.type === "tool_result") {
|
|
105
|
+
const tr =
|
|
106
|
+
typeof ev.content === "string"
|
|
107
|
+
? ev.content
|
|
108
|
+
: safeJsonStringify(ev.content);
|
|
109
|
+
turns.push({
|
|
110
|
+
role: AGENT_MEMORY_ROLES.TOOL,
|
|
111
|
+
text: tr,
|
|
112
|
+
ts: ev.timestamp || Date.now(),
|
|
113
|
+
hasToolCall: true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return turns;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function extractAgentMessages(lines) {
|
|
121
|
+
const messages = [];
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
let ev;
|
|
124
|
+
try {
|
|
125
|
+
ev = JSON.parse(line);
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const timestamp = normalizeTimestamp(ev.timestamp);
|
|
130
|
+
if (ev.role === AGENT_MEMORY_ROLES.USER) {
|
|
131
|
+
const content = textFromContent(ev.content);
|
|
132
|
+
if (content) messages.push({ role: AGENT_MEMORY_ROLES.USER, timestamp, content });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ev.role === AGENT_MEMORY_ROLES.ASSISTANT) {
|
|
136
|
+
const msg = agentAssistantMessage(ev.content, timestamp);
|
|
137
|
+
if (msg) messages.push(msg);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ev.role === AGENT_MEMORY_ROLES.TOOL || ev.type === "tool_result") {
|
|
141
|
+
const toolCallId = ev.toolCallId || ev.tool_call_id || ev.tool_use_id;
|
|
142
|
+
if (!toolCallId) continue;
|
|
143
|
+
const content =
|
|
144
|
+
typeof ev.content === "string" ? ev.content : safeJsonStringify(ev.content);
|
|
145
|
+
messages.push({ role: AGENT_MEMORY_ROLES.TOOL, timestamp, toolCallId, content });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return messages;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function agentAssistantMessage(content, timestamp) {
|
|
152
|
+
if (typeof content === "string") {
|
|
153
|
+
return content ? { role: AGENT_MEMORY_ROLES.ASSISTANT, timestamp, content } : null;
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(content)) return null;
|
|
156
|
+
const textParts = [];
|
|
157
|
+
const toolCalls = [];
|
|
158
|
+
for (const [i, b] of content.entries()) {
|
|
159
|
+
if (!b || typeof b !== "object") continue;
|
|
160
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
161
|
+
textParts.push(b.text);
|
|
162
|
+
} else if (b.type === "tool_use" || b.type === "toolCall") {
|
|
163
|
+
const args = b.input ?? b.arguments ?? {};
|
|
164
|
+
toolCalls.push({
|
|
165
|
+
id: b.id || b.tool_use_id || `claude_tool_${timestamp}_${i}`,
|
|
166
|
+
type: AGENT_MEMORY_TOOL_CALL_TYPES.FUNCTION,
|
|
167
|
+
name: b.name || "unknown",
|
|
168
|
+
arguments: typeof args === "string" ? args : safeJsonStringify(args),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const out = { role: AGENT_MEMORY_ROLES.ASSISTANT, timestamp };
|
|
173
|
+
const text = textParts.join("\n\n");
|
|
174
|
+
if (text) out.content = text;
|
|
175
|
+
if (toolCalls.length) out.toolCalls = toolCalls;
|
|
176
|
+
return out.content || out.toolCalls ? out : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function textFromContent(content) {
|
|
180
|
+
if (typeof content === "string") return content;
|
|
181
|
+
if (!Array.isArray(content)) return "";
|
|
182
|
+
return content
|
|
183
|
+
.map((b) => {
|
|
184
|
+
if (typeof b === "string") return b;
|
|
185
|
+
if (b?.type === "text" && typeof b.text === "string") return b.text;
|
|
186
|
+
return "";
|
|
187
|
+
})
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeTimestamp(ts) {
|
|
193
|
+
if (typeof ts === "number" && Number.isFinite(ts)) {
|
|
194
|
+
return ts > 10_000_000_000 ? Math.trunc(ts) : Math.trunc(ts * 1000);
|
|
195
|
+
}
|
|
196
|
+
const parsed = Date.parse(ts);
|
|
197
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
198
|
+
return Date.now();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function flattenAssistant(content) {
|
|
202
|
+
if (typeof content === "string") {
|
|
203
|
+
return { text: content, hasToolCall: false };
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(content)) return { text: "", hasToolCall: false };
|
|
206
|
+
const parts = [];
|
|
207
|
+
let hasToolCall = false;
|
|
208
|
+
for (const b of content) {
|
|
209
|
+
if (!b || typeof b !== "object") continue;
|
|
210
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
211
|
+
parts.push(b.text);
|
|
212
|
+
} else if (b.type === "tool_use") {
|
|
213
|
+
hasToolCall = true;
|
|
214
|
+
parts.push(
|
|
215
|
+
`[tool_use ${b.name || "unknown"}] ${safeJsonStringify(b.input)}`,
|
|
216
|
+
);
|
|
217
|
+
} else if (b.type === "tool_result") {
|
|
218
|
+
hasToolCall = true;
|
|
219
|
+
const tr =
|
|
220
|
+
typeof b.content === "string" ? b.content : safeJsonStringify(b.content);
|
|
221
|
+
parts.push(`[tool_result ${b.tool_use_id || ""}] ${tr}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { text: parts.join("\n\n"), hasToolCall };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function safeJsonStringify(v) {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.stringify(v);
|
|
230
|
+
} catch {
|
|
231
|
+
return String(v);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build a single markdown document from a turn sequence — same shape
|
|
237
|
+
* the @everme/memory-mcp runtime buffer writes, so backend chunkers /
|
|
238
|
+
* classifiers behave identically.
|
|
239
|
+
*/
|
|
240
|
+
export function buildTranscriptMarkdown(turns, { sessionId, agentId }) {
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
const lines = [
|
|
243
|
+
"---",
|
|
244
|
+
`everme_runtime_version: 1`,
|
|
245
|
+
`agent_id: ${agentId || "agt_claude_code"}`,
|
|
246
|
+
`session_key: ${sessionId || "claude-code-session"}`,
|
|
247
|
+
`last_flushed_at: ${now}`,
|
|
248
|
+
`turn_count: ${turns.length}`,
|
|
249
|
+
"---",
|
|
250
|
+
"",
|
|
251
|
+
`# Claude Code session ${sessionId || ""}`,
|
|
252
|
+
"",
|
|
253
|
+
];
|
|
254
|
+
for (const t of turns) {
|
|
255
|
+
lines.push(`## ${t.role} · ${new Date(t.ts).toISOString()}`);
|
|
256
|
+
lines.push("");
|
|
257
|
+
lines.push(t.text);
|
|
258
|
+
lines.push("");
|
|
259
|
+
lines.push("---");
|
|
260
|
+
lines.push("");
|
|
261
|
+
}
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP server bundled with the Claude Code plugin. Exposes the
|
|
4
|
+
* EverMe gateway's search + context endpoints as MCP tools so users
|
|
5
|
+
* can ALSO recall memory manually via natural language ("search my
|
|
6
|
+
* memory for the Postgres index thing"), even when the
|
|
7
|
+
* UserPromptSubmit hook has already done implicit recall.
|
|
8
|
+
*
|
|
9
|
+
* Wire format: MCP stdio transport (JSON-RPC 2.0 framed by line).
|
|
10
|
+
* We hand-roll the tiny subset Claude Code uses rather than pulling
|
|
11
|
+
* in @modelcontextprotocol/sdk — keeps the install fast (no npm
|
|
12
|
+
* install required) and the dependency surface minimal.
|
|
13
|
+
*
|
|
14
|
+
* Tools:
|
|
15
|
+
* everme_search — POST /api/v1/mem/search
|
|
16
|
+
* everme_context — POST /api/v1/mem/context (server-rendered prompt block)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createInterface } from "readline";
|
|
20
|
+
import { searchMemories, getContext, EvermeError } from "./lib/api.js";
|
|
21
|
+
import { isConfigured } from "./lib/config.js";
|
|
22
|
+
import { redactError, debug } from "./lib/redact.js";
|
|
23
|
+
|
|
24
|
+
const TOOLS = [
|
|
25
|
+
{
|
|
26
|
+
name: "everme_search",
|
|
27
|
+
description:
|
|
28
|
+
"Search EverMe memories from past sessions. Returns ranked memory items with subject, summary, and relevance score. Use when the user asks about previous work, decisions, or context. Params: query (required), topK (default 10, max 25).",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
query: { type: "string", description: "Search query — keywords or a question" },
|
|
33
|
+
topK: { type: "number", description: "Max results (default 10, max 25)" },
|
|
34
|
+
},
|
|
35
|
+
required: ["query"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "everme_context",
|
|
40
|
+
description:
|
|
41
|
+
"Fetch the server-rendered context block (profile + recent episodes) the gateway uses for prompt injection. Useful when you want a single ready-to-paste summary. Params: query (optional), topK (default 5).",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
query: { type: "string", description: "Optional query for relevance-biased context" },
|
|
46
|
+
topK: { type: "number", description: "Max items to include (default 5)" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const handlers = {
|
|
53
|
+
initialize: () => ({
|
|
54
|
+
protocolVersion: "2024-11-05",
|
|
55
|
+
capabilities: { tools: { listChanged: false } },
|
|
56
|
+
serverInfo: { name: "everme", version: "0.1.0" },
|
|
57
|
+
}),
|
|
58
|
+
"tools/list": () => ({ tools: TOOLS }),
|
|
59
|
+
"tools/call": async (params) => {
|
|
60
|
+
const name = params?.name;
|
|
61
|
+
const args = params?.arguments || {};
|
|
62
|
+
if (!isConfigured()) {
|
|
63
|
+
return errResp("EverMe not configured: set EVERME_API_KEY (emk) or EVERME_AGENT_TOKEN");
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
switch (name) {
|
|
67
|
+
case "everme_search": {
|
|
68
|
+
const topK = Math.min(Number(args.topK) || 10, 25);
|
|
69
|
+
const res = await searchMemories(String(args.query || ""), { topK });
|
|
70
|
+
return ok(JSON.stringify(res, null, 2));
|
|
71
|
+
}
|
|
72
|
+
case "everme_context": {
|
|
73
|
+
const topK = Number(args.topK) || 5;
|
|
74
|
+
const res = await getContext(String(args.query || ""), { topK });
|
|
75
|
+
return ok(JSON.stringify(res, null, 2));
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return errResp(`unknown tool: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const safe = redactError(err instanceof EvermeError ? err.message : err?.message || String(err));
|
|
82
|
+
debug("mcp", `tool ${name} failed:`, safe);
|
|
83
|
+
return errResp(safe);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function ok(text) {
|
|
89
|
+
return { content: [{ type: "text", text: String(text ?? "") }] };
|
|
90
|
+
}
|
|
91
|
+
function errResp(msg) {
|
|
92
|
+
return { isError: true, content: [{ type: "text", text: `error: ${msg}` }] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
96
|
+
rl.on("line", async (line) => {
|
|
97
|
+
let req;
|
|
98
|
+
try {
|
|
99
|
+
req = JSON.parse(line);
|
|
100
|
+
} catch {
|
|
101
|
+
return; // ignore malformed lines
|
|
102
|
+
}
|
|
103
|
+
const handler = handlers[req?.method];
|
|
104
|
+
if (!handler) {
|
|
105
|
+
if (req?.id != null) {
|
|
106
|
+
respond(req.id, undefined, { code: -32601, message: `method not found: ${req?.method}` });
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const result = await handler(req?.params);
|
|
112
|
+
if (req?.id != null) respond(req.id, result);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (req?.id != null) {
|
|
115
|
+
respond(req.id, undefined, {
|
|
116
|
+
code: -32000,
|
|
117
|
+
message: redactError(err?.message || String(err)),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function respond(id, result, error) {
|
|
124
|
+
const env = error
|
|
125
|
+
? { jsonrpc: "2.0", id, error }
|
|
126
|
+
: { jsonrpc: "2.0", id, result };
|
|
127
|
+
process.stdout.write(JSON.stringify(env) + "\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
debug("mcp", "everme MCP server ready");
|