metame-cli 1.4.18 → 1.4.20

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.
@@ -106,6 +106,59 @@ function createSessionStore(deps) {
106
106
 
107
107
  function invalidateSessionCache() { _sessionCache = null; }
108
108
 
109
+ // 监听 ~/.claude/projects 目录,手机端新建 session 后桌面端无需重启即可感知
110
+ let _watcher = null;
111
+ let _invalidateDebounce = null;
112
+
113
+ function _debouncedInvalidate() {
114
+ if (_invalidateDebounce) return;
115
+ _invalidateDebounce = setTimeout(() => {
116
+ _sessionCache = null;
117
+ _invalidateDebounce = null;
118
+ }, 500);
119
+ }
120
+
121
+ function watchSessionFiles() {
122
+ // 先关闭旧 watcher,防止热重载时叠加
123
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
124
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return;
125
+ try {
126
+ _watcher = fs.watch(CLAUDE_PROJECTS_DIR, { recursive: true }, (evt, filename) => {
127
+ if (filename && filename.endsWith('.jsonl')) _debouncedInvalidate();
128
+ });
129
+ _watcher.on('error', (e) => {
130
+ log('WARN', '[session-store] fs.watch error: ' + e.message);
131
+ _watcher = null;
132
+ });
133
+ log('INFO', '[session-store] fs.watch active on ' + CLAUDE_PROJECTS_DIR);
134
+ } catch (e) {
135
+ log('WARN', '[session-store] fs.watch failed, fallback to TTL cache: ' + e.message);
136
+ }
137
+ }
138
+
139
+ function stopWatchingSessionFiles() {
140
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
141
+ }
142
+
143
+ // [M3] 共享辅助:从 reversed JSONL 行数组中提取最后一条外部用户消息(统一规则)
144
+ function extractLastUserFromLines(lines) {
145
+ for (const line of lines) {
146
+ if (!line) continue;
147
+ try {
148
+ const d = JSON.parse(line);
149
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
150
+ const content = d.message.content;
151
+ let raw = typeof content === 'string' ? content
152
+ : Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
153
+ raw = raw.replace(/\[System hints[\s\S]*/i, '')
154
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
155
+ if (raw.length > 2) return raw.slice(0, 80);
156
+ }
157
+ } catch { /* skip */ }
158
+ }
159
+ return '';
160
+ }
161
+
109
162
  function scanAllSessions() {
110
163
  if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
111
164
  try {
@@ -162,49 +215,58 @@ function createSessionStore(deps) {
162
215
  const ENRICH_LIMIT = 20;
163
216
  for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
164
217
  const s = all[i];
165
- if (s.firstPrompt && s.customTitle) continue;
218
+ // [M1] _enriched 标志替代三字段联合判断
219
+ // customTitle 是可选的,无命名 session 合法值为 undefined,不能作为 skip 条件
220
+ if (s._enriched) continue;
166
221
  try {
167
222
  const sessionFile = findSessionFile(s.sessionId);
168
223
  if (!sessionFile) continue;
169
224
  const fd = fs.openSync(sessionFile, 'r');
170
- const headBuf = Buffer.alloc(8192);
171
- const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
172
- const headStr = headBuf.toString('utf8', 0, headBytes);
173
- if (!s.firstPrompt) {
174
- for (const line of headStr.split('\n')) {
175
- if (!line) continue;
176
- try {
177
- const d = JSON.parse(line);
178
- if (d.type === 'user' && d.message && d.userType === 'external') {
179
- const content = d.message.content;
180
- let raw = '';
181
- if (typeof content === 'string') raw = content;
182
- else if (Array.isArray(content)) {
183
- const txt = content.find(c => c.type === 'text');
184
- if (txt) raw = txt.text;
225
+ try {
226
+ if (!s.firstPrompt) {
227
+ const headBuf = Buffer.alloc(8192);
228
+ const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
229
+ const headStr = headBuf.toString('utf8', 0, headBytes);
230
+ for (const line of headStr.split('\n')) {
231
+ if (!line) continue;
232
+ try {
233
+ const d = JSON.parse(line);
234
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
235
+ const content = d.message.content;
236
+ let raw = '';
237
+ if (typeof content === 'string') raw = content;
238
+ else if (Array.isArray(content)) {
239
+ const txt = content.find(c => c.type === 'text');
240
+ if (txt) raw = txt.text;
241
+ }
242
+ raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
243
+ if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
185
244
  }
186
- raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
187
- if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
188
- }
189
- } catch { /* skip line */ }
245
+ } catch { /* skip line */ }
246
+ }
190
247
  }
191
- }
192
- if (!s.customTitle) {
248
+ // 从尾部读取:customTitle + lastUser(256KB 覆盖 tool-heavy session)
193
249
  const stat = fs.fstatSync(fd);
194
- const tailSize = Math.min(4096, stat.size);
250
+ const tailSize = Math.min(262144, stat.size);
195
251
  const tailBuf = Buffer.alloc(tailSize);
196
252
  fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
197
- const tailStr = tailBuf.toString('utf8');
198
- const tailLines = tailStr.split('\n').reverse();
199
- for (const line of tailLines) {
200
- if (!line) continue;
201
- try {
202
- const d = JSON.parse(line);
203
- if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
204
- } catch { /* skip */ }
253
+ const tailLines = tailBuf.toString('utf8').split('\n').reverse();
254
+ if (!s.customTitle) {
255
+ for (const line of tailLines) {
256
+ if (!line) continue;
257
+ try {
258
+ const d = JSON.parse(line);
259
+ if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
260
+ } catch { /* skip */ }
261
+ }
262
+ }
263
+ if (!s.lastUser) {
264
+ s.lastUser = extractLastUserFromLines(tailLines);
205
265
  }
266
+ } finally {
267
+ fs.closeSync(fd);
206
268
  }
207
- fs.closeSync(fd);
269
+ s._enriched = true; // [M1] 标记已完成富化,下次跳过
208
270
  } catch { /* non-fatal */ }
209
271
  }
210
272
 
@@ -265,20 +327,34 @@ function createSessionStore(deps) {
265
327
  .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
266
328
  .replace(/\s+/g, ' ')
267
329
  .trim();
330
+
331
+ // 优先级:name > summary > tags > firstPrompt > sessionId 前缀
268
332
  if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
333
+
334
+ if (s.summary) {
335
+ const t = sanitize(s.summary);
336
+ if (t.length > 2) return t.slice(0, maxLen);
337
+ }
338
+
269
339
  const tagEntry = sessionTags && sessionTags[s.sessionId];
270
340
  if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
341
+
271
342
  if (s.firstPrompt) {
272
343
  const clean = s.firstPrompt
273
344
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
274
- .replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
275
- .replace(/\[System hints[\s\S]*/i, '');
276
- const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
345
+ .replace(/\[System hints[\s\S]*/i, '')
346
+ .replace(/^[\s\S]*?<\/[^>]+>\s*/s, '') // 剥离 XML 头部标签
347
+ .trim();
348
+ // 取第一行非空、有实际内容(非纯符号/空格)的行
349
+ const firstLine = clean.split('\n')
350
+ .map(l => l.trim())
351
+ .find(l => l.length > 4 && /\p{L}/u.test(l)) || '';
277
352
  const sanitized = sanitize(firstLine);
278
353
  if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
279
354
  }
280
- if (s.summary) return sanitize(s.summary).slice(0, maxLen);
281
- return '';
355
+
356
+ // 最终兜底:显示 session ID 前缀而非空白
357
+ return s.sessionId ? s.sessionId.slice(0, 8) : '';
282
358
  }
283
359
 
284
360
  function sessionRichLabel(s, index, sessionTags) {
@@ -291,11 +367,17 @@ function createSessionStore(deps) {
291
367
  const shortId = s.sessionId.slice(0, 8);
292
368
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
293
369
 
294
- let line = `${index}. `;
295
- if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
296
- else line += '(unnamed)';
370
+ // [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
371
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
372
+ // fallback to firstPrompt when lastUser not found in tail
373
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
374
+ let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
297
375
  if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
298
376
  line += `\n 📁${proj} · ${ago}`;
377
+ if (snippetRaw && snippetRaw.length > 2) {
378
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
379
+ line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
380
+ }
299
381
  line += `\n /resume ${shortId}`;
300
382
  return line;
301
383
  }
@@ -312,8 +394,15 @@ function createSessionStore(deps) {
312
394
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
313
395
  const shortId = s.sessionId.slice(0, 6);
314
396
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
315
- let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
397
+ // [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
398
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
399
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
400
+ let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago}`;
316
401
  if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
402
+ if (snippetRaw && snippetRaw.length > 2) {
403
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
404
+ desc += `\n💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
405
+ }
317
406
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
318
407
  elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
319
408
  });
@@ -393,6 +482,49 @@ function createSessionStore(deps) {
393
482
  }
394
483
  }
395
484
 
485
+ /**
486
+ * 读取 session 最近一条用户消息 + 最近一条 AI 回复
487
+ * 用于 /resume 后帮助确认切换到正确 session
488
+ */
489
+ function getSessionRecentContext(sessionId) {
490
+ try {
491
+ const sessionFile = findSessionFile(sessionId);
492
+ if (!sessionFile) return null;
493
+ const stat = fs.statSync(sessionFile);
494
+ const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
495
+ const buf = Buffer.alloc(tailSize);
496
+ const fd = fs.openSync(sessionFile, 'r');
497
+ try {
498
+ fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
499
+ } finally {
500
+ fs.closeSync(fd);
501
+ }
502
+ const lines = buf.toString('utf8').split('\n').reverse();
503
+ // [M3] 复用共享函数,统一截取逻辑
504
+ const lastUser = extractLastUserFromLines(lines);
505
+ let lastAssistant = '';
506
+ for (const line of lines) {
507
+ if (!line.trim()) continue;
508
+ try {
509
+ const d = JSON.parse(line);
510
+ if (!lastAssistant && d.type === 'assistant' && d.message) {
511
+ const content = d.message.content;
512
+ if (Array.isArray(content)) {
513
+ for (const c of content) {
514
+ if (c.type === 'text' && c.text && c.text.trim().length > 2) {
515
+ lastAssistant = c.text.trim().slice(0, 80);
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ if (lastAssistant) break;
522
+ } catch { /* skip bad line */ }
523
+ }
524
+ return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
525
+ } catch { return null; }
526
+ }
527
+
396
528
  function markSessionStarted(chatId) {
397
529
  const state = loadState();
398
530
  if (state.sessions[chatId]) {
@@ -405,6 +537,8 @@ function createSessionStore(deps) {
405
537
  findSessionFile,
406
538
  clearSessionFileCache,
407
539
  truncateSessionToCheckpoint,
540
+ watchSessionFiles,
541
+ stopWatchingSessionFiles,
408
542
  listRecentSessions,
409
543
  loadSessionTags,
410
544
  getSessionFileMtime,
@@ -417,6 +551,7 @@ function createSessionStore(deps) {
417
551
  getSessionName,
418
552
  writeSessionName,
419
553
  markSessionStarted,
554
+ getSessionRecentContext,
420
555
  };
421
556
  }
422
557
 
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const { classifyTaskUsage } = require('./usage-classifier');
4
5
 
5
6
  const WEEKDAY_INDEX = Object.freeze({
@@ -229,25 +230,29 @@ function createTaskScheduler(deps) {
229
230
  if (!task || !task.memory_log) return;
230
231
  try {
231
232
  const memory = require('./memory');
232
- const nowIso = new Date().toISOString();
233
- const projectKey = (task._project && task._project.key) || 'heartbeat';
233
+ memory.acquire();
234
234
  const memoryId = `${task.name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
235
- const summaryText = String(output || '(no output)').trim() || '(no output)';
236
- const summary = [
237
- `[heartbeat task] ${task.name}`,
238
- sessionId ? `session: ${sessionId}` : '',
239
- summaryText,
240
- ].filter(Boolean).join('\n').slice(0, 8000);
241
- const keywords = [task.name, 'heartbeat', 'evolution', nowIso.slice(0, 10)].join(',');
242
- memory.saveSession({
243
- sessionId: memoryId,
244
- project: projectKey,
245
- summary,
246
- keywords,
247
- mood: '',
248
- tokenCost: Number(tokenCost) || 0,
249
- });
250
- memory.close();
235
+ try {
236
+ const nowIso = new Date().toISOString();
237
+ const projectKey = (task._project && task._project.key) || 'heartbeat';
238
+ const summaryText = String(output || '(no output)').trim() || '(no output)';
239
+ const summary = [
240
+ `[heartbeat task] ${task.name}`,
241
+ sessionId ? `session: ${sessionId}` : '',
242
+ summaryText,
243
+ ].filter(Boolean).join('\n').slice(0, 8000);
244
+ const keywords = [task.name, 'heartbeat', 'evolution', nowIso.slice(0, 10)].join(',');
245
+ memory.saveSession({
246
+ sessionId: memoryId,
247
+ project: projectKey,
248
+ summary,
249
+ keywords,
250
+ mood: '',
251
+ tokenCost: Number(tokenCost) || 0,
252
+ });
253
+ } finally {
254
+ memory.release();
255
+ }
251
256
  log('INFO', `Task ${task.name}: memory_log saved (${memoryId})`);
252
257
  } catch (e) {
253
258
  log('WARN', `Task ${task.name}: memory_log failed: ${e.message}`);
@@ -281,7 +286,7 @@ function createTaskScheduler(deps) {
281
286
 
282
287
  // Workflow tasks: multi-step skill chain via --resume session
283
288
  if (task.type === 'workflow') {
284
- return executeWorkflow(task, config);
289
+ return executeWorkflow(task, config, precheck);
285
290
  }
286
291
 
287
292
  // Script tasks: run a local script directly (e.g. distill.js), no claude -p
@@ -488,18 +493,13 @@ function createTaskScheduler(deps) {
488
493
 
489
494
  // parseInterval — imported from ./utils
490
495
 
491
- function executeWorkflow(task, config) {
496
+ function executeWorkflow(task, config, precheck) {
492
497
  const state = loadState();
493
498
  if (!checkBudget(config, state)) {
494
499
  log('WARN', `Budget exceeded, skipping workflow: ${task.name}`);
495
500
  return { success: false, error: 'budget_exceeded', output: '' };
496
501
  }
497
- const precheck = checkPrecondition(task);
498
- if (!precheck.pass) {
499
- state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'skipped', output_preview: 'Precondition not met' };
500
- saveState(state);
501
- return { success: true, output: '(skipped)', skipped: true };
502
- }
502
+ // precheck.pass is guaranteed true here — executeTask() already returns early when false
503
503
  const steps = task.steps || [];
504
504
  if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
505
505
 
@@ -518,6 +518,7 @@ function createTaskScheduler(deps) {
518
518
 
519
519
  log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
520
520
 
521
+ let loopState = loadState();
521
522
  for (let i = 0; i < steps.length; i++) {
522
523
  const step = steps[i];
523
524
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
@@ -546,19 +547,19 @@ function createTaskScheduler(deps) {
546
547
  totalTokens += tk;
547
548
  outputs.push({ step: i + 1, skill: step.skill || null, output: output.slice(0, 500), tokens: tk });
548
549
  log('INFO', `Workflow ${task.name} step ${i + 1} done (${tk} tokens)`);
549
- if (!checkBudget(config, loadState())) { log('WARN', 'Budget exceeded mid-workflow'); break; }
550
+ if (!checkBudget(config, loopState)) { log('WARN', 'Budget exceeded mid-workflow'); break; }
550
551
  } catch (e) {
551
552
  log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${e.message.slice(0, 200)}`);
552
553
  outputs.push({ step: i + 1, skill: step.skill || null, error: e.message.slice(0, 200) });
553
554
  if (!step.optional) {
554
- recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
555
+ recordTokens(loopState, totalTokens, { category: classifyTaskUsage(task) });
555
556
  state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Step ${i + 1} failed`, steps_completed: i, steps_total: steps.length };
556
557
  saveState(state);
557
558
  return { success: false, error: `Step ${i + 1} failed`, output: outputs.map(o => `Step ${o.step}: ${o.error ? 'FAILED' : 'OK'}`).join('\n'), tokens: totalTokens };
558
559
  }
559
560
  }
560
561
  }
561
- recordTokens(loadState(), totalTokens, { category: classifyTaskUsage(task) });
562
+ recordTokens(loopState, totalTokens, { category: classifyTaskUsage(task) });
562
563
  const lastOk = [...outputs].reverse().find(o => !o.error);
563
564
  state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'success', output_preview: (lastOk ? lastOk.output : '').slice(0, 200), steps_completed: outputs.filter(o => !o.error).length, steps_total: steps.length };
564
565
  saveState(state);