metame-cli 1.4.15 → 1.4.18

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/scripts/memory.js CHANGED
@@ -9,9 +9,9 @@
9
9
  * DB: ~/.metame/memory.db
10
10
  *
11
11
  * API:
12
- * saveSession({ sessionId, project, summary, keywords, mood })
13
- * searchSessions(query, { limit, project })
14
- * recentSessions({ limit, project })
12
+ * saveSession({ sessionId, project, scope, summary, keywords, mood })
13
+ * searchSessions(query, { limit, project, scope })
14
+ * recentSessions({ limit, project, scope })
15
15
  * getSession(sessionId)
16
16
  * stats()
17
17
  * close()
@@ -45,6 +45,7 @@ function getDb() {
45
45
  CREATE TABLE IF NOT EXISTS sessions (
46
46
  id TEXT PRIMARY KEY,
47
47
  project TEXT NOT NULL,
48
+ scope TEXT DEFAULT NULL,
48
49
  summary TEXT NOT NULL,
49
50
  keywords TEXT DEFAULT '',
50
51
  mood TEXT DEFAULT '',
@@ -88,6 +89,10 @@ function getDb() {
88
89
  try { _db.exec(t); } catch { /* trigger may already exist */ }
89
90
  }
90
91
 
92
+ // Backward-compatible migration for old DBs without `scope`
93
+ try { _db.exec('ALTER TABLE sessions ADD COLUMN scope TEXT DEFAULT NULL'); } catch {}
94
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_scope ON sessions(scope)'); } catch {}
95
+
91
96
 
92
97
  // ── Facts table: atomic knowledge triples ──
93
98
  _db.exec(`
@@ -100,6 +105,7 @@ function getDb() {
100
105
  source_type TEXT NOT NULL DEFAULT 'session',
101
106
  source_id TEXT,
102
107
  project TEXT NOT NULL DEFAULT '*',
108
+ scope TEXT DEFAULT NULL,
103
109
  tags TEXT DEFAULT '[]',
104
110
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
105
111
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -142,8 +148,13 @@ function getDb() {
142
148
 
143
149
  // Indexes
144
150
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch {}
151
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity_relation ON facts(entity, relation)'); } catch {}
145
152
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch {}
146
153
 
154
+ // Backward-compatible migration for old DBs without `scope`
155
+ try { _db.exec('ALTER TABLE facts ADD COLUMN scope TEXT DEFAULT NULL'); } catch {}
156
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(scope)'); } catch {}
157
+
147
158
  // Search frequency tracking: counts how many times a fact appeared in search results.
148
159
  // This is a RELEVANCE PROXY, not a usefulness score — "searched" ≠ "actually helpful".
149
160
  // Renamed from recall_count (was ambiguous). Migration copies existing data forward.
@@ -162,27 +173,42 @@ function getDb() {
162
173
  * @param {object} opts
163
174
  * @param {string} opts.sessionId - Claude session ID (unique key)
164
175
  * @param {string} opts.project - Project key (e.g. 'metame', 'desktop')
176
+ * @param {string|null} [opts.scope] - Stable workspace scope ID (e.g. proj_<hash>)
165
177
  * @param {string} opts.summary - Distilled summary text
166
178
  * @param {string} [opts.keywords] - Comma-separated keywords for search boost
167
179
  * @param {string} [opts.mood] - User mood/sentiment detected
168
180
  * @param {number} [opts.tokenCost] - Approximate token cost of the session
169
181
  * @returns {{ ok: boolean, id: string }}
170
182
  */
171
- function saveSession({ sessionId, project, summary, keywords = '', mood = '', tokenCost = 0 }) {
183
+ function saveSession({ sessionId, project, scope = null, summary, keywords = '', mood = '', tokenCost = 0 }) {
172
184
  if (!sessionId || !project || !summary) {
173
185
  throw new Error('saveSession requires sessionId, project, summary');
174
186
  }
187
+ const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
188
+ const normalizedScope = normalizedProject === '*'
189
+ ? '*'
190
+ : (scope && typeof scope === 'string' ? scope : null);
175
191
  const db = getDb();
176
192
  const stmt = db.prepare(`
177
- INSERT INTO sessions (id, project, summary, keywords, mood, token_cost)
178
- VALUES (?, ?, ?, ?, ?, ?)
193
+ INSERT INTO sessions (id, project, scope, summary, keywords, mood, token_cost)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?)
179
195
  ON CONFLICT(id) DO UPDATE SET
196
+ project = excluded.project,
197
+ scope = excluded.scope,
180
198
  summary = excluded.summary,
181
199
  keywords = excluded.keywords,
182
200
  mood = excluded.mood,
183
201
  token_cost = excluded.token_cost
184
202
  `);
185
- stmt.run(sessionId, project, summary.slice(0, 10000), keywords.slice(0, 1000), mood.slice(0, 100), tokenCost);
203
+ stmt.run(
204
+ sessionId,
205
+ normalizedProject,
206
+ normalizedScope,
207
+ summary.slice(0, 10000),
208
+ keywords.slice(0, 1000),
209
+ mood.slice(0, 100),
210
+ tokenCost
211
+ );
186
212
  return { ok: true, id: sessionId };
187
213
  }
188
214
 
@@ -196,32 +222,53 @@ const STATEFUL_RELATIONS = new Set(['user_pref', 'config_fact', 'config_change',
196
222
  * @param {string} sessionId - Source session ID
197
223
  * @param {string} project - Project key ('metame', 'desktop', '*' for global)
198
224
  * @param {Array} facts - Array of { entity, relation, value, confidence, tags }
225
+ * @param {object} [opts]
226
+ * @param {string|null} [opts.scope] - Stable workspace scope ID (e.g. proj_<hash>)
199
227
  * @returns {{ saved: number, skipped: number, superseded: number }}
200
228
  */
201
- function saveFacts(sessionId, project, facts) {
229
+ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
202
230
  if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0 };
203
231
  const db = getDb();
232
+ const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
233
+ const fallbackSessionScope = (() => {
234
+ const sid = String(sessionId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24);
235
+ return sid ? `sess_${sid}` : null;
236
+ })();
237
+ const normalizedScope = normalizedProject === '*'
238
+ ? '*'
239
+ : (scope && typeof scope === 'string' ? scope : (normalizedProject === 'unknown' ? fallbackSessionScope : null));
240
+
241
+ let dedupScopeSql = '';
242
+ let dedupScopeParams = [];
243
+ if (normalizedScope === '*') {
244
+ dedupScopeSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
245
+ } else if (normalizedScope) {
246
+ dedupScopeSql = `((scope = ?) OR (scope = '*') OR (scope IS NULL AND project IN (?, '*')))`;
247
+ dedupScopeParams = [normalizedScope, normalizedProject];
248
+ } else {
249
+ dedupScopeSql = `(project IN (?, '*'))`;
250
+ dedupScopeParams = [normalizedProject];
251
+ }
204
252
 
205
- // Load existing facts for dedup check
206
- const existing = db.prepare(
207
- "SELECT entity, relation, value FROM facts WHERE project IN (?, '*')"
208
- ).all(project);
253
+ const existsDup = db.prepare(`
254
+ SELECT 1 AS ok
255
+ FROM facts
256
+ WHERE entity = ? AND relation = ? AND substr(value, 1, 50) = ?
257
+ AND ${dedupScopeSql}
258
+ LIMIT 1
259
+ `);
209
260
 
210
261
  const insert = db.prepare(`
211
- INSERT INTO facts (id, entity, relation, value, confidence, source_type, source_id, project, tags, created_at, updated_at)
212
- VALUES (?, ?, ?, ?, ?, 'session', ?, ?, ?, datetime('now'), datetime('now'))
262
+ INSERT INTO facts (id, entity, relation, value, confidence, source_type, source_id, project, scope, tags, created_at, updated_at)
263
+ VALUES (?, ?, ?, ?, ?, 'session', ?, ?, ?, ?, datetime('now'), datetime('now'))
213
264
  ON CONFLICT(id) DO NOTHING
214
265
  `);
215
266
 
216
- const supersede = db.prepare(`
217
- UPDATE facts SET superseded_by = ?, updated_at = datetime('now')
218
- WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
219
- `);
220
-
221
267
  let saved = 0;
222
268
  let skipped = 0;
223
269
  let superseded = 0;
224
270
  const savedFacts = [];
271
+ const batchDedup = new Set();
225
272
 
226
273
  for (const f of facts) {
227
274
  // Basic validation
@@ -231,29 +278,49 @@ function saveFacts(sessionId, project, facts) {
231
278
  // Dedup: same entity+relation with similar value prefix
232
279
  const dupKey = `${f.entity}::${f.relation}`;
233
280
  const prefix = f.value.slice(0, 50);
234
- const isDup = existing.some(e =>
235
- `${e.entity}::${e.relation}` === dupKey && e.value.slice(0, 50) === prefix
236
- );
281
+ const dedupKey = `${dupKey}::${prefix}`;
282
+ const isBatchDup = batchDedup.has(dedupKey);
283
+ const dbDup = existsDup.get(f.entity, f.relation, prefix, ...dedupScopeParams);
284
+ const isDup = isBatchDup || !!(dbDup && dbDup.ok === 1);
237
285
  if (isDup) { skipped++; continue; }
238
286
 
239
287
  const id = `f-${sessionId.slice(0, 8)}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
240
288
  const tags = JSON.stringify(Array.isArray(f.tags) ? f.tags.slice(0, 3) : []);
241
289
  try {
242
290
  insert.run(id, f.entity, f.relation, f.value.slice(0, 300),
243
- f.confidence || 'medium', sessionId, project === '*' ? '*' : project, tags);
291
+ f.confidence || 'medium', sessionId, normalizedProject, normalizedScope, tags);
292
+ batchDedup.add(dedupKey);
244
293
  savedFacts.push({ id, entity: f.entity, relation: f.relation, value: f.value,
245
- project: project === '*' ? '*' : project, tags: f.tags || [], created_at: new Date().toISOString() });
294
+ project: normalizedProject, scope: normalizedScope, tags: f.tags || [], created_at: new Date().toISOString() });
246
295
  saved++;
247
296
 
248
297
  // For stateful relations, mark older active facts with same entity::relation as superseded
249
298
  if (STATEFUL_RELATIONS.has(f.relation)) {
299
+ let whereSql = '';
300
+ let filterParams = [];
301
+ if (normalizedScope === '*') {
302
+ whereSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
303
+ } else if (normalizedScope) {
304
+ whereSql = `((scope = ?) OR (scope IS NULL AND project = ?))`;
305
+ filterParams = [normalizedScope, normalizedProject];
306
+ } else {
307
+ whereSql = `(project IN (?, '*'))`;
308
+ filterParams = [normalizedProject];
309
+ }
310
+
250
311
  // Fetch the IDs being superseded before running the update (for audit log)
251
312
  const db2 = getDb();
252
313
  const toSupersede = db2.prepare(
253
- 'SELECT id, value FROM facts WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL'
254
- ).all(f.entity, f.relation, id);
255
-
256
- const result = supersede.run(id, f.entity, f.relation, id);
314
+ `SELECT id, value FROM facts
315
+ WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
316
+ AND ${whereSql}`
317
+ ).all(f.entity, f.relation, id, ...filterParams);
318
+
319
+ const result = db.prepare(
320
+ `UPDATE facts SET superseded_by = ?, updated_at = datetime('now')
321
+ WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
322
+ AND ${whereSql}`
323
+ ).run(id, f.entity, f.relation, id, ...filterParams);
257
324
  const changes = result.changes || 0;
258
325
  superseded += changes;
259
326
 
@@ -316,6 +383,26 @@ function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
316
383
  } catch { /* non-fatal */ }
317
384
  }
318
385
 
386
+ /**
387
+ * Scope filter semantics (new + legacy):
388
+ * - New rows: prefer `scope` exact match or global scope '*'
389
+ * - Legacy rows (scope NULL): fallback to project match or project='*'
390
+ */
391
+ function _matchesFactScope(row, project, scope) {
392
+ if (!row) return false;
393
+ const rowScope = row.scope === undefined ? null : row.scope;
394
+ if (scope) {
395
+ if (rowScope === scope || rowScope === '*') return true;
396
+ if (rowScope === null) {
397
+ if (!project) return false;
398
+ return row.project === project || row.project === '*';
399
+ }
400
+ return false;
401
+ }
402
+ if (project) return row.project === project || row.project === '*';
403
+ return true;
404
+ }
405
+
319
406
  /**
320
407
  * Search facts: QMD hybrid search (if available) → FTS5 → LIKE fallback.
321
408
  *
@@ -323,9 +410,10 @@ function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
323
410
  * @param {object} [opts]
324
411
  * @param {number} [opts.limit=5] - Max results
325
412
  * @param {string} [opts.project] - Filter by project (also always includes '*')
413
+ * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
326
414
  * @returns {Promise<Array>|Array} Fact objects
327
415
  */
328
- async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
416
+ async function searchFactsAsync(query, { limit = 5, project = null, scope = null } = {}) {
329
417
  // Try QMD hybrid search first
330
418
  let qmdClient = null;
331
419
  try { qmdClient = require('./qmd-client'); } catch { /* not available */ }
@@ -337,13 +425,13 @@ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
337
425
  const db = getDb();
338
426
  const placeholders = ids.map(() => '?').join(',');
339
427
  let rows = db.prepare(
340
- `SELECT id, entity, relation, value, confidence, project, tags, created_at
428
+ `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
341
429
  FROM facts WHERE id IN (${placeholders}) AND superseded_by IS NULL`
342
430
  ).all(...ids);
343
431
 
344
- // Apply project filter
345
- if (project) {
346
- rows = rows.filter(r => r.project === project || r.project === '*');
432
+ // Apply project/scope filter
433
+ if (project || scope) {
434
+ rows = rows.filter(r => _matchesFactScope(r, project, scope));
347
435
  }
348
436
 
349
437
  // Preserve QMD ranking order
@@ -358,7 +446,7 @@ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
358
446
  } catch { /* QMD failed, fall through to FTS5 */ }
359
447
  }
360
448
 
361
- return searchFacts(query, { limit, project });
449
+ return searchFacts(query, { limit, project, scope });
362
450
  }
363
451
 
364
452
  /**
@@ -368,9 +456,10 @@ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
368
456
  * @param {object} [opts]
369
457
  * @param {number} [opts.limit=5] - Max results
370
458
  * @param {string} [opts.project] - Filter by project (also always includes '*')
371
- * @returns {Array<{ id, entity, relation, value, confidence, project, tags, created_at }>}
459
+ * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
460
+ * @returns {Array<{ id, entity, relation, value, confidence, project, scope, tags, created_at }>}
372
461
  */
373
- function searchFacts(query, { limit = 5, project = null } = {}) {
462
+ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
374
463
  if (!query || !query.trim()) return [];
375
464
  const db = getDb();
376
465
 
@@ -380,9 +469,27 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
380
469
  // FTS5 path
381
470
  try {
382
471
  let sql, params;
383
- if (project) {
472
+ if (scope && project) {
473
+ sql = `
474
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
475
+ FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
476
+ WHERE facts_fts MATCH ?
477
+ AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))
478
+ AND f.superseded_by IS NULL
479
+ ORDER BY rank LIMIT ?
480
+ `;
481
+ params = [sanitized, scope, project, limit];
482
+ } else if (scope) {
483
+ sql = `
484
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
485
+ FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
486
+ WHERE facts_fts MATCH ? AND (f.scope = ? OR f.scope = '*') AND f.superseded_by IS NULL
487
+ ORDER BY rank LIMIT ?
488
+ `;
489
+ params = [sanitized, scope, limit];
490
+ } else if (project) {
384
491
  sql = `
385
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.tags, f.created_at, rank
492
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
386
493
  FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
387
494
  WHERE facts_fts MATCH ? AND (f.project = ? OR f.project = '*') AND f.superseded_by IS NULL
388
495
  ORDER BY rank LIMIT ?
@@ -390,7 +497,7 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
390
497
  params = [sanitized, project, limit];
391
498
  } else {
392
499
  sql = `
393
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.tags, f.created_at, rank
500
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
394
501
  FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
395
502
  WHERE facts_fts MATCH ? AND f.superseded_by IS NULL
396
503
  ORDER BY rank LIMIT ?
@@ -406,18 +513,33 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
406
513
 
407
514
  // LIKE fallback
408
515
  const like = '%' + query.trim() + '%';
409
- const likeSql = project
410
- ? `SELECT id, entity, relation, value, confidence, project, tags, created_at
516
+ const likeSql = scope && project
517
+ ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
518
+ FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
519
+ AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
520
+ AND superseded_by IS NULL
521
+ ORDER BY created_at DESC LIMIT ?`
522
+ : scope
523
+ ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
524
+ FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
525
+ AND (scope = ? OR scope = '*') AND superseded_by IS NULL
526
+ ORDER BY created_at DESC LIMIT ?`
527
+ : project
528
+ ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
411
529
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
412
530
  AND (project = ? OR project = '*') AND superseded_by IS NULL
413
531
  ORDER BY created_at DESC LIMIT ?`
414
- : `SELECT id, entity, relation, value, confidence, project, tags, created_at
532
+ : `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
415
533
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
416
534
  AND superseded_by IS NULL
417
535
  ORDER BY created_at DESC LIMIT ?`;
418
- const likeResults = project
419
- ? db.prepare(likeSql).all(like, like, like, project, limit)
420
- : db.prepare(likeSql).all(like, like, like, limit);
536
+ const likeResults = scope && project
537
+ ? db.prepare(likeSql).all(like, like, like, scope, project, limit)
538
+ : scope
539
+ ? db.prepare(likeSql).all(like, like, like, scope, limit)
540
+ : project
541
+ ? db.prepare(likeSql).all(like, like, like, project, limit)
542
+ : db.prepare(likeSql).all(like, like, like, limit);
421
543
  if (likeResults.length > 0) _trackSearch(likeResults.map(r => r.id));
422
544
  return likeResults;
423
545
  }
@@ -429,9 +551,10 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
429
551
  * @param {object} [opts]
430
552
  * @param {number} [opts.limit=5] - Max results
431
553
  * @param {string} [opts.project] - Filter by project
432
- * @returns {Array<{ id, project, summary, keywords, mood, created_at, rank }>}
554
+ * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
555
+ * @returns {Array<{ id, project, scope, summary, keywords, mood, created_at, rank }>}
433
556
  */
434
- function searchSessions(query, { limit = 5, project = null } = {}) {
557
+ function searchSessions(query, { limit = 5, project = null, scope = null } = {}) {
435
558
  if (!query || !query.trim()) return [];
436
559
  const db = getDb();
437
560
 
@@ -439,9 +562,26 @@ function searchSessions(query, { limit = 5, project = null } = {}) {
439
562
  const sanitized = query.trim().split(/\s+/).map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
440
563
 
441
564
  let sql, params;
442
- if (project) {
565
+ if (scope && project) {
443
566
  sql = `
444
- SELECT s.id, s.project, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
567
+ SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
568
+ FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
569
+ WHERE sessions_fts MATCH ?
570
+ AND ((s.scope = ? OR s.scope = '*') OR (s.scope IS NULL AND (s.project = ? OR s.project = '*')))
571
+ ORDER BY rank LIMIT ?
572
+ `;
573
+ params = [sanitized, scope, project, limit];
574
+ } else if (scope) {
575
+ sql = `
576
+ SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
577
+ FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
578
+ WHERE sessions_fts MATCH ? AND (s.scope = ? OR s.scope = '*')
579
+ ORDER BY rank LIMIT ?
580
+ `;
581
+ params = [sanitized, scope, limit];
582
+ } else if (project) {
583
+ sql = `
584
+ SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
445
585
  FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
446
586
  WHERE sessions_fts MATCH ? AND s.project = ?
447
587
  ORDER BY rank LIMIT ?
@@ -449,7 +589,7 @@ function searchSessions(query, { limit = 5, project = null } = {}) {
449
589
  params = [sanitized, project, limit];
450
590
  } else {
451
591
  sql = `
452
- SELECT s.id, s.project, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
592
+ SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
453
593
  FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
454
594
  WHERE sessions_fts MATCH ?
455
595
  ORDER BY rank LIMIT ?
@@ -464,12 +604,34 @@ function searchSessions(query, { limit = 5, project = null } = {}) {
464
604
 
465
605
  // LIKE fallback (handles short CJK terms like "飞书" that trigram can't match)
466
606
  const likeParam = '%' + query.trim() + '%';
467
- const likeSql = project
468
- ? 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE (summary LIKE ? OR keywords LIKE ?) AND project = ? ORDER BY created_at DESC LIMIT ?'
469
- : 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE (summary LIKE ? OR keywords LIKE ?) ORDER BY created_at DESC LIMIT ?';
470
- return project
471
- ? db.prepare(likeSql).all(likeParam, likeParam, project, limit)
472
- : db.prepare(likeSql).all(likeParam, likeParam, limit);
607
+ const likeSql = scope && project
608
+ ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
609
+ FROM sessions
610
+ WHERE (summary LIKE ? OR keywords LIKE ?)
611
+ AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
612
+ ORDER BY created_at DESC LIMIT ?`
613
+ : scope
614
+ ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
615
+ FROM sessions
616
+ WHERE (summary LIKE ? OR keywords LIKE ?)
617
+ AND (scope = ? OR scope = '*')
618
+ ORDER BY created_at DESC LIMIT ?`
619
+ : project
620
+ ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
621
+ FROM sessions
622
+ WHERE (summary LIKE ? OR keywords LIKE ?) AND project = ?
623
+ ORDER BY created_at DESC LIMIT ?`
624
+ : `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
625
+ FROM sessions
626
+ WHERE (summary LIKE ? OR keywords LIKE ?)
627
+ ORDER BY created_at DESC LIMIT ?`;
628
+ return scope && project
629
+ ? db.prepare(likeSql).all(likeParam, likeParam, scope, project, limit)
630
+ : scope
631
+ ? db.prepare(likeSql).all(likeParam, likeParam, scope, limit)
632
+ : project
633
+ ? db.prepare(likeSql).all(likeParam, likeParam, project, limit)
634
+ : db.prepare(likeSql).all(likeParam, likeParam, limit);
473
635
  }
474
636
 
475
637
  /**
@@ -478,17 +640,34 @@ function searchSessions(query, { limit = 5, project = null } = {}) {
478
640
  * @param {object} [opts]
479
641
  * @param {number} [opts.limit=3] - Max results
480
642
  * @param {string} [opts.project] - Filter by project
481
- * @returns {Array<{ id, project, summary, keywords, mood, created_at }>}
643
+ * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
644
+ * @returns {Array<{ id, project, scope, summary, keywords, mood, created_at }>}
482
645
  */
483
- function recentSessions({ limit = 3, project = null } = {}) {
646
+ function recentSessions({ limit = 3, project = null, scope = null } = {}) {
484
647
  const db = getDb();
648
+ if (scope && project) {
649
+ return db.prepare(
650
+ `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
651
+ FROM sessions
652
+ WHERE ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
653
+ ORDER BY created_at DESC LIMIT ?`
654
+ ).all(scope, project, limit);
655
+ }
656
+ if (scope) {
657
+ return db.prepare(
658
+ `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
659
+ FROM sessions
660
+ WHERE (scope = ? OR scope = '*')
661
+ ORDER BY created_at DESC LIMIT ?`
662
+ ).all(scope, limit);
663
+ }
485
664
  if (project) {
486
665
  return db.prepare(
487
- 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE project = ? ORDER BY created_at DESC LIMIT ?'
666
+ 'SELECT id, project, scope, summary, keywords, mood, created_at, token_cost FROM sessions WHERE project = ? ORDER BY created_at DESC LIMIT ?'
488
667
  ).all(project, limit);
489
668
  }
490
669
  return db.prepare(
491
- 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions ORDER BY created_at DESC LIMIT ?'
670
+ 'SELECT id, project, scope, summary, keywords, mood, created_at, token_cost FROM sessions ORDER BY created_at DESC LIMIT ?'
492
671
  ).all(limit);
493
672
  }
494
673
 
@@ -217,7 +217,7 @@ function listFormatted() {
217
217
  */
218
218
  function callHaiku(input, extraEnv, timeout) {
219
219
  const { execFile } = require('child_process');
220
- const env = { ...process.env, ...extraEnv };
220
+ const env = { ...process.env, ...extraEnv, METAME_INTERNAL_PROMPT: '1' };
221
221
  delete env.CLAUDECODE;
222
222
  return new Promise((resolve, reject) => {
223
223
  const proc = execFile(