metame-cli 1.6.2 → 1.6.3

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.
@@ -15,6 +15,7 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
+ const crypto = require('crypto');
18
19
  const { callHaiku, buildDistillEnv } = require('./providers');
19
20
 
20
21
  const HOME = os.homedir();
@@ -115,6 +116,63 @@ const VAGUE_PATTERNS = [
115
116
  ];
116
117
  const ALLOWED_FLAT = new Set(['王总', 'system', 'user']);
117
118
 
119
+ function hashFile(filePath) {
120
+ if (!filePath) return null;
121
+ try {
122
+ const hash = crypto.createHash('sha256');
123
+ const fd = fs.openSync(filePath, 'r');
124
+ try {
125
+ const buf = Buffer.alloc(64 * 1024);
126
+ let bytesRead = 0;
127
+ do {
128
+ bytesRead = fs.readSync(fd, buf, 0, buf.length, null);
129
+ if (bytesRead > 0) hash.update(buf.subarray(0, bytesRead));
130
+ } while (bytesRead > 0);
131
+ } finally {
132
+ fs.closeSync(fd);
133
+ }
134
+ return hash.digest('hex');
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function statSize(filePath) {
141
+ try {
142
+ return filePath ? fs.statSync(filePath).size : 0;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
147
+
148
+ function saveSessionSource(memory, engine, sourcePath, skeleton, status = 'indexed', errorMessage = null) {
149
+ if (!memory || typeof memory.saveSessionSource !== 'function' || !skeleton) return null;
150
+ const sourceHash = hashFile(sourcePath);
151
+ if (!sourceHash) return null;
152
+ try {
153
+ return memory.saveSessionSource({
154
+ engine,
155
+ sessionId: skeleton.session_id,
156
+ project: skeleton.project || 'unknown',
157
+ scope: skeleton.project_id || null,
158
+ cwd: skeleton.project_path || null,
159
+ sourcePath,
160
+ sourceHash,
161
+ sourceSize: statSize(sourcePath),
162
+ firstTs: skeleton.first_ts || null,
163
+ lastTs: skeleton.last_ts || null,
164
+ messageCount: skeleton.message_count || 0,
165
+ toolCallCount: skeleton.total_tool_calls || 0,
166
+ toolErrorCount: skeleton.tool_error_count || 0,
167
+ status,
168
+ errorMessage,
169
+ });
170
+ } catch (e) {
171
+ console.log(`[memory-extract] session source save failed: ${e.message}`);
172
+ return null;
173
+ }
174
+ }
175
+
118
176
  /**
119
177
  * Extract atomic facts from session skeleton + evidence via Haiku.
120
178
  * Returns filtered fact array (may be empty).
@@ -212,8 +270,6 @@ async function run() {
212
270
  const sessions = sessionAnalytics.findAllUnextractedSessions(3);
213
271
  if (sessions.length === 0) {
214
272
  console.log('[memory-extract] No unanalyzed sessions found.');
215
- memory.close();
216
- return { sessionsProcessed: 0, factsSaved: 0, factsSkipped: 0 };
217
273
  }
218
274
 
219
275
  let totalSaved = 0;
@@ -223,9 +279,11 @@ async function run() {
223
279
  for (const session of sessions) {
224
280
  try {
225
281
  const skeleton = sessionAnalytics.extractSkeleton(session.path);
282
+ const sourceRow = saveSessionSource(memory, 'claude', session.path, skeleton);
226
283
 
227
284
  // Skip trivial sessions
228
285
  if (skeleton.message_count < 2 && skeleton.duration_min < 1) {
286
+ if (sourceRow) saveSessionSource(memory, 'claude', session.path, skeleton, 'archived');
229
287
  sessionAnalytics.markFactsExtracted(skeleton.session_id);
230
288
  continue;
231
289
  }
@@ -237,6 +295,7 @@ async function run() {
237
295
 
238
296
  const { ok, facts, session_name } = await extractFacts(skeleton, evidence, distillEnv);
239
297
  if (!ok) {
298
+ if (sourceRow) saveSessionSource(memory, 'claude', session.path, skeleton, 'error', 'fact extraction failed');
240
299
  console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: extraction failed, will retry later`);
241
300
  continue;
242
301
  }
@@ -249,7 +308,7 @@ async function run() {
249
308
  skeleton.session_id,
250
309
  skeleton.project || 'unknown',
251
310
  facts,
252
- { scope: skeleton.project_id || fallbackScope }
311
+ { scope: skeleton.project_id || fallbackScope, source_id: sourceRow ? sourceRow.id : skeleton.session_id }
253
312
  );
254
313
  totalSaved += saved;
255
314
  totalSkipped += skipped;
@@ -276,6 +335,7 @@ async function run() {
276
335
 
277
336
  // P2-A: persist session name + tags to session_tags.json
278
337
  saveSessionTag(skeleton.session_id, session_name, facts);
338
+ if (sourceRow) saveSessionSource(memory, 'claude', session.path, skeleton, 'extracted');
279
339
 
280
340
  processed++;
281
341
  } catch (e) {
@@ -294,15 +354,18 @@ async function run() {
294
354
  for (const cs of codexSessions) {
295
355
  try {
296
356
  const { skeleton, evidence } = sessionAnalytics.buildCodexInput(cs.path, historyMap);
357
+ const sourceRow = saveSessionSource(memory, 'codex', cs.path, skeleton);
297
358
 
298
359
  // Skip trivial sessions with no user messages
299
360
  if (skeleton.message_count < 1) {
361
+ if (sourceRow) saveSessionSource(memory, 'codex', cs.path, skeleton, 'archived');
300
362
  sessionAnalytics.markCodexFactsExtracted(cs.session_id);
301
363
  continue;
302
364
  }
303
365
 
304
366
  const { ok, facts, session_name } = await extractFacts(skeleton, evidence, distillEnv);
305
367
  if (!ok) {
368
+ if (sourceRow) saveSessionSource(memory, 'codex', cs.path, skeleton, 'error', 'fact extraction failed');
306
369
  console.log(`[memory-extract] Codex ${cs.session_id.slice(0, 8)}: extraction failed, will retry later`);
307
370
  continue;
308
371
  }
@@ -313,7 +376,11 @@ async function run() {
313
376
  cs.session_id,
314
377
  skeleton.project || 'unknown',
315
378
  facts,
316
- { scope: skeleton.project_id || fallbackScope, source_type: 'codex' }
379
+ {
380
+ scope: skeleton.project_id || fallbackScope,
381
+ source_type: 'codex',
382
+ source_id: sourceRow ? sourceRow.id : cs.session_id,
383
+ }
317
384
  );
318
385
  totalSaved += saved;
319
386
  totalSkipped += skipped;
@@ -339,6 +406,7 @@ async function run() {
339
406
  }
340
407
 
341
408
  sessionAnalytics.markCodexFactsExtracted(cs.session_id);
409
+ if (sourceRow) saveSessionSource(memory, 'codex', cs.path, skeleton, 'extracted');
342
410
  processed++;
343
411
  } catch (e) {
344
412
  console.log(`[memory-extract] Codex session error: ${e.message}`);
@@ -130,6 +130,37 @@ function applyWikiSchema(db) {
130
130
  )
131
131
  `);
132
132
 
133
+ // ── session_sources (raw transcript provenance, L0) ───────────────────────
134
+ db.exec(`
135
+ CREATE TABLE IF NOT EXISTS session_sources (
136
+ id TEXT PRIMARY KEY,
137
+ engine TEXT NOT NULL DEFAULT 'unknown'
138
+ CHECK (engine IN ('claude','codex','unknown')),
139
+ session_id TEXT NOT NULL,
140
+ project TEXT DEFAULT '*',
141
+ scope TEXT,
142
+ agent_key TEXT,
143
+ cwd TEXT,
144
+ source_path TEXT,
145
+ source_hash TEXT NOT NULL,
146
+ source_size INTEGER DEFAULT 0,
147
+ first_ts TEXT,
148
+ last_ts TEXT,
149
+ message_count INTEGER DEFAULT 0,
150
+ tool_call_count INTEGER DEFAULT 0,
151
+ tool_error_count INTEGER DEFAULT 0,
152
+ status TEXT DEFAULT 'indexed'
153
+ CHECK (status IN ('indexed','summarized','extracted','error','archived')),
154
+ error_message TEXT,
155
+ created_at TEXT DEFAULT (datetime('now')),
156
+ updated_at TEXT DEFAULT (datetime('now')),
157
+ UNIQUE(engine, session_id, source_hash)
158
+ )
159
+ `);
160
+ db.exec('CREATE INDEX IF NOT EXISTS idx_session_sources_session ON session_sources(session_id)');
161
+ db.exec('CREATE INDEX IF NOT EXISTS idx_session_sources_project ON session_sources(project, scope, last_ts)');
162
+ db.exec('CREATE INDEX IF NOT EXISTS idx_session_sources_agent ON session_sources(agent_key, last_ts)');
163
+
133
164
  // ── doc_sources ───────────────────────────────────────────────────────────
134
165
  db.exec(`
135
166
  CREATE TABLE IF NOT EXISTS doc_sources (
package/scripts/memory.js CHANGED
@@ -177,6 +177,11 @@ function saveMemoryItem(item) {
177
177
  return { ok: true, id };
178
178
  }
179
179
 
180
+ function saveSessionSource(source) {
181
+ const { upsertSessionSource } = require('./core/session-source-db');
182
+ return upsertSessionSource(getDb(), source);
183
+ }
184
+
180
185
  function searchMemoryItems(query, { kind = null, scope = null, project = null, state = 'active', limit = 20 } = {}) {
181
186
  const db = getDb();
182
187
  const conditions = [];
@@ -335,7 +340,7 @@ function saveSession({ sessionId, project, scope = null, summary, keywords = ''
335
340
  });
336
341
  }
337
342
 
338
- function saveFacts(sessionId, project, facts, { scope = null, source_type = null } = {}) {
343
+ function saveFacts(sessionId, project, facts, { scope = null, source_type = null, source_id = null } = {}) {
339
344
  if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0, savedFacts: [] };
340
345
  const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
341
346
  let saved = 0;
@@ -363,7 +368,7 @@ function saveFacts(sessionId, project, facts, { scope = null, source_type = null
363
368
  scope: scope || null,
364
369
  session_id: sessionId,
365
370
  source_type: f.source_type || source_type || 'session',
366
- source_id: sessionId,
371
+ source_id: f.source_id || source_id || sessionId,
367
372
  relation: f.relation,
368
373
  tags,
369
374
  });
@@ -564,6 +569,7 @@ async function hybridSearchWiki(query, { ftsOnly = false, expand = false, trackS
564
569
  module.exports = {
565
570
  // core
566
571
  saveMemoryItem,
572
+ saveSessionSource,
567
573
  searchMemoryItems,
568
574
  promoteItem,
569
575
  archiveItem,
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: send-to-user
3
+ description: |
4
+ 把本地文件直接发到用户手机(飞书 / Telegram / iMessage),而不是只在群里报路径。
5
+ 触发:用户说「把 X 文件发我 / 给我下载 / 发到手机 / 发文件」、要 PDF/CSV/PNG/log
6
+ 下载、要查看文件内容(超过聊天可读长度)。
7
+ ---
8
+
9
+ # Send-to-User — 把文件交付到用户手机
10
+
11
+ ## 它解决什么问题
12
+
13
+ 聊天里贴长文本/大块代码体验很糟。当用户要的是一个**文件**(截图、报表、日志、
14
+ 压缩包、构件),最佳交付方式是直接把文件推到他们手机上的对话里——飞书会显示成
15
+ 附件卡片、可下载、可分享。MetaMe 已经把上传、`file_key` 转换、消息发送全部封装
16
+ 好,你只需要在回复里贴一个 marker。
17
+
18
+ ## 怎么用——一行 marker 协议
19
+
20
+ 完成你正常的回答后,**在回复末尾**为每个要交付的文件追加一行:
21
+
22
+ ```
23
+ [[FILE:/absolute/path/to/file]]
24
+ ```
25
+
26
+ 约束:
27
+
28
+ 1. **绝对路径**。不写 `~`、不写相对路径——daemon 不会做 shell 展开。
29
+ 2. **一行一个文件**。多个文件就多行。每行只放一个 marker,前后不要再加文字。
30
+ 3. **文件必须已经存在**。daemon 在发送前会校验存在性,不存在的会被静默丢弃。
31
+ 4. **marker 独占行**。不要把 marker 写在 markdown 列表项里、不要包在反引号里。
32
+ 5. 用户**看不到**这一行 marker——daemon 解析后会从输出里剥掉再回显文本,所以
33
+ 你的正文该写什么写什么,marker 是给 daemon 看的「附录」。
34
+ 6. **路径含空格/中文是 OK 的**,不要做 shell 转义、不要加引号——daemon 直接当
35
+ 字面字符串送给 `fs.statSync` 与上传 API。例:`[[FILE:/Users/王总/桌面/今日报表.xlsx]]`。
36
+
37
+ ## 完整示例
38
+
39
+ ```
40
+ 我已经把今天的访问日志整理好了,9–11 点有一波 502 集中在 nginx-edge-3,
41
+ 原因是上游 keepalive 池被打满,详情见附件。
42
+
43
+ [[FILE:/var/log/metame/edge-2026-04-28.csv]]
44
+ [[FILE:/Users/yaron/Desktop/edge-error-summary.png]]
45
+ ```
46
+
47
+ daemon 会:
48
+ - 文本部分作为飞书消息正常发送("我已经把...")
49
+ - CSV 与 PNG 各上传一次,作为飞书附件出现在用户对话里
50
+ - 上传失败时回退为文本告知文件路径,你不需要自己处理失败
51
+
52
+ ## 何时**不**用
53
+
54
+ - 用户问的是文件**内容**,且文件 < ~200 行 → 直接把内容贴进消息更直接
55
+ - 用户在做代码 review,不需要文件本体只想看代码 → 用代码块更方便
56
+ - 你刚刚生成的临时文件还没写到磁盘 → 先 `Write` 工具落盘,再贴 marker
57
+
58
+ ## 调试
59
+
60
+ 如果用户说「没收到附件」:
61
+
62
+ 1. 你的 marker 是否独占一行?多检查反引号、列表项、空格。
63
+ 2. 文件是否真的存在?在 marker 之前先 `ls -la /the/path` 确认。
64
+ 3. 是否大于飞书附件大小限制(单文件 30MB)?大文件请压缩或分片。
65
+ 4. 飞书应用是否拥有 `im:resource` 权限?这是首装环节的事,你这边没办法补救,
66
+ 告诉用户去 https://open.feishu.cn 应用后台核对。
67
+
68
+ ## 技术细节(供你判断使用)
69
+
70
+ - 解析器: `scripts/daemon-claude-engine.js` 里 `parseFileMarkers()`
71
+ - 发送器: `scripts/daemon-file-browser.js` 里 `sendFileButtons()`,优先调用
72
+ `bot.sendFile`(飞书走 `client.im.file.create` 上传 → file_key → 消息)
73
+ - 飞书适配器: `scripts/feishu-adapter.js` 的 `sendFile(chatId, filePath, caption)`
74
+ - 失败兜底: 文本文件会被截断到 3000 字以纯文本回显;二进制文件抛错给用户。
75
+
76
+ 不需要自己调以上 API——你只负责贴 marker,剩下的 daemon 都做了。