metame-cli 1.4.12 → 1.4.13

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.
@@ -21,7 +21,7 @@ const HOME = os.homedir();
21
21
  const LOCK_FILE = path.join(HOME, '.metame', 'memory-extract.lock');
22
22
 
23
23
  // Atomic fact extraction prompt (local copy — distill.js no longer exports this)
24
- const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会话骨架中提取「值得长期记住的原子事实」。
24
+ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会话材料中提取「值得长期记住的原子事实」。
25
25
 
26
26
  提取类型(必须是以下之一):
27
27
  - tech_decision(技术决策:为什么选A不选B)
@@ -53,12 +53,13 @@ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会
53
53
  - value长度20-200字
54
54
  - entity用英文点号路径,value可用中文
55
55
  - medium confidence必须有非空tags
56
+ - 优先引用证据里的具体锚点(文件名、命令、报错关键词);没有锚点时不要硬编
56
57
  - 没有值得提取的事实时 facts 返回 []
57
58
 
58
59
  只输出JSON对象,不要解释。
59
60
 
60
- 会话骨架:
61
- {{SKELETON}}`.trim();
61
+ 会话材料(包含骨架 + 证据):
62
+ {{SESSION_INPUT}}`.trim();
62
63
 
63
64
  const SESSION_TAGS_FILE = path.join(os.homedir(), '.metame', 'session_tags.json');
64
65
 
@@ -105,12 +106,12 @@ const VAGUE_PATTERNS = [
105
106
  const ALLOWED_FLAT = new Set(['王总', 'system', 'user']);
106
107
 
107
108
  /**
108
- * Extract atomic facts from a session skeleton via Haiku.
109
+ * Extract atomic facts from session skeleton + evidence via Haiku.
109
110
  * Returns filtered fact array (may be empty).
110
111
  */
111
- async function extractFacts(skeleton, sessionSummary, distillEnv) {
112
- const skeletonText = JSON.stringify({ skeleton, sessionSummary }, null, 2).slice(0, 3000);
113
- const prompt = FACT_EXTRACTION_PROMPT.replace('{{SKELETON}}', skeletonText);
112
+ async function extractFacts(skeleton, evidence, distillEnv) {
113
+ const sessionInput = JSON.stringify({ skeleton, evidence }, null, 2).slice(0, 4500);
114
+ const prompt = FACT_EXTRACTION_PROMPT.replace('{{SESSION_INPUT}}', sessionInput);
114
115
 
115
116
  let raw;
116
117
  try {
@@ -120,7 +121,7 @@ async function extractFacts(skeleton, sessionSummary, distillEnv) {
120
121
  ]);
121
122
  } catch (e) {
122
123
  console.log(`[memory-extract] Haiku call failed: ${e.message} | code:${e.code} killed:${e.killed} stdout:${String(e.stdout || '').slice(0, 100)} stderr:${String(e.stderr || '').slice(0, 100)}`);
123
- return [];
124
+ return { facts: [], session_name: "未命名会话" };
124
125
  }
125
126
 
126
127
  let parsed;
@@ -219,7 +220,12 @@ async function run() {
219
220
  continue;
220
221
  }
221
222
 
222
- const { facts, session_name } = await extractFacts(skeleton, null, distillEnv);
223
+ let evidence = null;
224
+ try {
225
+ evidence = sessionAnalytics.extractEvidence(session.path, 3000);
226
+ } catch { /* non-fatal */ }
227
+
228
+ const { facts, session_name } = await extractFacts(skeleton, evidence, distillEnv);
223
229
 
224
230
  if (facts.length > 0) {
225
231
  const { saved, skipped, superseded } = memory.saveFacts(
@@ -233,6 +233,121 @@ function extractSkeleton(jsonlPath) {
233
233
  return skeleton;
234
234
  }
235
235
 
236
+ /**
237
+ * Extract compact evidence from a session JSONL for memory extraction.
238
+ * Returns { user_messages, tool_traces, key_results, file_anchors }.
239
+ */
240
+ function extractEvidence(jsonlPath, budget = 3000) {
241
+ const content = fs.readFileSync(jsonlPath, 'utf8');
242
+ const lines = content.split('\n');
243
+
244
+ const totalBudget = Math.max(600, budget);
245
+ const userBudget = Math.floor(totalBudget / 3);
246
+ const toolBudget = Math.floor(totalBudget / 3);
247
+ const resultBudget = totalBudget - userBudget - toolBudget;
248
+
249
+ const evidence = {
250
+ user_messages: [],
251
+ tool_traces: [],
252
+ key_results: [],
253
+ file_anchors: [],
254
+ };
255
+
256
+ const seen = {
257
+ user: new Set(),
258
+ tool: new Set(),
259
+ result: new Set(),
260
+ file: new Set(),
261
+ };
262
+ const used = { user: 0, tool: 0, result: 0 };
263
+
264
+ const addWithBudget = (bucket, key, text, maxChars) => {
265
+ if (!text || !text.trim()) return;
266
+ const normalized = text.replace(/\s+/g, ' ').trim();
267
+ if (!normalized || seen[key].has(normalized)) return;
268
+ const room = maxChars - used[key];
269
+ if (room <= 0) return;
270
+ const clipped = normalized.slice(0, room);
271
+ if (clipped.length < 12) return;
272
+ bucket.push(clipped);
273
+ seen[key].add(normalized);
274
+ used[key] += clipped.length;
275
+ };
276
+
277
+ for (const line of lines) {
278
+ if (!line.trim()) continue;
279
+ if (!line.includes('"type"')) continue;
280
+
281
+ let entry;
282
+ try { entry = JSON.parse(line); } catch { continue; }
283
+
284
+ // User raw messages (exclude tool_result wrappers)
285
+ if (entry.type === 'user' && entry.message && entry.message.content) {
286
+ const msg = entry.message.content;
287
+ if (typeof msg === 'string') {
288
+ addWithBudget(evidence.user_messages, 'user', msg, userBudget);
289
+ } else if (Array.isArray(msg)) {
290
+ for (const item of msg) {
291
+ if (item && item.type === 'text' && item.text) {
292
+ addWithBudget(evidence.user_messages, 'user', item.text, userBudget);
293
+ } else if (item && item.type === 'tool_result' && item.is_error) {
294
+ const toolText = typeof item.content === 'string'
295
+ ? item.content
296
+ : Array.isArray(item.content)
297
+ ? item.content.map(c => (typeof c === 'string' ? c : c && c.text ? c.text : '')).join(' ')
298
+ : '';
299
+ addWithBudget(evidence.key_results, 'result', `tool_result error: ${toolText.slice(0, 120)}`, resultBudget);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ if (entry.type === 'assistant' && entry.message && Array.isArray(entry.message.content)) {
306
+ for (const item of entry.message.content) {
307
+ if (!item || item.type !== 'tool_use') continue;
308
+ const name = item.name || 'unknown';
309
+ const input = item.input || {};
310
+
311
+ if ((name === 'Write' || name === 'Edit') && typeof input.file_path === 'string') {
312
+ const base = path.basename(input.file_path);
313
+ const trace = `${name} ${base}`;
314
+ addWithBudget(evidence.tool_traces, 'tool', trace, toolBudget);
315
+ if (base && !seen.file.has(base)) {
316
+ evidence.file_anchors.push(base);
317
+ seen.file.add(base);
318
+ }
319
+ } else if (name === 'Bash' && typeof input.command === 'string') {
320
+ const cmd = input.command.replace(/\s+/g, ' ').trim();
321
+ const trace = `Bash ${cmd.slice(0, 120)}`;
322
+ addWithBudget(evidence.tool_traces, 'tool', trace, toolBudget);
323
+ }
324
+ }
325
+ }
326
+
327
+ // tool_result can appear as standalone event in some transcripts
328
+ if (entry.type === 'tool_result') {
329
+ const result = entry.message || {};
330
+ const isError = !!result.is_error;
331
+ const snippet = typeof result.content === 'string'
332
+ ? result.content
333
+ : Array.isArray(result.content)
334
+ ? result.content.map(c => (typeof c === 'string' ? c : c && c.text ? c.text : '')).join(' ')
335
+ : '';
336
+ if (isError) {
337
+ addWithBudget(evidence.key_results, 'result', `tool_result error: ${snippet.slice(0, 120)}`, resultBudget);
338
+ }
339
+ }
340
+ }
341
+
342
+ // Tight caps keep payload small and predictable
343
+ evidence.user_messages = evidence.user_messages.slice(0, 8);
344
+ evidence.tool_traces = evidence.tool_traces.slice(0, 12);
345
+ evidence.key_results = evidence.key_results.slice(0, 6);
346
+ evidence.file_anchors = evidence.file_anchors.slice(0, 12);
347
+
348
+ return evidence;
349
+ }
350
+
236
351
  /**
237
352
  * Format skeleton as a compact one-liner for injection into the distill prompt.
238
353
  * Target: ~60 tokens.
@@ -467,6 +582,7 @@ module.exports = {
467
582
  findAllUnanalyzedSessions,
468
583
  findAllUnextractedSessions,
469
584
  extractSkeleton,
585
+ extractEvidence,
470
586
  formatForPrompt,
471
587
  formatGoalContext,
472
588
  summarizeSession,