local-knowledge-graph 1.6.1 → 1.8.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/HELP.md CHANGED
@@ -65,6 +65,16 @@ kg
65
65
  - **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
66
66
  - 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
67
67
 
68
+ ## MCP 外部接入(外部 Agent 读写图谱)
69
+
70
+ 在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
71
+
72
+ - **HTTP 方式(推荐,支持远程 Agent)**:端点 `http://<主机>:3000/mcp`(Streamable HTTP,JSON 响应),启用后自动生成访问令牌,支持 `Authorization: Bearer` 头或 `?token=` 参数;界面提供三类客户端配置片段一键复制
73
+ - **本机 stdio 方式(免令牌)**:客户端配置命令 `npx -y local-knowledge-graph --mcp`(或 `kg --mcp`);数据目录默认 `~/.local-knowledge-graph`,可用 `KG_DATA_DIR` 环境变量指定
74
+ - **工具集(12 个)**:查询类 kg_stats / kg_list_entities / kg_get_entity / kg_get_graph / kg_ego / kg_search / kg_cypher / kg_inference / kg_path / kg_digest / kg_export_rdf;写入类 kg_apply_ops(add/update/delete entity 与 relation,自动记操作日志 + Git 保存点)
75
+ - **只读模式**:勾选后外部仅可查询;**令牌管理**:一键重新生成即刻吊销旧令牌
76
+ - **实时同步**:图谱被任一写入方(页面、API、MCP、外部进程)修改后,网页 3 秒内自动刷新
77
+
68
78
  ## 功能速览
69
79
 
70
80
  - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
@@ -105,6 +115,8 @@ kg
105
115
 
106
116
  ## 版本历史摘要
107
117
 
118
+ - **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
119
+ - **v1.7.0**:六项图谱能力升级——①实体别名(增删/全局唯一/搜索与消歧联动)②同名冲突检测(创建即提示,可合并可强制)③多路径查找(2-6跳,图上高亮,MCP kg_path)④相似实体推荐(语义+结构双模式)⑤关系置信度三档(确证/推测/存疑,样式区分可过滤)与来源引用标注 ⑥智能问答附证据路径链 ⑦文档批量入图(md/txt/pdf→LLM抽取→审核勾选→来源标记入库,支持跳过审核)
108
120
  - **v1.6.1**:修复浅色主题切换导致画布空白的问题;标题栏按钮防挤压(窄屏可横滑);修复3D样式主题刷新后不恢复的问题;新增脚本异常提示
109
121
  - **v1.6.0**:界面主题新增浅色配色(视图设置一键切换,本地记忆);GitHub 仓库与 npm 包通过 Actions 自动同步(打 tag 即测试+发布+Release)
110
122
 
package/bin/cli.js CHANGED
@@ -23,6 +23,7 @@ if (args.includes('-h') || args.includes('--help')) {
23
23
  --port <n> 服务端口(默认 3000,环境变量 PORT 同效)
24
24
  --host <addr> 监听地址(默认 127.0.0.1,局域网访问用 0.0.0.0,环境变量 KG_HOST 同效)
25
25
  --data <dir> 数据目录(默认 ~/.local-knowledge-graph,环境变量 KG_DATA_DIR 同效)
26
+ --mcp 以 MCP stdio 服务运行(供 Claude Desktop/Cursor 等客户端接入,不启动网页)
26
27
  --no-open 启动后不自动打开浏览器
27
28
  -v, --version 显示版本
28
29
  -h, --help 显示本帮助
@@ -36,6 +37,13 @@ if (args.includes('-v') || args.includes('--version')) {
36
37
  process.exit(0);
37
38
  }
38
39
 
40
+ if (args.includes('--mcp')) {
41
+ // stdio MCP:数据目录仍由 KG_DATA_DIR / --data 决定
42
+ const d = argValue('--data');
43
+ if (d) process.env.KG_DATA_DIR = d;
44
+ require('../mcp/server.js');
45
+ } else {
46
+
39
47
  const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
40
48
 
41
49
  const port = argValue('--port');
@@ -84,3 +92,4 @@ require('../server.js');
84
92
  if (isDevRepo()) {
85
93
  console.log('[开发模式] 检测到Git仓库,数据目录使用项目内 ./data');
86
94
  }
95
+ }
package/lib/agent.js CHANGED
@@ -138,7 +138,7 @@ async function runAgent(instruction, files = []) {
138
138
 
139
139
  // 底层进程调用:拉起 opencode run --format json,收集事件流(attempt与runPlain共用)
140
140
  // preArgs: 位于prompt之前的参数(如 --session);prompt必须在-f之前(-f是贪婪数组选项)
141
- function runOpenCodeRaw(preArgs, prompt, files, timeoutMs) {
141
+ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs, envExtra) {
142
142
  return new Promise((resolve) => {
143
143
  const args = ['run', '--format', 'json', '--auto', ...preArgs, prompt];
144
144
  for (const f of files || []) {
@@ -148,7 +148,7 @@ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs) {
148
148
  let stdout = '', stderr = '', done = false;
149
149
  let child;
150
150
  try {
151
- child = spawn('opencode', args, { cwd: APP_ROOT, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
151
+ child = spawn('opencode', args, { cwd: APP_ROOT, env: { ...process.env, ...(envExtra || {}) }, stdio: ['ignore', 'pipe', 'pipe'] });
152
152
  } catch (e) {
153
153
  return resolve({ ok: false, error: '无法启动OpenCode进程: ' + e.message });
154
154
  }
@@ -196,10 +196,10 @@ function attempt(sessionId, instruction, files) {
196
196
  }
197
197
 
198
198
  // 纯文本问答模式(智能检索编译/综述用):无会话、无 kg-ops 解析,仅取回复文本
199
- async function runPlain(prompt, timeoutMs = 20000) {
199
+ async function runPlain(prompt, timeoutMs = 20000, envExtra) {
200
200
  const p = String(prompt || '').trim();
201
201
  if (!p) return { ok: false, error: 'prompt不能为空' };
202
- const r = await runOpenCodeRaw([], p, [], timeoutMs);
202
+ const r = await runOpenCodeRaw([], p, [], timeoutMs, envExtra);
203
203
  if (!r.ok) return { ok: false, error: r.error };
204
204
  return { ok: true, text: r.texts || '' };
205
205
  }
package/lib/ask.js CHANGED
@@ -241,6 +241,63 @@ function scanByName(question, cap = 10) {
241
241
  return { entities: hits, relations };
242
242
  }
243
243
 
244
+ // ---------- 证据路径归纳:在被提及实体的诱导子图内枚举1-3跳路径(确定性,无LLM) ----------
245
+ function buildEvidencePaths(entities, relations, synthesisText, cap = 6) {
246
+ if (!entities.length || !relations.length) return [];
247
+ let seeds = entities;
248
+ if (synthesisText) {
249
+ const hits = entities.filter((e) => e.name && synthesisText.includes(e.name));
250
+ if (hits.length >= 2) seeds = hits; // 综述提及<2个实体时退回全部上下文实体
251
+ }
252
+ if (seeds.length < 2) return [];
253
+ const idSet = new Set(seeds.map((e) => e.id));
254
+ const ind = relations.filter((r) => idSet.has(r.source_id) && idSet.has(r.target_id));
255
+ if (!ind.length) return [];
256
+
257
+ const paths = [];
258
+ const seen = new Set();
259
+ for (const r of ind) { // 1跳
260
+ if (seen.has(r.id)) continue;
261
+ seen.add(r.id);
262
+ paths.push({ hops: [r] });
263
+ if (paths.length >= cap) return paths.slice(0, cap);
264
+ }
265
+ const adj = new Map();
266
+ for (const r of ind) {
267
+ if (!adj.has(r.source_id)) adj.set(r.source_id, []);
268
+ if (!adj.has(r.target_id)) adj.set(r.target_id, []);
269
+ adj.get(r.source_id).push(r);
270
+ adj.get(r.target_id).push(r);
271
+ }
272
+ for (const startId of idSet) { // 2-3跳:每个种子出发BFS,按关系id集合去重
273
+ let frontier = [{ id: startId, rels: [], visited: new Set([startId]) }];
274
+ for (let d = 0; d < 3 && paths.length < cap; d++) {
275
+ const next = [];
276
+ for (const node of frontier) {
277
+ for (const r of adj.get(node.id) || []) {
278
+ const other = r.source_id === node.id ? r.target_id : r.source_id;
279
+ if (node.visited.has(other)) continue;
280
+ const rels = [...node.rels, r];
281
+ const key = rels.map((x) => x.id).sort((a, b) => a - b).join('-');
282
+ if (d >= 1 && !seen.has(key)) {
283
+ seen.add(key);
284
+ paths.push({ hops: rels });
285
+ if (paths.length >= cap) break;
286
+ }
287
+ const visited = new Set(node.visited);
288
+ visited.add(other);
289
+ next.push({ id: other, rels, visited });
290
+ }
291
+ if (paths.length >= cap) break;
292
+ }
293
+ if (paths.length >= cap) break;
294
+ frontier = next;
295
+ }
296
+ if (paths.length >= cap) break;
297
+ }
298
+ return paths.slice(0, cap).map((p) => ({ hops: p.hops }));
299
+ }
300
+
244
301
  // ---------- 编排入口 ----------
245
302
  async function ask(question, opts = {}) {
246
303
  const q = String(question || '').trim();
@@ -284,7 +341,8 @@ async function ask(question, opts = {}) {
284
341
  if (s.error) result.synth_error = s.error;
285
342
  result.timings.total_ms = Date.now() - t0;
286
343
  }
344
+ result.evidence_paths = buildEvidencePaths(merged.entities, merged.relations, result.synthesis || null);
287
345
  return result;
288
346
  }
289
347
 
290
- module.exports = { buildDigest, validatePlan, compilePlan, executePlan, findCoNeighbors, synthesize, ask, extractJson, clampCypher };
348
+ module.exports = { buildDigest, validatePlan, compilePlan, executePlan, findCoNeighbors, synthesize, ask, extractJson, clampCypher, buildEvidencePaths };
package/lib/bus.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ // 极简进程内事件总线:图谱变更通知(SSE 推送用)
4
+
5
+ const listeners = new Set();
6
+
7
+ function on(fn) {
8
+ listeners.add(fn);
9
+ return () => listeners.delete(fn);
10
+ }
11
+
12
+ function emit(type, payload) {
13
+ for (const fn of listeners) {
14
+ try { fn(type, payload); } catch (_) { /* 单监听器异常不影响其余 */ }
15
+ }
16
+ }
17
+
18
+ module.exports = { on, emit, count: () => listeners.size };
package/lib/db.js CHANGED
@@ -40,7 +40,7 @@ function initSchema() {
40
40
  category TEXT NOT NULL CHECK(category IN ('物理实体','抽象实体','数值实体','时间实体')),
41
41
  attributes TEXT NOT NULL DEFAULT '{}',
42
42
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
43
- source TEXT NOT NULL CHECK(source IN ('手工','OpenCode'))
43
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
44
44
  );
45
45
  CREATE TABLE IF NOT EXISTS relations (
46
46
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -48,15 +48,17 @@ function initSchema() {
48
48
  target_id INTEGER NOT NULL,
49
49
  name TEXT NOT NULL,
50
50
  category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
51
+ confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
52
+ source_ref TEXT NOT NULL DEFAULT '',
51
53
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
52
- source TEXT NOT NULL CHECK(source IN ('手工','OpenCode'))
54
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
53
55
  );
54
56
  CREATE TABLE IF NOT EXISTS operation_logs (
55
57
  id INTEGER PRIMARY KEY AUTOINCREMENT,
56
58
  op_type TEXT NOT NULL,
57
59
  snapshot TEXT NOT NULL,
58
60
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
59
- source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','系统'))
61
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','系统','文档'))
60
62
  );
61
63
  CREATE INDEX IF NOT EXISTS idx_rel_source ON relations(source_id);
62
64
  CREATE INDEX IF NOT EXISTS idx_rel_target ON relations(target_id);
@@ -69,7 +71,15 @@ function initSchema() {
69
71
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
70
72
  );
71
73
  CREATE INDEX IF NOT EXISTS idx_img_entity ON entity_images(entity_id);
74
+ CREATE TABLE IF NOT EXISTS aliases (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
77
+ alias TEXT NOT NULL UNIQUE,
78
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
79
+ );
80
+ CREATE INDEX IF NOT EXISTS idx_alias_entity ON aliases(entity_id);
72
81
  `);
82
+ migrateEvidence();
73
83
  // 旧库迁移:entity_embeddings 已拆分至独立 vectors.db,主库内残留表直接清除
74
84
  const allTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((c) => c.name);
75
85
  if (allTables.includes('entity_embeddings')) {
@@ -82,6 +92,74 @@ function initSchema() {
82
92
  }
83
93
  }
84
94
 
95
+ // ---- 证据链迁移(幂等):旧库补 relations.confidence/source_ref 列,source 枚举扩展"文档" ----
96
+ function tableSql(name) {
97
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?").get(name);
98
+ return row ? row.sql : '';
99
+ }
100
+
101
+ function rebuildTableWithEnum(table, createSql, copyCols) {
102
+ db.exec('PRAGMA foreign_keys = OFF');
103
+ db.exec(`DROP TABLE IF EXISTS ${table}__rebuild`);
104
+ db.exec(createSql);
105
+ db.exec(`INSERT INTO ${table}__rebuild (${copyCols.join(',')}) SELECT ${copyCols.join(',')} FROM ${table}`);
106
+ db.exec(`DROP TABLE ${table}`);
107
+ db.exec(`ALTER TABLE ${table}__rebuild RENAME TO ${table}`);
108
+ if (table === 'relations') {
109
+ db.exec('CREATE INDEX IF NOT EXISTS idx_rel_source ON relations(source_id)');
110
+ db.exec('CREATE INDEX IF NOT EXISTS idx_rel_target ON relations(target_id)');
111
+ }
112
+ db.exec('PRAGMA foreign_keys = ON');
113
+ }
114
+
115
+ function migrateEvidence() {
116
+ // 1) relations 补 confidence / source_ref 列(CREATE TABLE 已含新列的全新库会直接跳过)
117
+ const relCols = db.prepare('PRAGMA table_info(relations)').all().map((c) => c.name);
118
+ if (relCols.length && !relCols.includes('confidence')) {
119
+ db.exec("ALTER TABLE relations ADD COLUMN confidence TEXT NOT NULL DEFAULT '确证'");
120
+ }
121
+ if (relCols.length && !relCols.includes('source_ref')) {
122
+ db.exec("ALTER TABLE relations ADD COLUMN source_ref TEXT NOT NULL DEFAULT ''");
123
+ }
124
+ // 2) source 枚举扩展"文档":检测建表语句,缺失则整表重建
125
+ const ENTITIES_NEW = `CREATE TABLE entities__rebuild (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ name TEXT NOT NULL,
128
+ category TEXT NOT NULL CHECK(category IN ('物理实体','抽象实体','数值实体','时间实体')),
129
+ attributes TEXT NOT NULL DEFAULT '{}',
130
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
131
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
132
+ )`;
133
+ const RELATIONS_NEW = `CREATE TABLE relations__rebuild (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ source_id INTEGER NOT NULL,
136
+ target_id INTEGER NOT NULL,
137
+ name TEXT NOT NULL,
138
+ category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
139
+ confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
140
+ source_ref TEXT NOT NULL DEFAULT '',
141
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
142
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
143
+ )`;
144
+ if (!tableSql('entities').includes("'文档'")) {
145
+ rebuildTableWithEnum('entities', ENTITIES_NEW, ['id', 'name', 'category', 'attributes', 'created_at', 'source']);
146
+ }
147
+ if (!tableSql('relations').includes("'文档'")) {
148
+ rebuildTableWithEnum('relations', RELATIONS_NEW, ['id', 'source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'created_at', 'source']);
149
+ }
150
+ if (!tableSql('operation_logs').includes("'文档'")) {
151
+ const LOGS_NEW = `CREATE TABLE operation_logs__rebuild (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ op_type TEXT NOT NULL,
154
+ snapshot TEXT NOT NULL,
155
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
156
+ source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','系统','文档'))
157
+ )`;
158
+ rebuildTableWithEnum('operation_logs', LOGS_NEW, ['id', 'op_type', 'snapshot', 'created_at', 'source']);
159
+ bumpVersion();
160
+ }
161
+ }
162
+
85
163
  function tx(fn) {
86
164
  db.exec('BEGIN');
87
165
  try {
@@ -173,8 +251,8 @@ function addRelationCore(input, source) {
173
251
  const check = V.validateRelationInput(input, db);
174
252
  if (!check.ok) throw writeErr(check);
175
253
  const v = check.value;
176
- const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, source) VALUES (?, ?, ?, ?, ?)')
177
- .run(v.source_id, v.target_id, v.name, v.category, source);
254
+ const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, confidence, source_ref, source) VALUES (?, ?, ?, ?, ?, ?, ?)')
255
+ .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, source);
178
256
  const id = Number(info.lastInsertRowid);
179
257
  const row = getRelation(id);
180
258
  logOp('ADD_RELATION', { relation: row }, source);
@@ -187,8 +265,8 @@ function updateRelationCore(id, patch, source) {
187
265
  const check = V.validateRelationPatch(patch, existing, db);
188
266
  if (!check.ok) throw writeErr(check);
189
267
  const v = check.value;
190
- db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ? WHERE id = ?')
191
- .run(v.source_id, v.target_id, v.name, v.category, id);
268
+ db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ? WHERE id = ?')
269
+ .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, id);
192
270
  const row = getRelation(id);
193
271
  logOp('UPDATE_RELATION', { before: existing, after: row }, source);
194
272
  return row;
@@ -286,6 +364,45 @@ function findPath(fromId, toId, maxHops = 6) {
286
364
  return { found: false, hops: null, entities: [], relations: [] };
287
365
  }
288
366
 
367
+ // 多路径枚举:无向BFS,按跳数升序返回至多 limit 条简单路径
368
+ function findPaths(fromId, toId, opts = {}) {
369
+ const maxHops = Math.min(6, Math.max(2, Number.isInteger(opts.maxHops) ? opts.maxHops : 4));
370
+ const limit = Math.min(10, Math.max(1, Number.isInteger(opts.limit) ? opts.limit : 5));
371
+ const from = getEntity(fromId);
372
+ const to = getEntity(toId);
373
+ if (!from || !to) return { found: false, paths: [], hint: '起点或终点实体不存在' };
374
+ if (fromId === toId) return { found: true, paths: [{ hops: 0, entities: [from], relations: [] }] };
375
+ const adj = new Map();
376
+ const touch = (id) => { if (!adj.has(id)) adj.set(id, []); };
377
+ for (const r of listRelations()) {
378
+ touch(r.source_id); touch(r.target_id);
379
+ adj.get(r.source_id).push(r);
380
+ adj.get(r.target_id).push(r);
381
+ }
382
+ const paths = [];
383
+ let frontier = [{ id: fromId, entities: [from], relations: [] }];
384
+ const visitedAtDepth = new Map([[fromId, 0]]);
385
+ for (let d = 0; d < maxHops && frontier.length && paths.length < limit; d++) {
386
+ const next = [];
387
+ for (const node of frontier) {
388
+ for (const r of adj.get(node.id) || []) {
389
+ const other = r.source_id === node.id ? r.target_id : r.source_id;
390
+ if (node.entities.some((e) => e.id === other)) continue; // 简单路径:不重复访问
391
+ const entities = [...node.entities, getEntity(other)];
392
+ const relations = [...node.relations, r];
393
+ if (other === toId) {
394
+ paths.push({ hops: relations.length, entities, relations });
395
+ break; // 同层内到达终点即止,更长的绕行路径不扩展
396
+ }
397
+ next.push({ id: other, entities, relations });
398
+ }
399
+ }
400
+ frontier = next;
401
+ }
402
+ paths.sort((a, b) => a.hops - b.hops);
403
+ return { found: paths.length > 0, paths: paths.slice(0, limit), hint: paths.length ? null : `当前跳数上限${maxHops}内无路径` };
404
+ }
405
+
289
406
  // ---- 撤销最近一条操作:按快照生成逆向写入,单事务 + 单条UNDO日志 ----
290
407
  // DELETE_ENTITY 的级联关系/图片不在快照内,仅恢复实体本身(完整恢复请回溯保存点)
291
408
  // 连续撤销:向后扫描时跳过UNDO日志与其已撤销的目标日志;候选为RESTORE时拦截
@@ -509,7 +626,84 @@ function egoSubgraph(centerId, depth) {
509
626
  }
510
627
 
511
628
  function getGraph() {
512
- return { entities: listEntities(), relations: listRelations(), image_counts: imageCounts() };
629
+ return { entities: listEntities(), relations: listRelations(), image_counts: imageCounts(), aliases: aliasMap() };
630
+ }
631
+
632
+ // ---- 别名与同名消歧 ----
633
+ function cleanAlias(alias) {
634
+ return String(alias === undefined || alias === null ? '' : alias).trim();
635
+ }
636
+
637
+ function listAliases(entityId) {
638
+ return db.prepare('SELECT id, entity_id, alias, created_at FROM aliases WHERE entity_id = ? ORDER BY id').all(entityId);
639
+ }
640
+
641
+ function aliasMap() {
642
+ const map = {};
643
+ for (const row of db.prepare('SELECT entity_id, alias FROM aliases ORDER BY id').all()) {
644
+ (map[row.entity_id] = map[row.entity_id] || []).push(row.alias);
645
+ }
646
+ return map;
647
+ }
648
+
649
+ function aliasRecords() {
650
+ return db.prepare('SELECT id, entity_id, alias, created_at FROM aliases ORDER BY id').all();
651
+ }
652
+
653
+ // 名称占用检测:与任何实体主名或别名一致(trim后精确匹配)
654
+ function findNameConflicts(name) {
655
+ const n = cleanAlias(name);
656
+ if (!n) return [];
657
+ const conflicts = [];
658
+ for (const e of db.prepare('SELECT id, name, category FROM entities WHERE name = ?').all(n)) {
659
+ conflicts.push({ id: e.id, name: e.name, category: e.category, via: '主名' });
660
+ }
661
+ for (const row of db.prepare('SELECT a.entity_id, a.alias, e.name, e.category FROM aliases a JOIN entities e ON e.id = a.entity_id WHERE a.alias = ?').all(n)) {
662
+ conflicts.push({ id: row.entity_id, name: row.name, category: row.category, via: `别名(${row.alias})` });
663
+ }
664
+ return conflicts;
665
+ }
666
+
667
+ function aliasConflict(alias, excludeEntityId) {
668
+ const n = cleanAlias(alias);
669
+ if (!n) return null;
670
+ const byName = db.prepare('SELECT id, name, category FROM entities WHERE name = ?').get(n);
671
+ if (byName && byName.id !== excludeEntityId) return { id: byName.id, name: byName.name, category: byName.category, via: '主名' };
672
+ const byAlias = db.prepare('SELECT entity_id, alias FROM aliases WHERE alias = ?').get(n);
673
+ if (byAlias && byAlias.entity_id !== excludeEntityId) {
674
+ const e = getEntity(byAlias.entity_id);
675
+ return { id: e.id, name: e.name, category: e.category, via: `别名(${byAlias.alias})` };
676
+ }
677
+ return null;
678
+ }
679
+
680
+ function addAlias(entityId, alias, source) {
681
+ const e = getEntity(entityId);
682
+ if (!e) { const err = new Error(`实体id=${entityId} 不存在`); err.status = 404; throw err; }
683
+ const n = cleanAlias(alias);
684
+ if (!n) { const err = new Error('别名必须为非空字符串'); err.status = 400; throw err; }
685
+ if (n.length > 200) { const err = new Error('别名长度不得超过200字符'); err.status = 400; throw err; }
686
+ if (n === e.name) { const err = new Error('别名与实体主名相同'); err.status = 400; throw err; }
687
+ const dup = db.prepare('SELECT id, entity_id, alias, created_at FROM aliases WHERE entity_id = ? AND alias = ?').get(entityId, n);
688
+ if (dup) return dup; // 幂等:同一实体同名别名已存在则直接返回
689
+ const conflict = aliasConflict(n, entityId);
690
+ if (conflict) { const err = new Error(`别名"${n}"已被占用`); err.status = 409; err.conflicts = [conflict]; throw err; }
691
+ return tx(() => {
692
+ const info = db.prepare('INSERT INTO aliases (entity_id, alias) VALUES (?, ?)').run(entityId, n);
693
+ const row = db.prepare('SELECT id, entity_id, alias, created_at FROM aliases WHERE id = ?').get(Number(info.lastInsertRowid));
694
+ logOp('ADD_ALIAS', { alias: row }, source);
695
+ return row;
696
+ });
697
+ }
698
+
699
+ function removeAlias(aliasId, source) {
700
+ const row = db.prepare('SELECT id, entity_id, alias FROM aliases WHERE id = ?').get(aliasId);
701
+ if (!row) { const err = new Error(`别名id=${aliasId} 不存在`); err.status = 404; throw err; }
702
+ return tx(() => {
703
+ db.prepare('DELETE FROM aliases WHERE id = ?').run(aliasId);
704
+ logOp('DELETE_ALIAS', { alias: row }, source);
705
+ return { deleted: row };
706
+ });
513
707
  }
514
708
 
515
709
  function getLogs(limit = 200) {
@@ -605,7 +799,8 @@ module.exports = {
605
799
  addEntity, updateEntity, deleteEntity,
606
800
  addRelation, updateRelation, deleteRelation,
607
801
  addEntityImage, getEntityImage, listEntityImages, deleteEntityImage, imageCounts, pruneMissingImages,
608
- egoSubgraph, findPath, resolveKey,
802
+ egoSubgraph, findPath, findPaths, resolveKey,
803
+ listAliases, addAlias, removeAlias, aliasMap, aliasRecords, findNameConflicts,
609
804
  undoLast,
610
805
  miniCypher,
611
806
  applyAgentOps, getGraph, getLogs, maxLogId, counts, logRestore,
package/lib/embeddings.js CHANGED
@@ -18,6 +18,9 @@ const DEFAULTS = {
18
18
  dim: 1024,
19
19
  api_key: '',
20
20
  rrf_k: 60,
21
+ mcp_enabled: false,
22
+ mcp_token: '',
23
+ mcp_readonly: false,
21
24
  };
22
25
 
23
26
  function loadSettings() {
@@ -99,18 +102,22 @@ async function build(progress) {
99
102
  return { indexed: vectors.count(), total: ents.length };
100
103
  }
101
104
 
102
- // 关键词检索:名称包含/属性包含,粗略打分(命中名称权重高)
105
+ // 关键词检索:名称/别名/属性包含,粗略打分(命中名称权重高,别名次之)
103
106
  function keywordSearch(graph, query) {
104
107
  const q = query.trim().toLowerCase();
105
108
  if (!q) return [];
106
109
  const terms = q.split(/\s+/);
110
+ const aliasMap = graph.aliases || {};
107
111
  const scored = [];
108
112
  for (const e of graph.entities) {
109
113
  let score = 0;
110
114
  const name = e.name.toLowerCase();
115
+ const aliases = (aliasMap[e.id] || []).map((a) => a.toLowerCase());
111
116
  for (const t of terms) {
112
117
  if (name === t) score += 10;
113
118
  else if (name.includes(t)) score += 5;
119
+ else if (aliases.some((a) => a === t)) score += 6;
120
+ else if (aliases.some((a) => a.includes(t))) score += 3;
114
121
  else if (entityText(e).toLowerCase().includes(t)) score += 1;
115
122
  }
116
123
  if (score > 0) scored.push({ id: e.id, score });