oh-memos-mcp 3.0.1 → 3.1.1
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/access-tracker.d.ts +22 -0
- package/dist/access-tracker.js +187 -0
- package/dist/formatters.d.ts +18 -0
- package/dist/formatters.js +52 -4
- package/dist/handlers/graph.d.ts +31 -0
- package/dist/handlers/graph.js +173 -90
- package/dist/handlers/memory.d.ts +16 -0
- package/dist/handlers/memory.js +128 -22
- package/dist/handlers/search.d.ts +43 -0
- package/dist/handlers/search.js +228 -27
- package/dist/memory-quality.d.ts +15 -0
- package/dist/memory-quality.js +314 -13
- package/dist/memory-tier.d.ts +56 -0
- package/dist/memory-tier.js +68 -0
- package/dist/models.js +10 -3
- package/dist/spreading-activation.d.ts +157 -0
- package/dist/spreading-activation.js +205 -0
- package/dist/types.d.ts +7 -0
- package/package.json +68 -66
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface AccessStat {
|
|
2
|
+
count: number;
|
|
3
|
+
lastAt: string;
|
|
4
|
+
}
|
|
5
|
+
/** id → 统计。查不到即表示从未被显式读取过。 */
|
|
6
|
+
export type AccessStats = ReadonlyMap<string, AccessStat>;
|
|
7
|
+
/**
|
|
8
|
+
* 读取某个 cube 的访问统计。任何失败都返回空 map —— 使用度是软数据,
|
|
9
|
+
* 读不到只意味着「没有强化信号」,绝不该让检索失败。
|
|
10
|
+
*/
|
|
11
|
+
export declare function readAccessStats(cubesDir: string, cubeId: string): AccessStats;
|
|
12
|
+
/**
|
|
13
|
+
* 记一次显式读取。**永不抛异常** —— 追踪是尽力而为的旁路,
|
|
14
|
+
* 磁盘满、权限不足、目录不存在都不该影响 memos_get 的返回。
|
|
15
|
+
*
|
|
16
|
+
* 单次 append 的载荷远小于 4 KB,在 POSIX 与 Windows 上都是原子写入,
|
|
17
|
+
* 因此多进程并发追加不需要加锁。
|
|
18
|
+
*/
|
|
19
|
+
export declare function recordAccess(cubesDir: string, cubeId: string, ids: readonly string[]): void;
|
|
20
|
+
/** 仅供测试:清掉进程内缓存,让下一次读取真的落盘。 */
|
|
21
|
+
export declare function resetAccessCache(): void;
|
|
22
|
+
//# sourceMappingURL=access-tracker.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆访问追踪 —— 让 memoryQualityScore 的 reinforcement 项真正有数据。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么是本地 sidecar,而不是回写记忆存储
|
|
5
|
+
*
|
|
6
|
+
* 直接给记忆记录加 access_count 会让检索从只读变成读写:Full 模式需要一个
|
|
7
|
+
* 不存在的后端端点;Lite 模式要在每次检索后重写 memories.jsonl,与既有的
|
|
8
|
+
* withLock 抢锁(多 agent 并发检索同一 cube 就会 `store lock held`)。
|
|
9
|
+
*
|
|
10
|
+
* 这里改成本地 append-only 侧车日志:
|
|
11
|
+
* - 不碰记忆存储 → Full / Lite 行为一致,无需后端改动
|
|
12
|
+
* - 只追加、不读改写 → 不与 withLock 交互,并发安全
|
|
13
|
+
* - fire-and-forget → 追踪失败绝不让业务调用失败
|
|
14
|
+
*
|
|
15
|
+
* 语义上这也更诚实:访问次数是**本机使用度**,不是全局真相。同一条记忆在
|
|
16
|
+
* 另一台机器上的使用情况与本机无关,也不该影响本机排序。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么只在 memos_get 记账,不在 search 记账
|
|
19
|
+
*
|
|
20
|
+
* 若「出现在检索结果里」就记账,会形成正反馈:高分记忆更常被返回 → 拿到更多
|
|
21
|
+
* 访问计数 → 分数更高 → 更常被返回。富者愈富,且新记忆永远追不上。
|
|
22
|
+
*
|
|
23
|
+
* `memos_get(memory_id=...)` 是**明确的选择性读取** —— agent 从候选里挑了这一条
|
|
24
|
+
* 看全文。它不参与排序输入,因此不构成回路。信号更稀疏但无偏。
|
|
25
|
+
*
|
|
26
|
+
* 设计文档:docs/design/memory-retrieval-optimization.md 第 6.4 节
|
|
27
|
+
*/
|
|
28
|
+
import * as fs from "fs";
|
|
29
|
+
import * as path from "path";
|
|
30
|
+
const LOG_FILE = "access-log.jsonl";
|
|
31
|
+
/** 超过这个行数就压实成一条快照,把增长界定在「去重记忆数」而非「累计访问数」。 */
|
|
32
|
+
const COMPACT_AFTER_LINES = 500;
|
|
33
|
+
/** 缓存有效期。同一次会话里连续检索不必反复读盘。 */
|
|
34
|
+
const CACHE_TTL_MS = 5_000;
|
|
35
|
+
const EMPTY = new Map();
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
function logPath(cubesDir, cubeId) {
|
|
38
|
+
return path.join(cubesDir, cubeId, LOG_FILE);
|
|
39
|
+
}
|
|
40
|
+
function merge(target, id, at, count = 1) {
|
|
41
|
+
const prev = target.get(id);
|
|
42
|
+
if (!prev) {
|
|
43
|
+
target.set(id, { count, lastAt: at });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
target.set(id, {
|
|
47
|
+
count: prev.count + count,
|
|
48
|
+
lastAt: prev.lastAt > at ? prev.lastAt : at,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 解析一行日志并累加进 target。
|
|
53
|
+
*
|
|
54
|
+
* 两种行格式:
|
|
55
|
+
* {"t":"<iso>","ids":["a","b"]} —— 一次访问事件
|
|
56
|
+
* {"t":"<iso>","snapshot":{"a":[3,"<iso>"]}} —— 压实后的快照
|
|
57
|
+
*
|
|
58
|
+
* 无法解析的行**静默跳过**:并发追加理论上可能撕裂一行,跳过坏行让日志自愈,
|
|
59
|
+
* 而抛异常会让一个坏字节永久废掉整份使用度数据。
|
|
60
|
+
*/
|
|
61
|
+
function applyLine(target, line) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
70
|
+
return;
|
|
71
|
+
const record = parsed;
|
|
72
|
+
const snapshot = record.snapshot;
|
|
73
|
+
if (snapshot && typeof snapshot === "object") {
|
|
74
|
+
for (const [id, value] of Object.entries(snapshot)) {
|
|
75
|
+
if (!Array.isArray(value))
|
|
76
|
+
continue;
|
|
77
|
+
const count = Number(value[0]);
|
|
78
|
+
const at = String(value[1] ?? "");
|
|
79
|
+
if (!id || !Number.isFinite(count) || count <= 0 || !at)
|
|
80
|
+
continue;
|
|
81
|
+
merge(target, id, at, Math.floor(count));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const at = String(record.t ?? "");
|
|
86
|
+
const ids = record.ids;
|
|
87
|
+
if (!at || !Array.isArray(ids))
|
|
88
|
+
return;
|
|
89
|
+
for (const raw of ids) {
|
|
90
|
+
const id = String(raw ?? "").trim();
|
|
91
|
+
if (id)
|
|
92
|
+
merge(target, id, at, 1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function parseLog(raw) {
|
|
96
|
+
const stats = new Map();
|
|
97
|
+
for (const line of raw.split("\n")) {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
if (trimmed)
|
|
100
|
+
applyLine(stats, trimmed);
|
|
101
|
+
}
|
|
102
|
+
return stats;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 读取某个 cube 的访问统计。任何失败都返回空 map —— 使用度是软数据,
|
|
106
|
+
* 读不到只意味着「没有强化信号」,绝不该让检索失败。
|
|
107
|
+
*/
|
|
108
|
+
export function readAccessStats(cubesDir, cubeId) {
|
|
109
|
+
const file = logPath(cubesDir, cubeId);
|
|
110
|
+
const key = file;
|
|
111
|
+
try {
|
|
112
|
+
const stat = fs.statSync(file);
|
|
113
|
+
const hit = cache.get(key);
|
|
114
|
+
// mtime + size 双条件:单看 mtime 在粗粒度时间戳的文件系统上会漏掉同秒内的追加。
|
|
115
|
+
if (hit &&
|
|
116
|
+
hit.mtimeMs === stat.mtimeMs &&
|
|
117
|
+
hit.size === stat.size &&
|
|
118
|
+
Date.now() - hit.readAt < CACHE_TTL_MS) {
|
|
119
|
+
return hit.stats;
|
|
120
|
+
}
|
|
121
|
+
const stats = parseLog(fs.readFileSync(file, "utf8"));
|
|
122
|
+
cache.set(key, {
|
|
123
|
+
stats,
|
|
124
|
+
readAt: Date.now(),
|
|
125
|
+
size: stat.size,
|
|
126
|
+
mtimeMs: stat.mtimeMs,
|
|
127
|
+
});
|
|
128
|
+
return stats;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return EMPTY;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 把当前统计压实成单行快照。
|
|
136
|
+
*
|
|
137
|
+
* 写临时文件再 rename —— rename 在同一文件系统内是原子的(Windows 亦然,
|
|
138
|
+
* 用 fs.renameSync 覆盖目标)。两个进程同时压实时后者胜出,可能丢掉几条
|
|
139
|
+
* 刚追加的访问;对软数据可接受,换来的是日志大小有界。
|
|
140
|
+
*/
|
|
141
|
+
function compact(file, stats) {
|
|
142
|
+
const snapshot = {};
|
|
143
|
+
for (const [id, stat] of stats)
|
|
144
|
+
snapshot[id] = [stat.count, stat.lastAt];
|
|
145
|
+
const line = `${JSON.stringify({ t: new Date().toISOString(), snapshot })}\n`;
|
|
146
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
147
|
+
fs.writeFileSync(tmp, line, "utf8");
|
|
148
|
+
fs.renameSync(tmp, file);
|
|
149
|
+
}
|
|
150
|
+
function countLines(raw) {
|
|
151
|
+
let lines = 0;
|
|
152
|
+
for (const ch of raw)
|
|
153
|
+
if (ch === "\n")
|
|
154
|
+
lines += 1;
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 记一次显式读取。**永不抛异常** —— 追踪是尽力而为的旁路,
|
|
159
|
+
* 磁盘满、权限不足、目录不存在都不该影响 memos_get 的返回。
|
|
160
|
+
*
|
|
161
|
+
* 单次 append 的载荷远小于 4 KB,在 POSIX 与 Windows 上都是原子写入,
|
|
162
|
+
* 因此多进程并发追加不需要加锁。
|
|
163
|
+
*/
|
|
164
|
+
export function recordAccess(cubesDir, cubeId, ids) {
|
|
165
|
+
// trim 而非裸 filter(Boolean):全空白的 id 无意义,且带首尾空白的 id
|
|
166
|
+
// 会与 readAccessStats 的查找键不一致,导致记了账却查不到。
|
|
167
|
+
const clean = ids.map((id) => String(id ?? "").trim()).filter(Boolean);
|
|
168
|
+
if (clean.length === 0)
|
|
169
|
+
return;
|
|
170
|
+
const file = logPath(cubesDir, cubeId);
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
173
|
+
fs.appendFileSync(file, `${JSON.stringify({ t: new Date().toISOString(), ids: clean })}\n`, "utf8");
|
|
174
|
+
cache.delete(file);
|
|
175
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
176
|
+
if (countLines(raw) > COMPACT_AFTER_LINES)
|
|
177
|
+
compact(file, parseLog(raw));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 尽力而为:使用度数据的价值不足以让业务调用失败。
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** 仅供测试:清掉进程内缓存,让下一次读取真的落盘。 */
|
|
184
|
+
export function resetAccessCache() {
|
|
185
|
+
cache.clear();
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=access-tracker.js.map
|
package/dist/formatters.d.ts
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
* Formats memory search results and knowledge graphs for display.
|
|
5
5
|
*/
|
|
6
6
|
import type { SearchData, MemoryNode, GraphEdge } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* 附在 ID 行后的检索期注解。
|
|
9
|
+
*
|
|
10
|
+
* 为什么需要这个:quality policy 会往 metadata 写 `access_count`、`freshness`、
|
|
11
|
+
* `duplicates_folded`,但显示层原本只输出 cube / type / 首行 / ID —— 这些字段
|
|
12
|
+
* **算出来就被丢掉了**。后果分两类:
|
|
13
|
+
*
|
|
14
|
+
* 1. `duplicates_folded` 拿不到 → 设计文档声称「折叠后信息不丢,可用 memos_get
|
|
15
|
+
* 展开」,实际上 agent 根本看不到被折叠的 id。这是我自己引入的缺陷。
|
|
16
|
+
* 2. `freshness` 的 stale / expired 判定是可行动信号(该复核这条记忆了),
|
|
17
|
+
* 不显示等于白算。
|
|
18
|
+
*
|
|
19
|
+
* 抽成纯函数是为了可单测 —— formatMemoriesForDisplay 的分支被 handler 层包着,
|
|
20
|
+
* 上一轮 P1.5 的教训是改这个文件的东西会整体逃出单测。
|
|
21
|
+
*
|
|
22
|
+
* 输出刻意吝啬:只在字段真有内容时才占字符,全新且未被读过的记忆无任何附加。
|
|
23
|
+
*/
|
|
24
|
+
export declare function memoryAnnotations(metadata: unknown): string;
|
|
7
25
|
export declare function formatMemoriesForDisplay(data: SearchData): string;
|
|
8
26
|
export interface GraphCubeData {
|
|
9
27
|
cube_id?: string;
|
package/dist/formatters.js
CHANGED
|
@@ -6,6 +6,47 @@
|
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// Memory Display Formatter
|
|
8
8
|
// ============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* 附在 ID 行后的检索期注解。
|
|
11
|
+
*
|
|
12
|
+
* 为什么需要这个:quality policy 会往 metadata 写 `access_count`、`freshness`、
|
|
13
|
+
* `duplicates_folded`,但显示层原本只输出 cube / type / 首行 / ID —— 这些字段
|
|
14
|
+
* **算出来就被丢掉了**。后果分两类:
|
|
15
|
+
*
|
|
16
|
+
* 1. `duplicates_folded` 拿不到 → 设计文档声称「折叠后信息不丢,可用 memos_get
|
|
17
|
+
* 展开」,实际上 agent 根本看不到被折叠的 id。这是我自己引入的缺陷。
|
|
18
|
+
* 2. `freshness` 的 stale / expired 判定是可行动信号(该复核这条记忆了),
|
|
19
|
+
* 不显示等于白算。
|
|
20
|
+
*
|
|
21
|
+
* 抽成纯函数是为了可单测 —— formatMemoriesForDisplay 的分支被 handler 层包着,
|
|
22
|
+
* 上一轮 P1.5 的教训是改这个文件的东西会整体逃出单测。
|
|
23
|
+
*
|
|
24
|
+
* 输出刻意吝啬:只在字段真有内容时才占字符,全新且未被读过的记忆无任何附加。
|
|
25
|
+
*/
|
|
26
|
+
export function memoryAnnotations(metadata) {
|
|
27
|
+
const meta = (metadata ?? {});
|
|
28
|
+
const parts = [];
|
|
29
|
+
const used = Number(meta.access_count);
|
|
30
|
+
if (Number.isFinite(used) && used > 0)
|
|
31
|
+
parts.push(`access_count ${Math.floor(used)}`);
|
|
32
|
+
// fresh 是常态,不值得占字符;stale / expired 才是要 agent 注意的。
|
|
33
|
+
const freshness = String(meta.freshness ?? "");
|
|
34
|
+
if (freshness === "stale" || freshness === "expired")
|
|
35
|
+
parts.push(freshness);
|
|
36
|
+
const folded = meta.duplicates_folded;
|
|
37
|
+
if (Array.isArray(folded) && folded.length > 0) {
|
|
38
|
+
parts.push(`folded ${folded.length}: ${folded.map(String).join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
// 扩散来源。P4 的可解释性要求:agent 必须看得出哪条是直接命中、哪条是
|
|
41
|
+
// 沿边联想带回的,以及经由什么边类型。不显示等于白算 —— 与本文件开头
|
|
42
|
+
// 记的 duplicates_folded 是同一类缺陷(算出来没人读)。
|
|
43
|
+
const via = String(meta.spread_via ?? "");
|
|
44
|
+
if (via !== "") {
|
|
45
|
+
const from = String(meta.spread_from ?? "");
|
|
46
|
+
parts.push(from === "" ? `via ${via}` : `via ${via} from ${from.slice(0, 8)}`);
|
|
47
|
+
}
|
|
48
|
+
return parts.length > 0 ? ` · ${parts.join(" · ")}` : "";
|
|
49
|
+
}
|
|
9
50
|
export function formatMemoriesForDisplay(data) {
|
|
10
51
|
const results = [];
|
|
11
52
|
const textMems = data.text_mem ?? [];
|
|
@@ -50,10 +91,12 @@ export function formatMemoriesForDisplay(data) {
|
|
|
50
91
|
else {
|
|
51
92
|
results.push(`#### ${i + 1}. ${displayText}`);
|
|
52
93
|
}
|
|
53
|
-
results.push(`ID: \`${memId}
|
|
94
|
+
results.push(`ID: \`${memId}\`${memoryAnnotations(mem.metadata)}`);
|
|
54
95
|
results.push("");
|
|
55
96
|
// Detect code blocks
|
|
56
|
-
const hasCodeIndicator = displayText
|
|
97
|
+
const hasCodeIndicator = displayText
|
|
98
|
+
.split("\n")
|
|
99
|
+
.some((line) => /^(import |def |class |export |const |let |var )/.test(line.trim()));
|
|
57
100
|
if (!displayText.includes("```") && hasCodeIndicator) {
|
|
58
101
|
results.push("```python");
|
|
59
102
|
results.push(displayText);
|
|
@@ -90,8 +133,13 @@ export function formatGraphForDisplay(data) {
|
|
|
90
133
|
for (const node of nodes) {
|
|
91
134
|
const nodeId = node.id ?? "";
|
|
92
135
|
const nodeMemory = node.memory ?? "";
|
|
93
|
-
const cleanText = nodeMemory
|
|
94
|
-
|
|
136
|
+
const cleanText = nodeMemory
|
|
137
|
+
.replace(/\n/g, " ")
|
|
138
|
+
.replace(/"/g, "'")
|
|
139
|
+
.replace(/\[/g, "(")
|
|
140
|
+
.replace(/\]/g, ")");
|
|
141
|
+
nodeLookup[nodeId] =
|
|
142
|
+
cleanText.length > 50 ? cleanText.slice(0, 50) + "..." : cleanText;
|
|
95
143
|
}
|
|
96
144
|
// Display nodes
|
|
97
145
|
if (nodes.length > 0) {
|
package/dist/handlers/graph.d.ts
CHANGED
|
@@ -14,6 +14,37 @@ import type { TextContent } from "../types.js";
|
|
|
14
14
|
export declare function handleMemosGraphifyImport(arguments_: Record<string, unknown>): TextContent[];
|
|
15
15
|
export declare function handleMemosTracePath(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
16
16
|
export declare function handleMemosGetGraph(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
17
|
+
/**
|
|
18
|
+
* 把 /graph/schema 的响应渲染成报告。
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么是导出的纯函数
|
|
21
|
+
*
|
|
22
|
+
* 这段逻辑原本内联在 handler 里,单测触达不到 —— 于是**五个字段名读错了
|
|
23
|
+
* 却长期无人发现**。抽出来才能钉住字段契约。同一类漏洞本轮已出现过两次
|
|
24
|
+
* (list 路径的层级过滤、access-tracker 的接线)。
|
|
25
|
+
*
|
|
26
|
+
* ## 修掉的字段名不匹配
|
|
27
|
+
*
|
|
28
|
+
* API(SchemaData)返回的键与此前读取的键对不上,读不到就 `?? 0` 静默兜成零:
|
|
29
|
+
*
|
|
30
|
+
* | 此前读 | API 实际返回 |
|
|
31
|
+
* |----------------------------|--------------------|
|
|
32
|
+
* | `avg_connections_per_node` | `avg_connections` |
|
|
33
|
+
* | `orphan_node_count` | `orphan_nodes` |
|
|
34
|
+
* | `edge_type_distribution` | `edge_types` |
|
|
35
|
+
* | `memory_type_distribution` | `memory_types` |
|
|
36
|
+
* | `tag_frequency` | `top_tags` |
|
|
37
|
+
*
|
|
38
|
+
* `max_connections` / `total_nodes` / `total_edges` / `time_range` 恰好同名,
|
|
39
|
+
* 所以只有上面五项失效 —— 部分正确正是它难被发现的原因。
|
|
40
|
+
*
|
|
41
|
+
* 后果不只是少显示:健康评估读的也是错字段,于是 orphan 比例恒为 0(永远
|
|
42
|
+
* 报「连接良好」),avg 恒为 0(永远报「平均连接过低」)。两句结论同时出现、
|
|
43
|
+
* 自相矛盾,而它们**都是基于零值**得出的。
|
|
44
|
+
*
|
|
45
|
+
* 兼容两种键名:后端字段若将来改回长名,读取仍然成立。
|
|
46
|
+
*/
|
|
47
|
+
export declare function formatSchemaReport(schema: Record<string, unknown>): string;
|
|
17
48
|
export declare function handleMemosExportSchema(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
18
49
|
export declare function handleMemosImpact(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
19
50
|
//# sourceMappingURL=graph.d.ts.map
|