@co-engram/viewer 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/README.md +38 -0
- package/dist/brand-logos.d.ts +9 -0
- package/dist/brand-logos.d.ts.map +1 -0
- package/dist/brand-logos.js +10 -0
- package/dist/brand-logos.js.map +1 -0
- package/dist/html.d.ts +21 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +299 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/app.d.ts +11 -0
- package/dist/runtime/app.d.ts.map +1 -0
- package/dist/runtime/app.js +437 -0
- package/dist/runtime/app.js.map +1 -0
- package/dist/runtime/decay.d.ts +16 -0
- package/dist/runtime/decay.d.ts.map +1 -0
- package/dist/runtime/decay.js +108 -0
- package/dist/runtime/decay.js.map +1 -0
- package/dist/runtime/graph.d.ts +13 -0
- package/dist/runtime/graph.d.ts.map +1 -0
- package/dist/runtime/graph.js +313 -0
- package/dist/runtime/graph.js.map +1 -0
- package/dist/runtime/i18n.d.ts +16 -0
- package/dist/runtime/i18n.d.ts.map +1 -0
- package/dist/runtime/i18n.js +76 -0
- package/dist/runtime/i18n.js.map +1 -0
- package/dist/runtime/tabs.d.ts +8 -0
- package/dist/runtime/tabs.d.ts.map +1 -0
- package/dist/runtime/tabs.js +1783 -0
- package/dist/runtime/tabs.js.map +1 -0
- package/dist/server.d.ts +73 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +985 -0
- package/dist/server.js.map +1 -0
- package/dist/styles.d.ts +13 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +1632 -0
- package/dist/styles.js.map +1 -0
- package/dist/vendor/dompurify-source.d.ts +11 -0
- package/dist/vendor/dompurify-source.d.ts.map +1 -0
- package/dist/vendor/dompurify-source.js +15 -0
- package/dist/vendor/dompurify-source.js.map +1 -0
- package/dist/vendor/marked-source.d.ts +11 -0
- package/dist/vendor/marked-source.d.ts.map +1 -0
- package/dist/vendor/marked-source.js +18 -0
- package/dist/vendor/marked-source.js.map +1 -0
- package/dist/vendor/vis-network-source.d.ts +11 -0
- package/dist/vendor/vis-network-source.d.ts.map +1 -0
- package/dist/vendor/vis-network-source.js +46 -0
- package/dist/vendor/vis-network-source.js.map +1 -0
- package/package.json +61 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Co-Engram Viewer HTTP Server
|
|
3
|
+
*
|
|
4
|
+
* 绑定 127.0.0.1 的轻量 HTTP server,提供只读为主的数据访问 + 极少写操作。
|
|
5
|
+
*
|
|
6
|
+
* 设计目标:
|
|
7
|
+
* - 只绑定 loopback,不对外网暴露
|
|
8
|
+
* - 可选 bearer token 认证
|
|
9
|
+
* - EADDRINUSE 自动重试 5 次,每次 port+1
|
|
10
|
+
* - 默认关闭,需 CO_ENGRAM_VIEWER_ENABLED=1 显式开启
|
|
11
|
+
*
|
|
12
|
+
* 端点清单(11 个):
|
|
13
|
+
* GET / SPA HTML(htmx)
|
|
14
|
+
* GET /api/stats 总览统计
|
|
15
|
+
* GET /api/engrams 列表
|
|
16
|
+
* GET /api/engrams/:id 详情
|
|
17
|
+
* PATCH /api/engrams/:id 更新(标题/importance/visibility 等)
|
|
18
|
+
* DELETE /api/engrams/:id 删除
|
|
19
|
+
* GET /api/search?q= 搜索
|
|
20
|
+
* GET /api/graph 图视图(节点 + 边)
|
|
21
|
+
* GET /api/proposals 候选提案
|
|
22
|
+
* GET /api/audit 审计日志
|
|
23
|
+
* GET /api/effectiveness 有效性统计
|
|
24
|
+
* GET /api/trash 回收站
|
|
25
|
+
* GET /api/trash/:id 回收站单条预览(完整内容)
|
|
26
|
+
* DELETE /api/trash 清空回收站(永久删除,支持 ?partition= 过滤)
|
|
27
|
+
* POST /api/trash/:id/restore 从回收站恢复
|
|
28
|
+
*
|
|
29
|
+
* @module @co-engram/claude-code/viewer
|
|
30
|
+
*/
|
|
31
|
+
import { createServer, } from "node:http";
|
|
32
|
+
import { DEFAULT_LANGUAGE, listTrashed, restoreFromTrash, purgeAllTrash, readTrashed, readTeamMemoryConfig, writeTeamMemoryConfig, loadAndSelfHealConfig, normalizeConfig, setDesiredDataRoot, computeMergeStats, detectAnomalies, } from "@co-engram/core";
|
|
33
|
+
import { renderSpaHtml } from "./html.js";
|
|
34
|
+
const DEFAULT_PORT = 18799;
|
|
35
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
36
|
+
/**
|
|
37
|
+
* 启动 Viewer HTTP server
|
|
38
|
+
*
|
|
39
|
+
* 不抛——端口冲突时自动重试 maxRetries 次。
|
|
40
|
+
*/
|
|
41
|
+
export function startViewerServer(ctx, config = {}) {
|
|
42
|
+
const startPort = config.port ?? DEFAULT_PORT;
|
|
43
|
+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
44
|
+
const token = config.token;
|
|
45
|
+
const language = config.language ?? DEFAULT_LANGUAGE;
|
|
46
|
+
const dataRoot = config.dataRoot ?? process.env.CO_ENGRAM_DATA_ROOT;
|
|
47
|
+
const hostType = config.hostType ?? detectHostType();
|
|
48
|
+
return tryListen(ctx, startPort, maxRetries, token, language, dataRoot, hostType);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 自动探测宿主类型
|
|
52
|
+
*
|
|
53
|
+
* 通过 process.argv[1](启动脚本路径)判断:
|
|
54
|
+
* - 含 'mcp-server' → 'mcp-server'(由 Claude Code 拉起)
|
|
55
|
+
* - 含 'gateway' 或 'coclaw' → 'openclaw-plugin'(由 openclaw/co-claw gateway 加载)
|
|
56
|
+
* - 其他 → 'mcp-server'(向后兼容默认)
|
|
57
|
+
*/
|
|
58
|
+
function detectHostType() {
|
|
59
|
+
const entryArg = process.argv[1] ?? "";
|
|
60
|
+
if (entryArg.includes("mcp-server"))
|
|
61
|
+
return "mcp-server";
|
|
62
|
+
if (entryArg.includes("gateway") || entryArg.includes("coclaw"))
|
|
63
|
+
return "openclaw-plugin";
|
|
64
|
+
return "mcp-server";
|
|
65
|
+
}
|
|
66
|
+
function tryListen(ctx, port, retriesLeft, token, language, dataRoot, hostType) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const server = createServer((req, res) => handleRequest(ctx, req, res, token, language, dataRoot, hostType));
|
|
69
|
+
server.on("error", (err) => {
|
|
70
|
+
if (err.code === "EADDRINUSE" && retriesLeft > 0) {
|
|
71
|
+
server.close();
|
|
72
|
+
resolve(tryListen(ctx, port + 1, retriesLeft - 1, token, language, dataRoot, hostType));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
reject(err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
server.listen(port, "127.0.0.1", () => {
|
|
79
|
+
const stop = async () => {
|
|
80
|
+
await new Promise((r) => server.close(() => r()));
|
|
81
|
+
};
|
|
82
|
+
resolve({ server, port, stop });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// ============================================================
|
|
87
|
+
// Request handler
|
|
88
|
+
// ============================================================
|
|
89
|
+
function handleRequest(ctx, req, res, token, language, dataRoot, hostType) {
|
|
90
|
+
try {
|
|
91
|
+
// CORS:仅本机
|
|
92
|
+
res.setHeader("Access-Control-Allow-Origin", "http://127.0.0.1:18799");
|
|
93
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
|
|
94
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
95
|
+
if (req.method === "OPTIONS") {
|
|
96
|
+
res.writeHead(204);
|
|
97
|
+
res.end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
101
|
+
const path = url.pathname;
|
|
102
|
+
// SPA HTML
|
|
103
|
+
if (path === "/" && req.method === "GET") {
|
|
104
|
+
const html = renderSpaHtml({ tokenRequired: !!token, language });
|
|
105
|
+
const buf = Buffer.from(html, "utf8");
|
|
106
|
+
res.writeHead(200, {
|
|
107
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
108
|
+
"Content-Length": buf.length,
|
|
109
|
+
});
|
|
110
|
+
res.end(buf);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// API 路由:需认证(如果配置了 token)
|
|
114
|
+
if (path.startsWith("/api/")) {
|
|
115
|
+
if (token && !isAuthorized(req, token)) {
|
|
116
|
+
respondJson(res, 401, { error: "Unauthorized" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
routeApi(ctx, req, res, path, url, language, dataRoot, hostType).catch((err) => {
|
|
120
|
+
respondJson(res, 500, {
|
|
121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
respondJson(res, 404, { error: `Not found: ${path}` });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
respondJson(res, 500, {
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function isAuthorized(req, expectedToken) {
|
|
135
|
+
const auth = req.headers.authorization;
|
|
136
|
+
if (!auth)
|
|
137
|
+
return false;
|
|
138
|
+
const match = /^Bearer\s+(.+)$/i.exec(auth);
|
|
139
|
+
if (!match)
|
|
140
|
+
return false;
|
|
141
|
+
return match[1] === expectedToken;
|
|
142
|
+
}
|
|
143
|
+
async function routeApi(ctx, req, res, path, url, language, dataRoot, hostType) {
|
|
144
|
+
// /api/stats
|
|
145
|
+
if (path === "/api/stats" && req.method === "GET") {
|
|
146
|
+
respondJson(res, 200, getStats(ctx));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// /api/engrams
|
|
150
|
+
//
|
|
151
|
+
// 返回 catalog + 完整排序字段(title/kind/domainTags 来自 catalog,
|
|
152
|
+
// summary/importance/createdAt/updatedAt/retrievalCount 来自完整 engram)。
|
|
153
|
+
// 单纯返回 catalog 会让前端排序下拉菜单(createdAt/importance/retrievalCount)
|
|
154
|
+
// 失效——这些字段不在 catalog tier 里。
|
|
155
|
+
if (path === "/api/engrams" && req.method === "GET") {
|
|
156
|
+
// 兼容两种参数名:
|
|
157
|
+
// - tag(旧版,单数,精确匹配单个 tag)
|
|
158
|
+
// - domainTags(新版,复数,可多次出现,任一匹配即保留)
|
|
159
|
+
// kind 过滤单值匹配。
|
|
160
|
+
const tagFilter = url.searchParams.get("tag") ?? undefined;
|
|
161
|
+
const kindFilter = url.searchParams.get("kind") ?? undefined;
|
|
162
|
+
const domainTagFilters = url.searchParams
|
|
163
|
+
.getAll("domainTags")
|
|
164
|
+
.filter((t) => t.length > 0);
|
|
165
|
+
const sortParam = url.searchParams.get("sort") ?? undefined;
|
|
166
|
+
const orderParam = (url.searchParams.get("order") ?? "desc").toLowerCase();
|
|
167
|
+
const limitRaw = url.searchParams.get("limit");
|
|
168
|
+
const limit = limitRaw ? Number(limitRaw) : undefined;
|
|
169
|
+
const descending = orderParam !== "asc";
|
|
170
|
+
const entries = ctx.repository.listEngrams();
|
|
171
|
+
const filtered = entries.filter((e) => {
|
|
172
|
+
if (kindFilter && e.kind !== kindFilter)
|
|
173
|
+
return false;
|
|
174
|
+
if (tagFilter && !e.domainTags.includes(tagFilter))
|
|
175
|
+
return false;
|
|
176
|
+
if (domainTagFilters.length > 0) {
|
|
177
|
+
// 任一 domainTags 参数匹配 engram 的 tag 即保留(OR 语义)
|
|
178
|
+
const matched = domainTagFilters.some((t) => e.domainTags.includes(t));
|
|
179
|
+
if (!matched)
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
const enriched = filtered.map((entry) => {
|
|
185
|
+
let full = null;
|
|
186
|
+
try {
|
|
187
|
+
full = ctx.repository.readEngram(entry.id);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
full = null;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...entry,
|
|
194
|
+
summary: full?.summary ?? "",
|
|
195
|
+
importance: full?.importance ?? 0,
|
|
196
|
+
retrievalCount: full?.retrievalCount ?? 0,
|
|
197
|
+
createdAt: full?.createdAt ?? "",
|
|
198
|
+
updatedAt: full?.updatedAt ?? "",
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
// 排序:支持 createdAt / updatedAt / importance / retrievalCount / title
|
|
202
|
+
// 不识别的 sort 值保持原顺序(repository.listEngrams 的自然顺序)
|
|
203
|
+
const sortField = sortParam;
|
|
204
|
+
if (sortField) {
|
|
205
|
+
enriched.sort((a, b) => {
|
|
206
|
+
const av = a[sortField];
|
|
207
|
+
const bv = b[sortField];
|
|
208
|
+
if (av === bv)
|
|
209
|
+
return 0;
|
|
210
|
+
if (typeof av === "number" && typeof bv === "number") {
|
|
211
|
+
return descending ? bv - av : av - bv;
|
|
212
|
+
}
|
|
213
|
+
// 字符串比较(createdAt/updatedAt/title)
|
|
214
|
+
const ac = String(av ?? "");
|
|
215
|
+
const bc = String(bv ?? "");
|
|
216
|
+
return descending ? bc.localeCompare(ac) : ac.localeCompare(bc);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// limit:截断到指定数量;不传或 NaN 时返回全部
|
|
220
|
+
const limited = typeof limit === "number" && Number.isFinite(limit) && limit > 0
|
|
221
|
+
? enriched.slice(0, limit)
|
|
222
|
+
: enriched;
|
|
223
|
+
respondJson(res, 200, { results: limited, total: enriched.length });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// /api/engrams/:id (GET | PATCH | DELETE)
|
|
227
|
+
const engramMatch = /^\/api\/engrams\/(.+)$/.exec(path);
|
|
228
|
+
if (engramMatch) {
|
|
229
|
+
const id = decodeURIComponent(engramMatch[1]);
|
|
230
|
+
if (req.method === "GET") {
|
|
231
|
+
try {
|
|
232
|
+
const engram = ctx.repository.readEngram(id);
|
|
233
|
+
respondJson(res, 200, engram);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
respondJson(res, 404, { error: `Not found: ${id}` });
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (req.method === "PATCH") {
|
|
241
|
+
const body = await readJsonBody(req);
|
|
242
|
+
const updated = ctx.repository.updateEngram(id, parseUpdateInput(body));
|
|
243
|
+
respondJson(res, 200, updated);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (req.method === "DELETE") {
|
|
247
|
+
ctx.repository.deleteEngram(id);
|
|
248
|
+
respondJson(res, 200, { deleted: true, id });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
respondJson(res, 405, { error: `Method not allowed: ${req.method}` });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// /api/synapses/:id (GET | PATCH | DELETE) — synapse detail/edit/delete
|
|
255
|
+
const synapseMatch = /^\/api\/synapses\/(.+)$/.exec(path);
|
|
256
|
+
if (synapseMatch) {
|
|
257
|
+
const id = decodeURIComponent(synapseMatch[1]);
|
|
258
|
+
const syn = ctx.repository.readSynapseById(id);
|
|
259
|
+
if (!syn) {
|
|
260
|
+
respondJson(res, 404, { error: `Synapse not found: ${id}` });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (req.method === "GET") {
|
|
264
|
+
respondJson(res, 200, syn);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (req.method === "PATCH") {
|
|
268
|
+
const body = await readJsonBodyAs(req);
|
|
269
|
+
const updated = ctx.repository.updateSynapse(syn.from, syn.id, {
|
|
270
|
+
...(body?.weight !== undefined ? { weight: body.weight } : {}),
|
|
271
|
+
...(body?.direction
|
|
272
|
+
? { direction: body.direction }
|
|
273
|
+
: {}),
|
|
274
|
+
...(body?.kind ? { kind: body.kind } : {}),
|
|
275
|
+
...(body?.evidence ? { evidence: body.evidence } : {}),
|
|
276
|
+
updatedBy: "viewer",
|
|
277
|
+
});
|
|
278
|
+
respondJson(res, 200, updated);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (req.method === "DELETE") {
|
|
282
|
+
ctx.repository.deleteSynapse(id);
|
|
283
|
+
respondJson(res, 200, { deleted: true, id });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
respondJson(res, 405, { error: `Method not allowed: ${req.method}` });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// /api/search
|
|
290
|
+
if (path === "/api/search" && req.method === "GET") {
|
|
291
|
+
const q = url.searchParams.get("q") ?? "";
|
|
292
|
+
const limit = Number(url.searchParams.get("limit") ?? 20);
|
|
293
|
+
if (!q) {
|
|
294
|
+
respondJson(res, 200, { results: [], total: 0 });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!ctx.searchOrchestrator) {
|
|
298
|
+
respondJson(res, 200, {
|
|
299
|
+
results: [],
|
|
300
|
+
total: 0,
|
|
301
|
+
error: "SearchOrchestrator not available",
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const results = ctx.searchOrchestrator.search(q, undefined, limit);
|
|
306
|
+
respondJson(res, 200, { results, total: results.length });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// /api/graph
|
|
310
|
+
if (path === "/api/graph" && req.method === "GET") {
|
|
311
|
+
const graph = buildGraph(ctx);
|
|
312
|
+
respondJson(res, 200, graph);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// /api/proposals
|
|
316
|
+
if (path === "/api/proposals" && req.method === "GET") {
|
|
317
|
+
if (!ctx.proposalEngine) {
|
|
318
|
+
respondJson(res, 200, { results: [], total: 0, enabled: false });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const status = url.searchParams.get("status") ?? "pending";
|
|
322
|
+
const all = ctx.proposalEngine.listAll();
|
|
323
|
+
const filtered = all.filter((p) => status === "all" ? true : p.status === status);
|
|
324
|
+
respondJson(res, 200, {
|
|
325
|
+
results: filtered,
|
|
326
|
+
total: filtered.length,
|
|
327
|
+
enabled: true,
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// /api/proposals/:entityId/accept | /dismiss
|
|
332
|
+
const proposalActionMatch = /^\/api\/proposals\/(.+)\/(accept|dismiss)$/.exec(path);
|
|
333
|
+
if (proposalActionMatch && req.method === "POST") {
|
|
334
|
+
const entityId = decodeURIComponent(proposalActionMatch[1]);
|
|
335
|
+
const action = proposalActionMatch[2];
|
|
336
|
+
if (!ctx.proposalEngine) {
|
|
337
|
+
respondJson(res, 503, {
|
|
338
|
+
error: "Proposal engine not enabled",
|
|
339
|
+
enabled: false,
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const body = await readJsonBodyAs(req);
|
|
344
|
+
try {
|
|
345
|
+
if (action === "accept") {
|
|
346
|
+
if (!body?.title || !body.content) {
|
|
347
|
+
respondJson(res, 400, { error: "accept requires title and content" });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const engramId = ctx.proposalEngine.accept(entityId, {
|
|
351
|
+
title: body.title,
|
|
352
|
+
content: body.content,
|
|
353
|
+
domainTags: body.domainTags ?? [],
|
|
354
|
+
...(body.createdBy ? { createdBy: body.createdBy } : {}),
|
|
355
|
+
...(body.kind
|
|
356
|
+
? {
|
|
357
|
+
kind: body.kind,
|
|
358
|
+
}
|
|
359
|
+
: {}),
|
|
360
|
+
});
|
|
361
|
+
respondJson(res, 200, { ok: true, action, engramId });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// dismiss
|
|
365
|
+
ctx.proposalEngine.dismiss(entityId, body?.reason, body?.dismissDays);
|
|
366
|
+
respondJson(res, 200, { ok: true, action });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
respondJson(res, 400, {
|
|
371
|
+
error: err instanceof Error ? err.message : String(err),
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// /api/audit
|
|
377
|
+
if (path === "/api/audit" && req.method === "GET") {
|
|
378
|
+
if (!ctx.auditLog) {
|
|
379
|
+
respondJson(res, 200, { results: [], total: 0, enabled: false });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// action 支持逗号分隔多值:?action=accept,propose → 数组
|
|
383
|
+
const rawAction = url.searchParams.get("action") ?? undefined;
|
|
384
|
+
const actionList = rawAction
|
|
385
|
+
? rawAction
|
|
386
|
+
.split(",")
|
|
387
|
+
.map((s) => s.trim())
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
: [];
|
|
390
|
+
const engramId = url.searchParams.get("engramId") ?? undefined;
|
|
391
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
392
|
+
const until = url.searchParams.get("until") ?? undefined;
|
|
393
|
+
const limit = Number(url.searchParams.get("limit") ?? 200);
|
|
394
|
+
const entries = ctx.auditLog.query({
|
|
395
|
+
...(actionList.length === 1
|
|
396
|
+
? { action: actionList[0] }
|
|
397
|
+
: actionList.length > 1
|
|
398
|
+
? { action: actionList }
|
|
399
|
+
: {}),
|
|
400
|
+
...(engramId ? { engramId } : {}),
|
|
401
|
+
...(since ? { since } : {}),
|
|
402
|
+
...(until ? { until } : {}),
|
|
403
|
+
limit,
|
|
404
|
+
});
|
|
405
|
+
respondJson(res, 200, {
|
|
406
|
+
results: entries,
|
|
407
|
+
total: entries.length,
|
|
408
|
+
enabled: true,
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// /api/effectiveness
|
|
413
|
+
if (path === "/api/effectiveness" && req.method === "GET") {
|
|
414
|
+
const engramId = url.searchParams.get("engramId");
|
|
415
|
+
if (!engramId || !ctx.effectivenessTracker) {
|
|
416
|
+
respondJson(res, 200, {
|
|
417
|
+
enabled: !!ctx.effectivenessTracker,
|
|
418
|
+
report: null,
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const report = ctx.effectivenessTracker.effectiveness(engramId);
|
|
423
|
+
respondJson(res, 200, { enabled: true, engramId, report });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// /api/merge-stats — P4.3 viewer "Merges" tab data source
|
|
427
|
+
if (path === "/api/merge-stats" && req.method === "GET") {
|
|
428
|
+
if (!ctx.auditLog) {
|
|
429
|
+
respondJson(res, 200, { enabled: false, stats: null });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const rawDays = Number(url.searchParams.get("windowDays") ?? 7);
|
|
433
|
+
const safeWindowDays = Number.isFinite(rawDays)
|
|
434
|
+
? Math.min(365, Math.max(1, Math.trunc(rawDays)))
|
|
435
|
+
: 7;
|
|
436
|
+
const stats = computeMergeStats({
|
|
437
|
+
auditLog: ctx.auditLog,
|
|
438
|
+
windowMs: safeWindowDays * 24 * 60 * 60 * 1000,
|
|
439
|
+
});
|
|
440
|
+
respondJson(res, 200, { enabled: true, stats, windowDays: safeWindowDays });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// /api/merge-anomalies — P4.4 anomaly alerting(spec §13.2)
|
|
444
|
+
if (path === "/api/merge-anomalies" && req.method === "GET") {
|
|
445
|
+
if (!ctx.auditLog) {
|
|
446
|
+
respondJson(res, 200, { enabled: false, anomalies: [] });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const rawDays = Number(url.searchParams.get("windowDays") ?? 7);
|
|
450
|
+
const safeWindowDays = Number.isFinite(rawDays)
|
|
451
|
+
? Math.min(365, Math.max(1, Math.trunc(rawDays)))
|
|
452
|
+
: 7;
|
|
453
|
+
const stats = computeMergeStats({
|
|
454
|
+
auditLog: ctx.auditLog,
|
|
455
|
+
windowMs: safeWindowDays * 24 * 60 * 60 * 1000,
|
|
456
|
+
});
|
|
457
|
+
const anomalies = detectAnomalies(stats);
|
|
458
|
+
respondJson(res, 200, {
|
|
459
|
+
enabled: true,
|
|
460
|
+
anomalies,
|
|
461
|
+
windowDays: safeWindowDays,
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// /api/trash
|
|
466
|
+
if (path === "/api/trash" && req.method === "GET") {
|
|
467
|
+
const trashed = listTrashedSimple(ctx);
|
|
468
|
+
respondJson(res, 200, { results: trashed, total: trashed.length });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (path === "/api/trash" && req.method === "DELETE") {
|
|
472
|
+
if (!ctx.repository) {
|
|
473
|
+
respondJson(res, 503, { error: "Repository not available" });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const partition = url.searchParams.get("partition") ?? undefined;
|
|
477
|
+
const dryRun = url.searchParams.get("dryRun") === "1";
|
|
478
|
+
const result = purgeAllTrash(ctx.repository, {
|
|
479
|
+
partition: partition || undefined,
|
|
480
|
+
dryRun,
|
|
481
|
+
auditLog: ctx.auditLog,
|
|
482
|
+
actor: "user",
|
|
483
|
+
});
|
|
484
|
+
respondJson(res, 200, {
|
|
485
|
+
purged: result.purged,
|
|
486
|
+
partitionsRemoved: result.partitionsRemoved,
|
|
487
|
+
count: result.purged.length,
|
|
488
|
+
dryRun,
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// /api/trash/:id (GET — 预览完整内容)
|
|
493
|
+
const trashItemMatch = /^\/api\/trash\/([^/]+)$/.exec(path);
|
|
494
|
+
if (trashItemMatch && req.method === "GET") {
|
|
495
|
+
if (!ctx.repository) {
|
|
496
|
+
respondJson(res, 503, { error: "Repository not available" });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const id = decodeURIComponent(trashItemMatch[1]);
|
|
500
|
+
const detail = readTrashed(ctx.repository, id);
|
|
501
|
+
if (!detail) {
|
|
502
|
+
respondJson(res, 404, { error: `Not in trash: ${id}` });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
respondJson(res, 200, detail);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// /api/trash/:id/restore
|
|
509
|
+
const trashRestoreMatch = /^\/api\/trash\/(.+)\/restore$/.exec(path);
|
|
510
|
+
if (trashRestoreMatch && req.method === "POST") {
|
|
511
|
+
const id = decodeURIComponent(trashRestoreMatch[1]);
|
|
512
|
+
if (!ctx.repository) {
|
|
513
|
+
respondJson(res, 503, { error: "Repository not available" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const result = restoreFromTrash(ctx.repository, id, ctx.auditLog ? { auditLog: ctx.auditLog } : {});
|
|
517
|
+
if (result.ok) {
|
|
518
|
+
respondJson(res, 200, { ok: true, id });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
respondJson(res, 404, { ok: false, error: result.reason, id });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// /api/config (GET 返回当前配置;PUT 持久化更新)
|
|
525
|
+
if (path === "/api/config") {
|
|
526
|
+
if (req.method === "GET") {
|
|
527
|
+
const persisted = dataRoot
|
|
528
|
+
? await readTeamMemoryConfig(dataRoot)
|
|
529
|
+
: undefined;
|
|
530
|
+
respondJson(res, 200, {
|
|
531
|
+
enabled: !!dataRoot,
|
|
532
|
+
dataRoot: dataRoot || null,
|
|
533
|
+
// hostType:当前 viewer 的宿主模式,UI 文字按此适配
|
|
534
|
+
// 'mcp-server' → 重启提示指 "MCP server",父进程是 Claude Code
|
|
535
|
+
// 'openclaw-plugin' → 重启提示指 "openclaw gateway",不支持自动重启
|
|
536
|
+
hostType,
|
|
537
|
+
// envSet:env CO_ENGRAM_DATA_ROOT 是否设置(仅信息性)
|
|
538
|
+
// envDataRoot:env 设置时的具体路径(用于 UI 提示,未设置时为 null)
|
|
539
|
+
// envDataRootOverride:env 设置 AND env 路径就是当前实际 dataRoot
|
|
540
|
+
// (即 desiredDataRoot 没有覆盖 env)。true 表示用户在 viewer 改 desiredDataRoot
|
|
541
|
+
// 不会有任何效果——env 路径下没有 desiredDataRoot 覆盖,需提示用户。
|
|
542
|
+
envSet: !!process.env.CO_ENGRAM_DATA_ROOT,
|
|
543
|
+
envDataRoot: process.env.CO_ENGRAM_DATA_ROOT || null,
|
|
544
|
+
envDataRootOverride: !!process.env.CO_ENGRAM_DATA_ROOT &&
|
|
545
|
+
process.env.CO_ENGRAM_DATA_ROOT === dataRoot,
|
|
546
|
+
persisted: persisted ?? null,
|
|
547
|
+
runtime: {
|
|
548
|
+
auditEnabled: !!ctx.auditLog,
|
|
549
|
+
proposalEnabled: !!ctx.proposalEngine,
|
|
550
|
+
searchEnabled: !!ctx.searchOrchestrator,
|
|
551
|
+
// 新语义:以 dataRoot 内 config.json 为单一权威,
|
|
552
|
+
// env 已不再承载这些开关。
|
|
553
|
+
maintenanceEnabled: persisted?.maintenance?.enabled === true,
|
|
554
|
+
viewerEnabled: persisted?.viewer?.enabled === true ||
|
|
555
|
+
(!!ctx.proposalEngine && persisted?.viewer?.enabled !== false),
|
|
556
|
+
profile: persisted?.toolsProfile ?? null,
|
|
557
|
+
language,
|
|
558
|
+
defaultCreatedBy: ctx.defaultCreatedBy || null,
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
564
|
+
if (!dataRoot) {
|
|
565
|
+
respondJson(res, 503, {
|
|
566
|
+
error: "Config persistence not available (dataRoot unknown)",
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const body = (await readJsonBodyAs(req)) ?? {};
|
|
571
|
+
// 用 loadAndSelfHealConfig 取代手动 fallback,保证返回的字段齐全(已嵌套化)。
|
|
572
|
+
// 写回时通过 normalizeConfig 保护,避免丢失嵌套字段。
|
|
573
|
+
const { config: existing } = await loadAndSelfHealConfig(dataRoot);
|
|
574
|
+
const next = { ...existing };
|
|
575
|
+
if (typeof body.language === "string" &&
|
|
576
|
+
(body.language === "zh" || body.language === "en")) {
|
|
577
|
+
next.language = body.language;
|
|
578
|
+
}
|
|
579
|
+
if (typeof body.defaultCreatedBy === "string") {
|
|
580
|
+
next.defaultCreatedBy = body.defaultCreatedBy.trim() || undefined;
|
|
581
|
+
}
|
|
582
|
+
if (typeof body.toolsProfile === "string" &&
|
|
583
|
+
["minimal", "standard", "full"].includes(body.toolsProfile)) {
|
|
584
|
+
next.toolsProfile = body.toolsProfile;
|
|
585
|
+
}
|
|
586
|
+
// 子系统开关:接收嵌套字段(maintenance.enabled / audit.enabled / proposals.enabled)
|
|
587
|
+
const bodyMaintenance = body.maintenance;
|
|
588
|
+
const bodyAudit = body.audit;
|
|
589
|
+
const bodyProposals = body.proposals;
|
|
590
|
+
if (bodyMaintenance?.enabled !== undefined) {
|
|
591
|
+
next.maintenance = {
|
|
592
|
+
...(existing.maintenance ?? {}),
|
|
593
|
+
enabled: !!bodyMaintenance.enabled,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (bodyAudit?.enabled !== undefined) {
|
|
597
|
+
next.audit = {
|
|
598
|
+
...(existing.audit ?? {}),
|
|
599
|
+
enabled: !!bodyAudit.enabled,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (bodyProposals?.enabled !== undefined) {
|
|
603
|
+
next.proposals = {
|
|
604
|
+
...(existing.proposals ?? {}),
|
|
605
|
+
enabled: !!bodyProposals.enabled,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// 数据根目录期望值(下次启动生效)。允许空字符串清空(回退到默认)。
|
|
609
|
+
let newDesiredDataRoot = null; // null = 不修改
|
|
610
|
+
if (body.desiredDataRoot !== undefined &&
|
|
611
|
+
typeof body.desiredDataRoot === "string") {
|
|
612
|
+
const trimmed = body.desiredDataRoot.trim();
|
|
613
|
+
newDesiredDataRoot = trimmed || undefined;
|
|
614
|
+
if (trimmed) {
|
|
615
|
+
next.desiredDataRoot = trimmed;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
delete next.desiredDataRoot;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
next.updatedAt = new Date().toISOString();
|
|
622
|
+
const normalized = normalizeConfig(next);
|
|
623
|
+
await writeTeamMemoryConfig(dataRoot, normalized);
|
|
624
|
+
// 跨重启一致性:bootstrap 路径的 config.json 只承担"redirect hint"角色。
|
|
625
|
+
// 新语义下不再双写整份 config;仅当 desiredDataRoot 变更时,同步此单字段到 bootstrap。
|
|
626
|
+
// 这样 bootstrap config 保持最小化(不污染),其他字段以 runtime dataRoot 为权威。
|
|
627
|
+
const bootstrapDataRoot = process.env.CO_ENGRAM_DATA_ROOT ??
|
|
628
|
+
`${process.env.HOME ?? "/tmp"}/team-memory`;
|
|
629
|
+
const bootstrapSyncWarning = undefined;
|
|
630
|
+
if (newDesiredDataRoot !== null && bootstrapDataRoot !== dataRoot) {
|
|
631
|
+
try {
|
|
632
|
+
await setDesiredDataRoot(bootstrapDataRoot, newDesiredDataRoot);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
respondJson(res, 200, {
|
|
636
|
+
ok: true,
|
|
637
|
+
persisted: normalized,
|
|
638
|
+
warning: `Config written to runtime (${dataRoot}) but failed to sync desiredDataRoot to bootstrap (${bootstrapDataRoot}). Next startup may not pick up the new dataRoot.`,
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
respondJson(res, 200, {
|
|
644
|
+
ok: true,
|
|
645
|
+
persisted: normalized,
|
|
646
|
+
...(bootstrapSyncWarning ? { warning: bootstrapSyncWarning } : {}),
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// /api/path-tree (progressive disclosure directory tree)
|
|
652
|
+
if (path === "/api/path-tree" && req.method === "GET") {
|
|
653
|
+
if (!ctx.repository) {
|
|
654
|
+
respondJson(res, 200, { enabled: false, root: null });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const rawDepth = url.searchParams.get("maxDepth");
|
|
658
|
+
const maxDepth = rawDepth
|
|
659
|
+
? Math.min(10, Math.max(1, Number(rawDepth) || 5))
|
|
660
|
+
: 5;
|
|
661
|
+
const tree = ctx.repository.listPathTree();
|
|
662
|
+
respondJson(res, 200, {
|
|
663
|
+
enabled: true,
|
|
664
|
+
root: pruneTreeForJson(tree, maxDepth, 0),
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// /api/doctor (self-healing scan report)
|
|
669
|
+
if (path === "/api/doctor" && req.method === "GET") {
|
|
670
|
+
if (!ctx.repository) {
|
|
671
|
+
respondJson(res, 200, { enabled: false, report: null });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const incremental = url.searchParams.get("incremental") === "1";
|
|
675
|
+
const report = ctx.repository.runDoctor({ incremental });
|
|
676
|
+
respondJson(res, 200, {
|
|
677
|
+
enabled: true,
|
|
678
|
+
report: {
|
|
679
|
+
startedAt: report.startedAt,
|
|
680
|
+
finishedAt: report.finishedAt,
|
|
681
|
+
totalEngrams: report.totalEngrams,
|
|
682
|
+
totalSynapses: report.totalSynapses,
|
|
683
|
+
fixes: report.fixes,
|
|
684
|
+
pendingManualReview: report.pendingManualReview,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// /api/observe (proposal engine 入口:Claude Code hook / 外部喂入对话流)
|
|
690
|
+
//
|
|
691
|
+
// 设计要点:任何错误都吞掉返回 200,绝不阻塞调用方(hook 脚本必须 fire-and-forget)。
|
|
692
|
+
// role 只接受 'user' / 'assistant','system' 由 ProposalEngine 内部过滤。
|
|
693
|
+
if (path === "/api/observe" && req.method === "POST") {
|
|
694
|
+
if (!ctx.proposalEngine) {
|
|
695
|
+
respondJson(res, 200, { ok: true, enabled: false });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const body = await readJsonBodyAs(req);
|
|
700
|
+
const role = body?.role;
|
|
701
|
+
const content = body?.content;
|
|
702
|
+
if (role !== "user" && role !== "assistant") {
|
|
703
|
+
respondJson(res, 200, { ok: true, skipped: "invalid_role" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (typeof content !== "string" || content.trim().length === 0) {
|
|
707
|
+
respondJson(res, 200, { ok: true, skipped: "empty_content" });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
await ctx.proposalEngine.observe({
|
|
711
|
+
role,
|
|
712
|
+
content,
|
|
713
|
+
...(body?.at ? { at: body.at } : {}),
|
|
714
|
+
});
|
|
715
|
+
respondJson(res, 200, { ok: true });
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
// observe 失败不能影响 hook 调用方
|
|
719
|
+
respondJson(res, 200, {
|
|
720
|
+
ok: false,
|
|
721
|
+
error: err instanceof Error ? err.message : String(err),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
// POST /api/restart — 让进程 graceful 退出,由父进程自动重启。
|
|
727
|
+
//
|
|
728
|
+
// 仅在 hostType === 'mcp-server' 时生效:父进程是 Claude Code,会自动 respawn。
|
|
729
|
+
// 在 'openclaw-plugin' 模式下拒绝:viewer 是 gateway 进程的一部分,
|
|
730
|
+
// process.exit 会杀掉整个 gateway,影响其他 plugin / 会话。
|
|
731
|
+
// Plugin 模式请用 `openclaw gateway restart` 命令重启。
|
|
732
|
+
//
|
|
733
|
+
// 安全考量:
|
|
734
|
+
// - 退出码 0(正常退出),父进程 supervision 才会重启
|
|
735
|
+
// - 延迟 300ms 退出,确保 HTTP 响应先 flush 到客户端
|
|
736
|
+
// - viewer 是 loopback-only,外网无法触发
|
|
737
|
+
if (path === "/api/restart" && req.method === "POST") {
|
|
738
|
+
if (hostType === "openclaw-plugin") {
|
|
739
|
+
respondJson(res, 409, {
|
|
740
|
+
ok: false,
|
|
741
|
+
error: "restart not supported in openclaw-plugin mode (would kill entire gateway). Use `openclaw gateway restart` instead.",
|
|
742
|
+
hostType,
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
respondJson(res, 200, {
|
|
747
|
+
ok: true,
|
|
748
|
+
message: "restarting in 300ms",
|
|
749
|
+
hostType,
|
|
750
|
+
});
|
|
751
|
+
setTimeout(() => process.exit(0), 300);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
respondJson(res, 404, { error: `API not found: ${path}` });
|
|
755
|
+
}
|
|
756
|
+
function pruneTreeForJson(node, maxDepth, currentDepth) {
|
|
757
|
+
const children = currentDepth + 1 >= maxDepth
|
|
758
|
+
? []
|
|
759
|
+
: node.children.map((c) => pruneTreeForJson(c, maxDepth, currentDepth + 1));
|
|
760
|
+
return {
|
|
761
|
+
path: node.path,
|
|
762
|
+
engramCount: node.engramCount,
|
|
763
|
+
children,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function getStats(ctx) {
|
|
767
|
+
const entries = ctx.repository.listEngrams();
|
|
768
|
+
const byKind = {};
|
|
769
|
+
const byStatus = {};
|
|
770
|
+
const bySynapseKind = {};
|
|
771
|
+
const tagCount = {};
|
|
772
|
+
// 贡献者统计:actor → {engram, synapse}
|
|
773
|
+
const contributorMap = {};
|
|
774
|
+
const bumpContributor = (actor, field) => {
|
|
775
|
+
if (!actor)
|
|
776
|
+
return;
|
|
777
|
+
const key = actor.trim();
|
|
778
|
+
if (!key)
|
|
779
|
+
return;
|
|
780
|
+
contributorMap[key] = contributorMap[key] ?? { engram: 0, synapse: 0 };
|
|
781
|
+
contributorMap[key][field]++;
|
|
782
|
+
};
|
|
783
|
+
for (const entry of entries) {
|
|
784
|
+
byKind[entry.kind] = (byKind[entry.kind] ?? 0) + 1;
|
|
785
|
+
// 读取完整 engram(catalog entry 没有 createdBy/status)
|
|
786
|
+
try {
|
|
787
|
+
const full = ctx.repository.readEngram(entry.id);
|
|
788
|
+
byStatus[full.status] = (byStatus[full.status] ?? 0) + 1;
|
|
789
|
+
bumpContributor(full.createdBy, "engram");
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
byStatus["unknown"] = (byStatus["unknown"] ?? 0) + 1;
|
|
793
|
+
}
|
|
794
|
+
for (const t of entry.domainTags) {
|
|
795
|
+
tagCount[t] = (tagCount[t] ?? 0) + 1;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// 突触按 kind 分组
|
|
799
|
+
const allSynapses = ctx.repository.collectAllSynapses();
|
|
800
|
+
for (const { synapse } of allSynapses) {
|
|
801
|
+
bySynapseKind[synapse.kind] = (bySynapseKind[synapse.kind] ?? 0) + 1;
|
|
802
|
+
bumpContributor(synapse.createdBy, "synapse");
|
|
803
|
+
}
|
|
804
|
+
const topTags = Object.entries(tagCount)
|
|
805
|
+
.map(([tag, count]) => ({ tag, count }))
|
|
806
|
+
.sort((a, b) => b.count - a.count)
|
|
807
|
+
.slice(0, 10);
|
|
808
|
+
const topContributors = Object.entries(contributorMap)
|
|
809
|
+
.map(([actor, c]) => ({
|
|
810
|
+
actor,
|
|
811
|
+
engramCount: c.engram,
|
|
812
|
+
synapseCount: c.synapse,
|
|
813
|
+
total: c.engram + c.synapse,
|
|
814
|
+
}))
|
|
815
|
+
.sort((a, b) => b.total - a.total || b.engramCount - a.engramCount)
|
|
816
|
+
.slice(0, 10);
|
|
817
|
+
return {
|
|
818
|
+
totalEngrams: entries.length,
|
|
819
|
+
totalSynapses: allSynapses.length,
|
|
820
|
+
byKind,
|
|
821
|
+
byStatus,
|
|
822
|
+
bySynapseKind,
|
|
823
|
+
topTags,
|
|
824
|
+
topContributors,
|
|
825
|
+
pendingProposals: ctx.proposalEngine?.listPending().length ?? 0,
|
|
826
|
+
auditEnabled: !!ctx.auditLog,
|
|
827
|
+
effectivenessEnabled: !!ctx.effectivenessTracker,
|
|
828
|
+
proposalEnabled: !!ctx.proposalEngine,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
function buildGraph(ctx) {
|
|
832
|
+
const entries = ctx.repository.listEngrams();
|
|
833
|
+
// slug 取自 index(如果有);否则 undefined
|
|
834
|
+
const slugById = new Map();
|
|
835
|
+
if (ctx.repository) {
|
|
836
|
+
try {
|
|
837
|
+
for (const entry of ctx.repository.listEngramIndex()) {
|
|
838
|
+
slugById.set(entry.id, entry.slug);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// 索引不可用就降级(不影响 graph 主流程)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const nodes = entries.map((e) => ({
|
|
846
|
+
id: e.id,
|
|
847
|
+
title: e.title,
|
|
848
|
+
...(slugById.has(e.id) ? { slug: slugById.get(e.id) } : {}),
|
|
849
|
+
kind: e.kind,
|
|
850
|
+
domainTags: e.domainTags,
|
|
851
|
+
}));
|
|
852
|
+
const edges = [];
|
|
853
|
+
// bidirectional synapse 会同时出现在两端的 outgoing 里(对 A 是出、对 B 也是出),
|
|
854
|
+
// 直接遍历会让同一 id 的 edge 进列表两次,前端 vis-network DataSet 拒绝重复 id。
|
|
855
|
+
// 用 Set 按 synapse id 去重;directional 不受影响(只在起点 outgoing 出现一次)。
|
|
856
|
+
const seenSynapseIds = new Set();
|
|
857
|
+
for (const entry of entries) {
|
|
858
|
+
try {
|
|
859
|
+
const synapses = ctx.repository.readSynapses(entry.id);
|
|
860
|
+
if (synapses.outgoing) {
|
|
861
|
+
for (const s of synapses.outgoing) {
|
|
862
|
+
if (seenSynapseIds.has(s.id))
|
|
863
|
+
continue;
|
|
864
|
+
seenSynapseIds.add(s.id);
|
|
865
|
+
edges.push({
|
|
866
|
+
id: s.id,
|
|
867
|
+
from: entry.id,
|
|
868
|
+
to: s.to,
|
|
869
|
+
kind: s.kind,
|
|
870
|
+
weight: s.weight,
|
|
871
|
+
evidenceCount: s.evidence?.length ?? 0,
|
|
872
|
+
direction: s.direction,
|
|
873
|
+
...(s.resolutionState?.status
|
|
874
|
+
? { resolutionStatus: s.resolutionState.status }
|
|
875
|
+
: {}),
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// 跳过读取失败的 engram
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { nodes, edges };
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* 列出回收站中的 engram(物理在 .trash/<partition>/ 下)
|
|
888
|
+
*
|
|
889
|
+
* 注:还包含逻辑上 status=forgotten/archived 但物理仍在 engrams/ 的记录,
|
|
890
|
+
* 便于用户查看全貌。
|
|
891
|
+
*/
|
|
892
|
+
function listTrashedSimple(ctx) {
|
|
893
|
+
const trashed = listTrashed(ctx.repository);
|
|
894
|
+
const out = trashed.map((t) => ({
|
|
895
|
+
id: t.id,
|
|
896
|
+
partition: t.partition,
|
|
897
|
+
trashedAt: t.trashedAt,
|
|
898
|
+
}));
|
|
899
|
+
// 补充 status=forgotten/archived 但仍在主目录的记录
|
|
900
|
+
const entries = ctx.repository.listEngrams();
|
|
901
|
+
for (const entry of entries) {
|
|
902
|
+
try {
|
|
903
|
+
const full = ctx.repository.readEngram(entry.id);
|
|
904
|
+
if (full.status === "forgotten" || full.status === "archived") {
|
|
905
|
+
if (!out.some((o) => o.id === entry.id)) {
|
|
906
|
+
out.push({ id: entry.id });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
// skip
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
}
|
|
916
|
+
function parseUpdateInput(body) {
|
|
917
|
+
const patch = { updatedBy: "viewer" };
|
|
918
|
+
const VALID_KINDS = [
|
|
919
|
+
"fact",
|
|
920
|
+
"observation",
|
|
921
|
+
"pattern",
|
|
922
|
+
"procedure",
|
|
923
|
+
"hypothesis",
|
|
924
|
+
];
|
|
925
|
+
return {
|
|
926
|
+
...patch,
|
|
927
|
+
...(typeof body.title === "string" ? { title: body.title } : {}),
|
|
928
|
+
...(typeof body.content === "string" ? { content: body.content } : {}),
|
|
929
|
+
...(typeof body.importance === "number"
|
|
930
|
+
? { importance: body.importance }
|
|
931
|
+
: {}),
|
|
932
|
+
...(typeof body.confidence === "number"
|
|
933
|
+
? { confidence: body.confidence }
|
|
934
|
+
: {}),
|
|
935
|
+
...(typeof body.visibility === "string"
|
|
936
|
+
? { visibility: body.visibility }
|
|
937
|
+
: {}),
|
|
938
|
+
...(typeof body.kind === "string" &&
|
|
939
|
+
VALID_KINDS.includes(body.kind)
|
|
940
|
+
? { kinds: [body.kind] }
|
|
941
|
+
: {}),
|
|
942
|
+
...(Array.isArray(body.domainTags) ? { domainTags: body.domainTags } : {}),
|
|
943
|
+
...(Array.isArray(body.contextTags)
|
|
944
|
+
? { contextTags: body.contextTags }
|
|
945
|
+
: {}),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
async function readJsonBody(req) {
|
|
949
|
+
const chunks = [];
|
|
950
|
+
for await (const c of req) {
|
|
951
|
+
chunks.push(c);
|
|
952
|
+
}
|
|
953
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
954
|
+
if (!raw)
|
|
955
|
+
return {};
|
|
956
|
+
return JSON.parse(raw);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* 读取请求体并按给定 schema 解析,失败返回 undefined。
|
|
960
|
+
* 用于 POST endpoint(proposals/trash)。
|
|
961
|
+
*/
|
|
962
|
+
async function readJsonBodyAs(req) {
|
|
963
|
+
const chunks = [];
|
|
964
|
+
for await (const c of req) {
|
|
965
|
+
chunks.push(c);
|
|
966
|
+
}
|
|
967
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
968
|
+
if (!raw)
|
|
969
|
+
return undefined;
|
|
970
|
+
try {
|
|
971
|
+
return JSON.parse(raw);
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function respondJson(res, status, body) {
|
|
978
|
+
const payload = JSON.stringify(body, null, 2);
|
|
979
|
+
res.writeHead(status, {
|
|
980
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
981
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
982
|
+
});
|
|
983
|
+
res.end(payload);
|
|
984
|
+
}
|
|
985
|
+
//# sourceMappingURL=server.js.map
|