@rvboris/opencode-mempalace 0.4.0 → 0.5.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.
@@ -1,12 +1,91 @@
1
- import { spawn } from "node:child_process";
2
- import path from "node:path";
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path, { delimiter } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { DEFAULT_ADAPTER_TIMEOUT_MS, ENV_KEYS, TOOL_ERROR_MESSAGES } from "./constants";
5
6
  const getAdapterPath = () => {
6
7
  const here = path.dirname(fileURLToPath(import.meta.url));
7
8
  return path.resolve(here, "..", "..", "bridge", "mempalace_adapter.py");
8
9
  };
9
- const getPythonCommand = () => process.env[ENV_KEYS.adapterPython] || "python";
10
+ const PYTHON_BINARIES = process.platform === "win32" ? ["python.exe", "python3.exe", "python"] : ["python3", "python"];
11
+ const MEMPALACE_BINARIES = process.platform === "win32" ? ["mempalace.exe"] : ["mempalace"];
12
+ /** Find an executable by name somewhere on $PATH without spawning a shell. */
13
+ const findOnPath = (name) => {
14
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
15
+ for (const dir of dirs) {
16
+ try {
17
+ const candidate = path.join(dir, name);
18
+ if (existsSync(candidate))
19
+ return candidate;
20
+ }
21
+ catch {
22
+ // unreadable / permission issues — keep scanning
23
+ }
24
+ }
25
+ return null;
26
+ };
27
+ /** Extract the interpreter path from a script's shebang line, if absolute. */
28
+ const readShebang = (file) => {
29
+ try {
30
+ const firstLine = readFileSync(file, { encoding: "utf8", flag: "r" }).split("\n", 1)[0];
31
+ if (!firstLine.startsWith("#!"))
32
+ return null;
33
+ const parts = firstLine.slice(2).trim().split(/\s+/);
34
+ // Skip `#!/usr/bin/env python` — can't resolve to an absolute path here;
35
+ // the $PATH fallback below handles it.
36
+ if (parts[0] === "env" || parts[0].endsWith("/env"))
37
+ return null;
38
+ return parts[0] || null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ };
44
+ let resolvedPython;
45
+ /**
46
+ * Resolve the Python interpreter that owns the `mempalace` package.
47
+ *
48
+ * Resolution order:
49
+ * 1. `MEMPALACE_ADAPTER_PYTHON` env (explicit override).
50
+ * 2. The shebang of the `mempalace` CLI on $PATH — a console_script always
51
+ * points at the interpreter that installed the package (pipx, uv-tool,
52
+ * pip --user, or a venv), so this is the most reliable auto-detection.
53
+ * 3. `python3` then `python` on $PATH (best-effort; may still lack mempalace).
54
+ *
55
+ * Returns null if nothing usable was found.
56
+ */
57
+ export const resolvePython = () => {
58
+ if (resolvedPython !== undefined)
59
+ return resolvedPython;
60
+ const envPy = process.env[ENV_KEYS.adapterPython];
61
+ if (envPy?.trim()) {
62
+ resolvedPython = envPy;
63
+ return envPy;
64
+ }
65
+ for (const name of MEMPALACE_BINARIES) {
66
+ const bin = findOnPath(name);
67
+ if (!bin)
68
+ continue;
69
+ const shebangPy = readShebang(bin);
70
+ if (shebangPy && existsSync(shebangPy)) {
71
+ resolvedPython = shebangPy;
72
+ return shebangPy;
73
+ }
74
+ }
75
+ for (const name of PYTHON_BINARIES) {
76
+ if (findOnPath(name)) {
77
+ resolvedPython = name;
78
+ return name;
79
+ }
80
+ }
81
+ resolvedPython = null;
82
+ return null;
83
+ };
84
+ /** Test-only: clear the cached interpreter so env/PATH changes are re-read. */
85
+ export const resetPythonResolver = () => {
86
+ resolvedPython = undefined;
87
+ };
88
+ const getPythonCommand = () => resolvePython() ?? "python";
10
89
  const getAdapterTimeoutMs = () => {
11
90
  const raw = process.env[ENV_KEYS.adapterTimeoutMs];
12
91
  if (!raw)
@@ -23,67 +102,112 @@ const getStderrSnippet = (value) => {
23
102
  return "";
24
103
  return normalized.length > 240 ? `${normalized.slice(0, 240)}...` : normalized;
25
104
  };
26
- export const executeAdapter = async (_shell, payload, retries = 3) => {
27
- let lastError;
105
+ const WRITE_LIKE_MODES = new Set([
106
+ "save",
107
+ "mine_messages",
108
+ "diary_write",
109
+ "kg_add",
110
+ "checkpoint",
111
+ "delete",
112
+ "delete_by_source",
113
+ ]);
114
+ const LOCK_HELD_PATTERN = /(?:palace\s+.*\s+is\s+held\s+by\s+PID|held\s+by\s+PID)/i;
115
+ let writeLikeQueue = Promise.resolve();
116
+ let spawnCommand = nodeSpawn;
117
+ let adapterDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
118
+ export const setAdapterDelayForTests = (delay) => {
119
+ adapterDelay = delay;
120
+ };
121
+ export const setAdapterSpawnForTests = (spawn) => {
122
+ spawnCommand = spawn;
123
+ };
124
+ export const resetAdapterTestHooks = () => {
125
+ spawnCommand = nodeSpawn;
126
+ adapterDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
127
+ writeLikeQueue = Promise.resolve();
128
+ };
129
+ const isWriteLikeMode = (payload) => WRITE_LIKE_MODES.has(payload.mode);
130
+ const isLockHeldError = (error) => {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ return LOCK_HELD_PATTERN.test(message);
133
+ };
134
+ const executeAdapterProcess = async (payload) => {
28
135
  const timeoutMs = getAdapterTimeoutMs();
136
+ const { stderrText, stdoutText } = await new Promise((resolve, reject) => {
137
+ const child = spawnCommand(getPythonCommand(), [getAdapterPath()], {
138
+ stdio: ["pipe", "pipe", "pipe"],
139
+ });
140
+ let settled = false;
141
+ const stdout = [];
142
+ const stderr = [];
143
+ const timeoutId = setTimeout(() => {
144
+ child.kill();
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ reject(new Error(`${TOOL_ERROR_MESSAGES.adapterTimedOut} after ${timeoutMs}ms`));
149
+ }, timeoutMs);
150
+ const finish = (handler) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ clearTimeout(timeoutId);
155
+ handler();
156
+ };
157
+ child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
158
+ child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
159
+ child.on("error", (error) => finish(() => reject(error)));
160
+ child.on("close", (code) => {
161
+ finish(() => {
162
+ const stderrText = Buffer.concat(stderr).toString("utf8");
163
+ const stdoutText = Buffer.concat(stdout).toString("utf8");
164
+ if (code === 0) {
165
+ resolve({ stderrText, stdoutText });
166
+ return;
167
+ }
168
+ reject(new Error(stderrText || `Adapter exited with code ${code}`));
169
+ });
170
+ });
171
+ child.stdin.write(JSON.stringify(payload), "utf8");
172
+ child.stdin.end();
173
+ });
174
+ if (!stdoutText.trim()) {
175
+ const stderrSnippet = getStderrSnippet(stderrText);
176
+ throw new Error(stderrSnippet
177
+ ? `${TOOL_ERROR_MESSAGES.emptyAdapterStdout}: ${stderrSnippet}`
178
+ : TOOL_ERROR_MESSAGES.emptyAdapterStdout);
179
+ }
180
+ const parsed = JSON.parse(stdoutText);
181
+ if (!isAdapterResponse(parsed)) {
182
+ throw new Error(TOOL_ERROR_MESSAGES.invalidAdapterPayload);
183
+ }
184
+ return parsed;
185
+ };
186
+ const executeAdapterWithRetry = async (payload, retries) => {
187
+ let lastError;
29
188
  for (let attempt = 0; attempt <= retries; attempt += 1) {
30
189
  try {
31
- const { stderrText, stdoutText } = await new Promise((resolve, reject) => {
32
- const child = spawn(getPythonCommand(), [getAdapterPath()], {
33
- stdio: ["pipe", "pipe", "pipe"],
34
- });
35
- let settled = false;
36
- const stdout = [];
37
- const stderr = [];
38
- const timeoutId = setTimeout(() => {
39
- child.kill();
40
- if (settled)
41
- return;
42
- settled = true;
43
- reject(new Error(`${TOOL_ERROR_MESSAGES.adapterTimedOut} after ${timeoutMs}ms`));
44
- }, timeoutMs);
45
- const finish = (handler) => {
46
- if (settled)
47
- return;
48
- settled = true;
49
- clearTimeout(timeoutId);
50
- handler();
51
- };
52
- child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
53
- child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
54
- child.on("error", (error) => finish(() => reject(error)));
55
- child.on("close", (code) => {
56
- finish(() => {
57
- const stderrText = Buffer.concat(stderr).toString("utf8");
58
- const stdoutText = Buffer.concat(stdout).toString("utf8");
59
- if (code === 0) {
60
- resolve({ stderrText, stdoutText });
61
- return;
62
- }
63
- reject(new Error(stderrText || `Adapter exited with code ${code}`));
64
- });
65
- });
66
- child.stdin.write(JSON.stringify(payload), "utf8");
67
- child.stdin.end();
68
- });
69
- if (!stdoutText.trim()) {
70
- const stderrSnippet = getStderrSnippet(stderrText);
71
- throw new Error(stderrSnippet
72
- ? `${TOOL_ERROR_MESSAGES.emptyAdapterStdout}: ${stderrSnippet}`
73
- : TOOL_ERROR_MESSAGES.emptyAdapterStdout);
74
- }
75
- const parsed = JSON.parse(stdoutText);
76
- if (!isAdapterResponse(parsed)) {
77
- throw new Error(TOOL_ERROR_MESSAGES.invalidAdapterPayload);
78
- }
79
- return parsed;
190
+ return await executeAdapterProcess(payload);
80
191
  }
81
192
  catch (error) {
82
193
  lastError = error;
83
- if (attempt === retries)
194
+ if (attempt === retries || !isLockHeldError(error))
84
195
  throw error;
85
- await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
196
+ await adapterDelay(50 * (attempt + 1));
86
197
  }
87
198
  }
88
199
  throw lastError;
89
200
  };
201
+ const enqueueWriteLike = (operation) => {
202
+ const run = writeLikeQueue.then(operation, operation);
203
+ writeLikeQueue = run.catch(() => undefined);
204
+ return run;
205
+ };
206
+ export const executeAdapter = async (_shell, payload, retries = 3) => {
207
+ // Fail fast with an actionable message if no usable interpreter was found.
208
+ if (resolvePython() === null) {
209
+ throw new Error(TOOL_ERROR_MESSAGES.pythonNotFound);
210
+ }
211
+ const operation = () => executeAdapterWithRetry(payload, retries);
212
+ return isWriteLikeMode(payload) ? enqueueWriteLike(operation) : operation();
213
+ };
@@ -44,4 +44,5 @@ export declare const clearKeywordSavePending: (sessionId: string) => void;
44
44
  export declare const extractLastUserMessage: (messages: readonly MessageLike[] | null | undefined) => string;
45
45
  export declare const markFailed: (sessionId: string) => SessionAutosaveState;
46
46
  export declare const shouldScheduleAutosave: (sessionId: string, userDigest: string, transcriptDigest: string) => boolean;
47
+ export declare const buildAutosaveMiningTranscript: (transcript: string) => string;
47
48
  export declare const buildTranscriptText: (messages: readonly MessageLike[] | null | undefined) => string;
@@ -175,6 +175,29 @@ export const shouldScheduleAutosave = (sessionId, userDigest, transcriptDigest)
175
175
  return false;
176
176
  return true;
177
177
  };
178
+ const countSignal = (text) => {
179
+ const tokens = text.match(/[\p{L}\p{N}][\p{L}\p{N}_'-]*/gu) ?? [];
180
+ const alnum = text.match(/[\p{L}\p{N}]/gu)?.length ?? 0;
181
+ return { alnum, tokens: tokens.length };
182
+ };
183
+ const stripTranscriptRole = (block) => block.replace(/^[A-Z_]+:\s*/, "");
184
+ const hasLineSignal = (text) => {
185
+ const signal = countSignal(text);
186
+ return signal.alnum >= 3;
187
+ };
188
+ const hasCombinedAutosaveSignal = (text) => {
189
+ const signal = countSignal(text);
190
+ return signal.alnum >= 5 && signal.tokens >= 2;
191
+ };
192
+ export const buildAutosaveMiningTranscript = (transcript) => {
193
+ const blocks = transcript
194
+ .split(/\n{2,}/)
195
+ .map((block) => block.trim())
196
+ .filter((block) => block && hasLineSignal(stripTranscriptRole(block)));
197
+ const filtered = blocks.join("\n\n").trim();
198
+ const combinedText = blocks.map(stripTranscriptRole).join("\n");
199
+ return hasCombinedAutosaveSignal(combinedText) ? filtered : "";
200
+ };
178
201
  export const buildTranscriptText = (messages) => {
179
202
  const lines = [];
180
203
  for (const message of messages ?? []) {
@@ -9,6 +9,8 @@ export declare const DEFAULT_ADAPTER_TIMEOUT_MS: 15000;
9
9
  export declare const DEFAULT_USER_WING_PREFIX: "wing_user";
10
10
  export declare const DEFAULT_PROJECT_WING_PREFIX: "wing_project";
11
11
  export declare const DEFAULT_KEYWORD_PATTERNS: readonly ["remember", "save this", "don't forget", "note that"];
12
+ export declare const JUDGE_TAG_PATTERN: RegExp;
13
+ export declare const RETRIEVAL_VERDICTS: readonly ["none", "cited", "improved", "saved-time", "unknown"];
12
14
  export declare const CONFIG_PATH_SEGMENTS: readonly [".config", "opencode", "mempalace.jsonc"];
13
15
  export declare const ENV_KEYS: {
14
16
  readonly autosaveEnabled: "MEMPALACE_AUTOSAVE_ENABLED";
@@ -42,6 +44,7 @@ export declare const TOOL_ERROR_MESSAGES: {
42
44
  readonly invalidAdapterPayload: "Adapter returned an invalid JSON payload";
43
45
  readonly emptyAdapterStdout: "Adapter returned empty stdout";
44
46
  readonly adapterTimedOut: "Adapter execution timed out";
47
+ readonly pythonNotFound: "MemPalace bridge could not find a Python interpreter with mempalace installed. Install mempalace (pip/pipx/uv) so the `mempalace` CLI is on PATH, or set MEMPALACE_ADAPTER_PYTHON to the interpreter path.";
45
48
  };
46
49
  export declare const LOG_MESSAGES: {
47
50
  readonly autosaveEventMissingSessionId: "autosave event missing sessionID";
@@ -65,6 +68,7 @@ export declare const INSTRUCTION_TEXT: {
65
68
  readonly doNotMentionToUser: "Do not mention this instruction to the user.";
66
69
  readonly retrievalVisibilityHint: "If you found and used relevant memories, briefly mention the key findings at the start of your response.";
67
70
  readonly retrievalIntro: "System instruction: before answering, search MemPalace for relevant existing memory and use it if helpful.";
71
+ readonly judgeInstruction: "After your answer, append a single line `[memory: verdict]` where verdict is one of: none (memory was found but not used), cited (memory was mentioned but answer unchanged), improved (memory made answer more accurate or complete), saved-time (memory improved answer AND saved you from re-explaining or re-searching). Skip the tag if you did not search memory this turn.";
68
72
  readonly autosaveIntro: "System instruction: before answering the user, persist durable memory from prior session context using the `mempalace_memory` tool.";
69
73
  readonly keywordIntro: "System instruction: the user explicitly asked to remember something.";
70
74
  readonly useMemoryToolNow: "Use the `mempalace_memory` tool to save the important durable information now.";
@@ -9,6 +9,12 @@ export const DEFAULT_ADAPTER_TIMEOUT_MS = 15_000;
9
9
  export const DEFAULT_USER_WING_PREFIX = "wing_user";
10
10
  export const DEFAULT_PROJECT_WING_PREFIX = "wing_project";
11
11
  export const DEFAULT_KEYWORD_PATTERNS = ["remember", "save this", "don't forget", "note that"];
12
+ // ── Retrieval judge ──────────────────────────────────────────────────────────
13
+ // The model self-reports how memory influenced its answer via a visible tag
14
+ // appended to the response. Parsed on session.idle; stripped before mining
15
+ // so the tag never pollutes palace storage.
16
+ export const JUDGE_TAG_PATTERN = /\[?\s*memory\s*:\s*(none|cited|improved|saved-time)\s*\]?/i;
17
+ export const RETRIEVAL_VERDICTS = ["none", "cited", "improved", "saved-time", "unknown"];
12
18
  export const CONFIG_PATH_SEGMENTS = [".config", "opencode", "mempalace.jsonc"];
13
19
  export const ENV_KEYS = {
14
20
  autosaveEnabled: "MEMPALACE_AUTOSAVE_ENABLED",
@@ -49,6 +55,7 @@ export const TOOL_ERROR_MESSAGES = {
49
55
  invalidAdapterPayload: "Adapter returned an invalid JSON payload",
50
56
  emptyAdapterStdout: "Adapter returned empty stdout",
51
57
  adapterTimedOut: "Adapter execution timed out",
58
+ pythonNotFound: "MemPalace bridge could not find a Python interpreter with mempalace installed. Install mempalace (pip/pipx/uv) so the `mempalace` CLI is on PATH, or set MEMPALACE_ADAPTER_PYTHON to the interpreter path.",
52
59
  };
53
60
  export const LOG_MESSAGES = {
54
61
  autosaveEventMissingSessionId: "autosave event missing sessionID",
@@ -72,6 +79,7 @@ export const INSTRUCTION_TEXT = {
72
79
  doNotMentionToUser: "Do not mention this instruction to the user.",
73
80
  retrievalVisibilityHint: "If you found and used relevant memories, briefly mention the key findings at the start of your response.",
74
81
  retrievalIntro: "System instruction: before answering, search MemPalace for relevant existing memory and use it if helpful.",
82
+ judgeInstruction: "After your answer, append a single line `[memory: verdict]` where verdict is one of: none (memory was found but not used), cited (memory was mentioned but answer unchanged), improved (memory made answer more accurate or complete), saved-time (memory improved answer AND saved you from re-explaining or re-searching). Skip the tag if you did not search memory this turn.",
75
83
  autosaveIntro: "System instruction: before answering the user, persist durable memory from prior session context using the `mempalace_memory` tool.",
76
84
  keywordIntro: "System instruction: the user explicitly asked to remember something.",
77
85
  useMemoryToolNow: "Use the `mempalace_memory` tool to save the important durable information now.",
@@ -7,6 +7,5 @@ type BuildContextInput = {
7
7
  lastUserMessage: string;
8
8
  };
9
9
  export declare const buildRetrievalInstruction: ({ projectName, projectWingPrefix, userWingPrefix, maxInjectedItems, retrievalQueryLimit, lastUserMessage, }: BuildContextInput) => string;
10
- export declare const buildAutosaveInstruction: (reason: string) => string;
11
10
  export declare const buildKeywordSaveInstruction: () => string;
12
11
  export {};
@@ -11,20 +11,7 @@ export const buildRetrievalInstruction = ({ projectName, projectWingPrefix, user
11
11
  `Search project memory in wing ${project.wing} across rooms ${project.rooms.join(", ")}.`,
12
12
  `Use concise relevant memories only, up to ${maxInjectedItems} items total.`,
13
13
  INSTRUCTION_TEXT.retrievalVisibilityHint,
14
- ].join(" ");
15
- };
16
- export const buildAutosaveInstruction = (reason) => {
17
- return [
18
- INSTRUCTION_TEXT.autosaveIntro,
19
- INSTRUCTION_TEXT.avoidRawMutationTools,
20
- `Trigger reason: ${reason}.`,
21
- INSTRUCTION_TEXT.saveStableFacts,
22
- INSTRUCTION_TEXT.preferConciseStructuredMemories,
23
- INSTRUCTION_TEXT.userScopeHint,
24
- INSTRUCTION_TEXT.projectScopeHint,
25
- INSTRUCTION_TEXT.avoidFullTranscript,
26
- INSTRUCTION_TEXT.applyPrivacyRedaction,
27
- INSTRUCTION_TEXT.doNotMentionToUser,
14
+ INSTRUCTION_TEXT.judgeInstruction,
28
15
  ].join(" ");
29
16
  };
30
17
  export const buildKeywordSaveInstruction = () => {
@@ -1 +1,3 @@
1
1
  export declare const sanitizeText: (text: string) => string;
2
+ export declare const stripJudgeTag: (text: string) => string;
3
+ export declare const parseJudgeTag: (text: string) => string | null;
@@ -1,6 +1,8 @@
1
+ import { JUDGE_TAG_PATTERN } from "./constants";
1
2
  const ANSI_PATTERN = /\u001B(?:\][^\u0007]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
2
3
  const ZERO_WIDTH_PATTERN = /[\u200B-\u200D\uFEFF]/g;
3
4
  const CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
5
+ const JUDGE_TAG_STRIP_PATTERN = /\n*\[?\s*memory\s*:\s*(?:none|cited|improved|saved-time)\s*\]?\s*$/i;
4
6
  const stripInvalidSurrogates = (text) => {
5
7
  let result = "";
6
8
  for (let i = 0; i < text.length; i += 1) {
@@ -34,3 +36,22 @@ export const sanitizeText = (text) => {
34
36
  .replace(ZERO_WIDTH_PATTERN, "")
35
37
  .replace(CONTROL_PATTERN, "");
36
38
  };
39
+ export const stripJudgeTag = (text) => {
40
+ if (!text)
41
+ return text;
42
+ return text.replace(JUDGE_TAG_STRIP_PATTERN, "").trimEnd();
43
+ };
44
+ export const parseJudgeTag = (text) => {
45
+ if (!text)
46
+ return null;
47
+ // Find all matches, return the last verdict (transcript may contain tags from prior turns)
48
+ const global = new RegExp(JUDGE_TAG_PATTERN.source, "gi");
49
+ let last = null;
50
+ for (;;) {
51
+ const m = global.exec(text);
52
+ if (m === null)
53
+ break;
54
+ last = m[1].toLowerCase();
55
+ }
56
+ return last;
57
+ };
@@ -1,8 +1,15 @@
1
- import type { AdapterResponse, MemoryScope } from "./types";
1
+ import type { AdapterResponse, MemoryScope, RetrievalVerdict } from "./types";
2
+ declare const DEFAULT_JUDGE_COUNTERS: () => {
3
+ none: number;
4
+ cited: number;
5
+ improved: number;
6
+ savedTime: number;
7
+ unknown: number;
8
+ };
2
9
  export type StatusCounters = {
3
10
  retrievalPrompts: number;
4
11
  retrievalSearches: number;
5
- retrievalHits: number;
12
+ retrievalJudge: ReturnType<typeof DEFAULT_JUDGE_COUNTERS>;
6
13
  autosavesCompleted: number;
7
14
  autosavesSkipped: number;
8
15
  autosavesFailed: number;
@@ -10,7 +17,7 @@ export type StatusCounters = {
10
17
  };
11
18
  export type StatusSessionCounters = {
12
19
  retrievalSearches: number;
13
- retrievalHits: number;
20
+ retrievalJudge: ReturnType<typeof DEFAULT_JUDGE_COUNTERS>;
14
21
  autosavesCompleted: number;
15
22
  autosavesSkipped: number;
16
23
  autosavesFailed: number;
@@ -81,6 +88,10 @@ export declare const recordRetrievalSearch: (input: {
81
88
  query: string;
82
89
  result: AdapterResponse;
83
90
  }) => Promise<void>;
91
+ export declare const recordRetrievalJudge: (input: {
92
+ sessionId?: string;
93
+ verdict: RetrievalVerdict;
94
+ }) => Promise<void>;
84
95
  export declare const recordMemoryWrite: (input: {
85
96
  sessionId?: string;
86
97
  mode: "save" | "kg_add" | "diary_write";
@@ -2,12 +2,19 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { ENV_KEYS, STATUS_FILE_NAME } from "./constants";
5
+ const DEFAULT_JUDGE_COUNTERS = () => ({
6
+ none: 0,
7
+ cited: 0,
8
+ improved: 0,
9
+ savedTime: 0,
10
+ unknown: 0,
11
+ });
5
12
  const MAX_SESSION_IDS = 50;
6
13
  const MAX_PREVIEWS = 3;
7
14
  const MAX_PREVIEW_LENGTH = 140;
8
15
  const DEFAULT_SESSION_COUNTERS = () => ({
9
16
  retrievalSearches: 0,
10
- retrievalHits: 0,
17
+ retrievalJudge: DEFAULT_JUDGE_COUNTERS(),
11
18
  autosavesCompleted: 0,
12
19
  autosavesSkipped: 0,
13
20
  autosavesFailed: 0,
@@ -23,7 +30,7 @@ const DEFAULT_STATE = () => ({
23
30
  counters: {
24
31
  retrievalPrompts: 0,
25
32
  retrievalSearches: 0,
26
- retrievalHits: 0,
33
+ retrievalJudge: DEFAULT_JUDGE_COUNTERS(),
27
34
  autosavesCompleted: 0,
28
35
  autosavesSkipped: 0,
29
36
  autosavesFailed: 0,
@@ -58,6 +65,14 @@ const readStringArray = (value) => {
58
65
  return [];
59
66
  return value.map((item) => normalizeString(item)).filter((item) => Boolean(item));
60
67
  };
68
+ const readJudgeCounters = (value) => {
69
+ const input = isRecord(value) ? value : {};
70
+ const rc = (key) => {
71
+ const item = input[key];
72
+ return typeof item === "number" && Number.isFinite(item) && item >= 0 ? Math.floor(item) : 0;
73
+ };
74
+ return { none: rc("none"), cited: rc("cited"), improved: rc("improved"), savedTime: rc("savedTime"), unknown: rc("unknown") };
75
+ };
61
76
  const readCounters = (value) => {
62
77
  const input = isRecord(value) ? value : {};
63
78
  const readCount = (key) => {
@@ -67,7 +82,7 @@ const readCounters = (value) => {
67
82
  return {
68
83
  retrievalPrompts: readCount("retrievalPrompts"),
69
84
  retrievalSearches: readCount("retrievalSearches"),
70
- retrievalHits: readCount("retrievalHits"),
85
+ retrievalJudge: readJudgeCounters(input.retrievalJudge),
71
86
  autosavesCompleted: readCount("autosavesCompleted"),
72
87
  autosavesSkipped: readCount("autosavesSkipped"),
73
88
  autosavesFailed: readCount("autosavesFailed"),
@@ -82,7 +97,7 @@ const readSessionCounters = (value) => {
82
97
  };
83
98
  return {
84
99
  retrievalSearches: readCount("retrievalSearches"),
85
- retrievalHits: readCount("retrievalHits"),
100
+ retrievalJudge: readJudgeCounters(input.retrievalJudge),
86
101
  autosavesCompleted: readCount("autosavesCompleted"),
87
102
  autosavesSkipped: readCount("autosavesSkipped"),
88
103
  autosavesFailed: readCount("autosavesFailed"),
@@ -305,10 +320,6 @@ export const recordRetrievalSearch = async (input) => {
305
320
  const summary = summarizeSearchResult(input.result);
306
321
  await updateState((state) => {
307
322
  state.counters.retrievalSearches += 1;
308
- if ((summary.resultCount ?? 0) > 0) {
309
- state.counters.retrievalHits += 1;
310
- addHelpedSession(state, input.sessionId);
311
- }
312
323
  state.lastRetrieval = {
313
324
  sessionId: input.sessionId,
314
325
  timestamp: new Date().toISOString(),
@@ -320,13 +331,22 @@ export const recordRetrievalSearch = async (input) => {
320
331
  };
321
332
  updateSessionState(state, input.sessionId, (sessionState) => {
322
333
  sessionState.counters.retrievalSearches += 1;
323
- if ((summary.resultCount ?? 0) > 0) {
324
- sessionState.counters.retrievalHits += 1;
325
- }
326
334
  sessionState.lastRetrieval = state.lastRetrieval;
327
335
  });
328
336
  });
329
337
  };
338
+ export const recordRetrievalJudge = async (input) => {
339
+ await updateState((state) => {
340
+ const key = input.verdict === "saved-time" ? "savedTime" : input.verdict;
341
+ state.counters.retrievalJudge[key] += 1;
342
+ if (input.verdict === "improved" || input.verdict === "saved-time") {
343
+ addHelpedSession(state, input.sessionId);
344
+ }
345
+ updateSessionState(state, input.sessionId, (sessionState) => {
346
+ sessionState.counters.retrievalJudge[key] += 1;
347
+ });
348
+ });
349
+ };
330
350
  export const recordMemoryWrite = async (input) => {
331
351
  await updateState((state) => {
332
352
  state.counters.manualWrites += 1;
@@ -389,25 +409,49 @@ const isCurrentSession = (expectedSessionId, actualSessionId) => {
389
409
  export const formatSessionHud = (state, sessionId) => {
390
410
  const sessionState = state.sessions[sessionId];
391
411
  if (!sessionState)
392
- return "MEM no activity yet";
393
- const parts = [
394
- `hits ${sessionState.counters.retrievalHits}`,
395
- `saved ${sessionState.counters.autosavesCompleted}`,
396
- `failed ${sessionState.counters.autosavesFailed}`,
397
- ];
398
- if (sessionState.counters.autosavesSkipped > 0) {
399
- parts.push(`skipped ${sessionState.counters.autosavesSkipped}`);
412
+ return "MEM quiet";
413
+ const j = sessionState.counters.retrievalJudge;
414
+ const helps = j.improved + j.savedTime;
415
+ const judged = j.none + j.cited + j.improved + j.savedTime;
416
+ let label;
417
+ if (helps > 0) {
418
+ label = `MEM helps ${helps}`;
400
419
  }
401
- if (sessionState.counters.manualWrites > 0) {
402
- parts.push(`writes ${sessionState.counters.manualWrites}`);
420
+ else if (j.cited > 0) {
421
+ label = `MEM cited ${j.cited}`;
403
422
  }
404
- if (sessionState.lastAutosave?.outcome === "failed") {
405
- return `MEM FAILED · ${parts.join(" · ")}`;
423
+ else if (judged > 0) {
424
+ label = "MEM no help";
406
425
  }
407
- if (sessionState.lastAutosave?.outcome === "skipped") {
408
- return `MEM SKIPPED · ${parts.join(" · ")}`;
426
+ else if (j.unknown > 0) {
427
+ label = "MEM unknown";
428
+ }
429
+ else if (sessionState.lastRetrieval) {
430
+ const count = sessionState.lastRetrieval.resultCount;
431
+ if (count && count > 0) {
432
+ label = `MEM found ${count}`;
433
+ }
434
+ else if (count === 0) {
435
+ label = "MEM no hits";
436
+ }
437
+ else {
438
+ label = "MEM searched";
439
+ }
440
+ }
441
+ else if (sessionState.counters.retrievalSearches > 0) {
442
+ label = "MEM searched";
443
+ }
444
+ else {
445
+ label = "MEM quiet";
446
+ }
447
+ const flags = [];
448
+ if (sessionState.counters.autosavesFailed > 0) {
449
+ flags.push(`fail ${sessionState.counters.autosavesFailed}`);
450
+ }
451
+ if (sessionState.counters.autosavesSkipped > 0) {
452
+ flags.push(`skip ${sessionState.counters.autosavesSkipped}`);
409
453
  }
410
- return `MEM ${parts.join(" · ")}`;
454
+ return flags.length ? `${label} · ${flags.join(" · ")}` : label;
411
455
  };
412
456
  const pushRetrievalLines = (lines, retrieval, verbose) => {
413
457
  if ("scope" in retrieval) {
@@ -527,8 +571,9 @@ export const formatStatusSummary = (state, sessionId, options = {}) => {
527
571
  }
528
572
  lines.push("- Totals:");
529
573
  lines.push(` retrieval prompts: ${state.counters.retrievalPrompts}`);
574
+ const j = state.counters.retrievalJudge;
530
575
  lines.push(` retrieval searches: ${state.counters.retrievalSearches}`);
531
- lines.push(` retrieval hits: ${state.counters.retrievalHits}`);
576
+ lines.push(` retrieval judge: none ${j.none} · cited ${j.cited} · improved ${j.improved} · saved-time ${j.savedTime} · unknown ${j.unknown}`);
532
577
  lines.push(` autosaves completed: ${state.counters.autosavesCompleted}`);
533
578
  lines.push(` autosaves skipped: ${state.counters.autosavesSkipped}`);
534
579
  lines.push(` autosaves failed: ${state.counters.autosavesFailed}`);