metame-cli 1.5.26 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.5.26",
3
+ "version": "1.6.0",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -285,6 +285,41 @@ function buildMemorySnapshotContent(sessions = [], facts = []) {
285
285
  return lines.join('\n');
286
286
  }
287
287
 
288
+ function selectSnapshotContext(memoryApi, {
289
+ projectHints = [],
290
+ sessionLimit = 5,
291
+ factLimit = 10,
292
+ } = {}) {
293
+ if (!memoryApi || typeof memoryApi.recentSessions !== 'function') {
294
+ return { sessions: [], facts: [], matchedProject: null, usedFallback: false };
295
+ }
296
+
297
+ const fetchFacts = typeof memoryApi.recentFacts === 'function'
298
+ ? (project) => memoryApi.recentFacts({ limit: factLimit, project: project || null })
299
+ : () => [];
300
+
301
+ const candidates = Array.from(new Set(
302
+ (Array.isArray(projectHints) ? projectHints : [projectHints])
303
+ .map(v => String(v || '').trim())
304
+ .filter(Boolean)
305
+ ));
306
+
307
+ for (const candidate of candidates) {
308
+ const sessions = memoryApi.recentSessions({ limit: sessionLimit, project: candidate });
309
+ const facts = fetchFacts(candidate);
310
+ if (sessions.length > 0 || facts.length > 0) {
311
+ return { sessions, facts, matchedProject: candidate, usedFallback: false };
312
+ }
313
+ }
314
+
315
+ return {
316
+ sessions: memoryApi.recentSessions({ limit: sessionLimit }),
317
+ facts: fetchFacts(null),
318
+ matchedProject: null,
319
+ usedFallback: true,
320
+ };
321
+ }
322
+
288
323
  /**
289
324
  * Overwrite memory-snapshot.md for the given agent.
290
325
  * Returns true on success, false if the agent directory doesn't exist yet.
@@ -318,5 +353,6 @@ module.exports = {
318
353
  buildAgentContextForEngine,
319
354
  buildAgentContextForProject,
320
355
  buildMemorySnapshotContent,
356
+ selectSnapshotContext,
321
357
  refreshMemorySnapshot,
322
358
  };
@@ -0,0 +1,404 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/wiki-db.js — Wiki DB read/write layer
5
+ *
6
+ * All functions accept a DatabaseSync instance as first arg.
7
+ * No DB lifecycle management here — caller provides db.
8
+ *
9
+ * Exports:
10
+ * // wiki_pages CRUD
11
+ * getWikiPageBySlug(db, slug) → row | null
12
+ * listWikiPages(db, { limit=20, orderBy='updated_at' }) → row[]
13
+ * getStalePages(db, threshold=0.4) → row[]
14
+ * upsertWikiPage(db, { slug, primary_topic, title, content, raw_source_ids,
15
+ * capsule_refs, raw_source_count, topic_tags, word_count }) → void
16
+ * resetPageStaleness(db, slug, rawSourceCount) → void
17
+ *
18
+ * // wiki_topics CRUD
19
+ * upsertWikiTopic(db, tag, { label, pinned=0, force=false }) → { slug, isNew }
20
+ * checkTopicThreshold(db, tag) → boolean
21
+ * listWikiTopics(db) → row[]
22
+ *
23
+ * // search
24
+ * searchWikiAndFacts(db, query, { trackSearch=true }) → { wikiPages, facts }
25
+ * listRecentSessionSummaries(db, { limit=200 }) → row[]
26
+ *
27
+ * // staleness
28
+ * updateStalenessForTags(db, dirtyTagCounts: Map<string, number>) → void
29
+ */
30
+
31
+ const { toSlug, sanitizeFts5 } = require('./wiki-slug');
32
+ const { calcStaleness } = require('./wiki-staleness');
33
+
34
+ // ── wiki_pages CRUD ────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * @param {object} db
38
+ * @param {string} slug
39
+ * @returns {object|null}
40
+ */
41
+ function getWikiPageBySlug(db, slug) {
42
+ return db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug) ?? null;
43
+ }
44
+
45
+ /**
46
+ * @param {object} db
47
+ * @param {{ limit?: number, orderBy?: string }} opts
48
+ * @returns {object[]}
49
+ */
50
+ function listWikiPages(db, { limit = 20, orderBy = 'updated_at' } = {}) {
51
+ // Whitelist orderBy to prevent SQL injection
52
+ const allowed = ['updated_at', 'created_at', 'staleness', 'title', 'last_built_at', 'word_count'];
53
+ const col = allowed.includes(orderBy) ? orderBy : 'updated_at';
54
+ return db.prepare(`SELECT * FROM wiki_pages ORDER BY ${col} DESC LIMIT ?`).all(limit);
55
+ }
56
+
57
+ /**
58
+ * @param {object} db
59
+ * @param {number} threshold
60
+ * @returns {object[]}
61
+ */
62
+ function getStalePages(db, threshold = 0.4) {
63
+ return db.prepare('SELECT * FROM wiki_pages WHERE staleness >= ? ORDER BY staleness DESC').all(threshold);
64
+ }
65
+
66
+ /**
67
+ * Upsert a wiki page (INSERT OR REPLACE).
68
+ * On insert: id = wp_<timestamp>_<random>, created_at = now.
69
+ * On update: preserves existing id/created_at, updates all provided fields.
70
+ *
71
+ * @param {object} db
72
+ * @param {{ slug: string, primary_topic: string, title: string, content: string,
73
+ * raw_source_ids?: any, capsule_refs?: any, raw_source_count?: number,
74
+ * topic_tags?: any, word_count?: number }} opts
75
+ */
76
+ function upsertWikiPage(db, {
77
+ slug,
78
+ primary_topic,
79
+ title,
80
+ content,
81
+ raw_source_ids = '[]',
82
+ capsule_refs = '[]',
83
+ raw_source_count = 0,
84
+ topic_tags = '[]',
85
+ word_count = 0,
86
+ }) {
87
+ const rawSourceIdsStr = typeof raw_source_ids === 'string'
88
+ ? raw_source_ids : JSON.stringify(raw_source_ids);
89
+ const capsuleRefsStr = typeof capsule_refs === 'string'
90
+ ? capsule_refs : JSON.stringify(capsule_refs);
91
+ const topicTagsStr = typeof topic_tags === 'string'
92
+ ? topic_tags : JSON.stringify(topic_tags);
93
+
94
+ // Check if page exists
95
+ const existing = db.prepare('SELECT id, created_at FROM wiki_pages WHERE slug = ?').get(slug);
96
+
97
+ if (existing) {
98
+ db.prepare(`
99
+ UPDATE wiki_pages
100
+ SET primary_topic = ?,
101
+ title = ?,
102
+ content = ?,
103
+ raw_source_ids = ?,
104
+ capsule_refs = ?,
105
+ raw_source_count = ?,
106
+ topic_tags = ?,
107
+ word_count = ?,
108
+ updated_at = datetime('now')
109
+ WHERE slug = ?
110
+ `).run(primary_topic, title, content, rawSourceIdsStr, capsuleRefsStr, raw_source_count, topicTagsStr, word_count, slug);
111
+ } else {
112
+ const id = `wp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
113
+ db.prepare(`
114
+ INSERT INTO wiki_pages
115
+ (id, slug, primary_topic, title, content, raw_source_ids, capsule_refs,
116
+ raw_source_count, topic_tags, word_count, staleness, new_facts_since_build,
117
+ created_at, updated_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0.0, 0, datetime('now'), datetime('now'))
119
+ `).run(id, slug, primary_topic, title, content, rawSourceIdsStr, capsuleRefsStr, raw_source_count, topicTagsStr, word_count);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Reset staleness after wiki-reflect rebuilds a page.
125
+ * @param {object} db
126
+ * @param {string} slug
127
+ * @param {number} rawSourceCount
128
+ */
129
+ function resetPageStaleness(db, slug, rawSourceCount) {
130
+ db.prepare(`
131
+ UPDATE wiki_pages
132
+ SET staleness = 0.0,
133
+ new_facts_since_build = 0,
134
+ raw_source_count = ?,
135
+ last_built_at = datetime('now'),
136
+ updated_at = datetime('now')
137
+ WHERE slug = ?
138
+ `).run(rawSourceCount, slug);
139
+ }
140
+
141
+ // ── wiki_topics CRUD ──────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Upsert a wiki topic.
145
+ * Handles slug collision by appending -2 ... -10.
146
+ * force=true skips checkTopicThreshold.
147
+ *
148
+ * @param {object} db
149
+ * @param {string} tag
150
+ * @param {{ label?: string, pinned?: number, force?: boolean }} opts
151
+ * @returns {{ slug: string, isNew: boolean }}
152
+ */
153
+ function upsertWikiTopic(db, tag, { label, pinned = 0, force = false } = {}) {
154
+ if (typeof tag !== 'string' || !tag.trim()) {
155
+ throw new Error('upsertWikiTopic: tag must be a non-empty string');
156
+ }
157
+
158
+ // Check if this exact tag already exists → idempotent update
159
+ const existing = db.prepare('SELECT slug FROM wiki_topics WHERE tag = ?').get(tag);
160
+ if (existing) {
161
+ // Update label/pinned if provided
162
+ db.prepare(`
163
+ UPDATE wiki_topics
164
+ SET label = COALESCE(?, label),
165
+ pinned = MAX(pinned, ?)
166
+ WHERE tag = ?
167
+ `).run(label ?? null, pinned, tag);
168
+ return { slug: existing.slug, isNew: false };
169
+ }
170
+
171
+ // New tag — check threshold unless force
172
+ if (!force) {
173
+ const passes = checkTopicThreshold(db, tag);
174
+ if (!passes) {
175
+ throw new Error(`upsertWikiTopic: tag "${tag}" does not meet threshold (need ≥5 active raw facts AND ≥1 in last 30 days)`);
176
+ }
177
+ }
178
+
179
+ // Generate slug with collision handling
180
+ let baseSlug;
181
+ try {
182
+ baseSlug = toSlug(tag);
183
+ } catch (err) {
184
+ throw new Error(`upsertWikiTopic: ${err.message}`);
185
+ }
186
+
187
+ let finalSlug = baseSlug;
188
+ // Check collision: same slug, different tag
189
+ const collision = db.prepare('SELECT tag FROM wiki_topics WHERE slug = ? AND tag != ?').get(finalSlug, tag);
190
+ if (collision) {
191
+ let found = false;
192
+ for (let i = 2; i <= 10; i++) {
193
+ const candidate = `${baseSlug}-${i}`;
194
+ const exists = db.prepare('SELECT tag FROM wiki_topics WHERE slug = ?').get(candidate);
195
+ if (!exists) {
196
+ finalSlug = candidate;
197
+ found = true;
198
+ break;
199
+ }
200
+ }
201
+ if (!found) {
202
+ throw new Error(`upsertWikiTopic: slug collision for tag "${tag}" — exhausted -2 to -10 suffixes`);
203
+ }
204
+ }
205
+
206
+ const effectiveLabel = label ?? tag;
207
+ db.prepare(`
208
+ INSERT INTO wiki_topics (tag, slug, label, pinned)
209
+ VALUES (?, ?, ?, ?)
210
+ `).run(tag, finalSlug, effectiveLabel, pinned);
211
+
212
+ return { slug: finalSlug, isNew: true };
213
+ }
214
+
215
+ /**
216
+ * Check whether a tag meets the threshold for wiki topic registration.
217
+ * Condition 1: active raw facts COUNT >= 5 (lifetime)
218
+ * Condition 2: active raw facts COUNT >= 1 WHERE created_at >= datetime('now', '-30 days') (UTC)
219
+ * Both must be true.
220
+ *
221
+ * "raw facts" = state IN ('active', 'candidate') AND (relation NOT IN (...) OR relation IS NULL)
222
+ * Counts both states so that topic promotion can fire during saveFacts (facts enter
223
+ * as 'candidate' and are promoted to 'active' by nightly-reflect).
224
+ *
225
+ * @param {object} db
226
+ * @param {string} tag
227
+ * @returns {boolean}
228
+ */
229
+ function checkTopicThreshold(db, tag) {
230
+ const DERIVED = ['synthesized_insight', 'knowledge_capsule'];
231
+ const placeholders = DERIVED.map(() => '?').join(', ');
232
+
233
+ // Condition 1: lifetime count >= 5 (active or candidate)
234
+ const row1 = db.prepare(`
235
+ SELECT COUNT(*) as cnt
236
+ FROM memory_items mi
237
+ JOIN json_each(mi.tags) jt ON lower(trim(jt.value)) = lower(trim(?))
238
+ WHERE mi.state IN ('active', 'candidate')
239
+ AND (mi.relation NOT IN (${placeholders}) OR mi.relation IS NULL)
240
+ `).get(tag, ...DERIVED);
241
+
242
+ if (!row1 || row1.cnt < 5) return false;
243
+
244
+ // Condition 2: at least 1 in last 30 days (active or candidate)
245
+ const row2 = db.prepare(`
246
+ SELECT COUNT(*) as cnt
247
+ FROM memory_items mi
248
+ JOIN json_each(mi.tags) jt ON lower(trim(jt.value)) = lower(trim(?))
249
+ WHERE mi.state IN ('active', 'candidate')
250
+ AND (mi.relation NOT IN (${placeholders}) OR mi.relation IS NULL)
251
+ AND mi.created_at >= datetime('now', '-30 days')
252
+ `).get(tag, ...DERIVED);
253
+
254
+ if (!row2 || row2.cnt < 1) return false;
255
+
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * @param {object} db
261
+ * @returns {object[]}
262
+ */
263
+ function listWikiTopics(db) {
264
+ return db.prepare('SELECT * FROM wiki_topics ORDER BY created_at DESC').all();
265
+ }
266
+
267
+ function listRecentSessionSummaries(db, { limit = 200 } = {}) {
268
+ return db.prepare(`
269
+ SELECT
270
+ id,
271
+ session_id,
272
+ project,
273
+ scope,
274
+ title,
275
+ content,
276
+ tags,
277
+ created_at,
278
+ updated_at
279
+ FROM memory_items
280
+ WHERE kind = 'episode'
281
+ AND state = 'active'
282
+ ORDER BY created_at DESC
283
+ LIMIT ?
284
+ `).all(limit);
285
+ }
286
+
287
+ // ── Search ────────────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Search wiki pages and memory facts via FTS5.
291
+ * trackSearch=true → UPDATE search_count on matched facts.
292
+ *
293
+ * @param {object} db
294
+ * @param {string} query
295
+ * @param {{ trackSearch?: boolean }} opts
296
+ * @returns {{ wikiPages: object[], facts: object[] }}
297
+ */
298
+ function searchWikiAndFacts(db, query, { trackSearch = true } = {}) {
299
+ const safeQuery = sanitizeFts5(query);
300
+ if (!safeQuery) return { wikiPages: [], facts: [] };
301
+
302
+ // 1. FTS5 search wiki_pages_fts (weight 1.5x)
303
+ const wikiPages = db.prepare(`
304
+ SELECT wp.slug, wp.title, wp.staleness, wp.last_built_at,
305
+ snippet(wiki_pages_fts, 2, '<b>', '</b>', '...', 20) as excerpt,
306
+ rank * 1.5 as score
307
+ FROM wiki_pages_fts
308
+ JOIN wiki_pages wp ON wiki_pages_fts.rowid = wp.rowid
309
+ WHERE wiki_pages_fts MATCH ?
310
+ ORDER BY rank
311
+ LIMIT 5
312
+ `).all(safeQuery);
313
+
314
+ // 2. FTS5 search memory_items_fts — graceful fallback if table doesn't exist
315
+ let facts = [];
316
+ try {
317
+ facts = db.prepare(`
318
+ SELECT mi.id, mi.title, mi.content, mi.kind, mi.confidence,
319
+ snippet(memory_items_fts, 1, '<b>', '</b>', '...', 20) as excerpt,
320
+ rank as score
321
+ FROM memory_items_fts
322
+ JOIN memory_items mi ON memory_items_fts.rowid = mi.rowid
323
+ WHERE memory_items_fts MATCH ?
324
+ AND mi.state = 'active'
325
+ ORDER BY rank
326
+ LIMIT 10
327
+ `).all(safeQuery);
328
+ } catch {
329
+ facts = [];
330
+ }
331
+
332
+ // 3. trackSearch: update search_count on matched facts
333
+ if (trackSearch && facts.length > 0) {
334
+ _trackSearch(db, facts.map(r => r.id));
335
+ }
336
+
337
+ return { wikiPages, facts };
338
+ }
339
+
340
+ /**
341
+ * Increment search_count and update last_searched_at for given memory item IDs.
342
+ * @param {object} db
343
+ * @param {string[]} ids
344
+ */
345
+ function _trackSearch(db, ids) {
346
+ if (!ids || ids.length === 0) return;
347
+ const placeholders = ids.map(() => '?').join(', ');
348
+ db.prepare(`
349
+ UPDATE memory_items
350
+ SET search_count = search_count + 1,
351
+ last_searched_at = datetime('now')
352
+ WHERE id IN (${placeholders})
353
+ `).run(...ids);
354
+ }
355
+
356
+ // ── Staleness ─────────────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Update staleness for wiki pages matching dirty tags.
360
+ * Routes through wiki_topics (the canonical tag registry) so that casing
361
+ * differences between fact tags and wiki_pages.primary_topic are bridged.
362
+ * RHS expressions in a single UPDATE see pre-update column values (SQLite semantics),
363
+ * so new_facts_since_build in the staleness formula is the original value.
364
+ *
365
+ * @param {object} db
366
+ * @param {Map<string, number>} dirtyTagCounts
367
+ */
368
+ function updateStalenessForTags(db, dirtyTagCounts) {
369
+ for (const [tag, newCount] of dirtyTagCounts) {
370
+ if (newCount <= 0) continue;
371
+
372
+ // Match via wiki_topics.tag (canonical registry) → wiki_pages.slug
373
+ db.prepare(`
374
+ UPDATE wiki_pages
375
+ SET new_facts_since_build = new_facts_since_build + ?,
376
+ staleness = MIN(1.0,
377
+ CAST(new_facts_since_build + ? AS REAL)
378
+ / NULLIF(raw_source_count + new_facts_since_build + ?, 0)
379
+ ),
380
+ updated_at = datetime('now')
381
+ WHERE slug IN (
382
+ SELECT slug FROM wiki_topics WHERE lower(trim(tag)) = lower(trim(?))
383
+ )
384
+ `).run(newCount, newCount, newCount, tag);
385
+ }
386
+ }
387
+
388
+ module.exports = {
389
+ // wiki_pages
390
+ getWikiPageBySlug,
391
+ listWikiPages,
392
+ getStalePages,
393
+ upsertWikiPage,
394
+ resetPageStaleness,
395
+ // wiki_topics
396
+ upsertWikiTopic,
397
+ checkTopicThreshold,
398
+ listWikiTopics,
399
+ listRecentSessionSummaries,
400
+ // search
401
+ searchWikiAndFacts,
402
+ // staleness
403
+ updateStalenessForTags,
404
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/wiki-prompt.js — Pure functions for wiki article generation prompts
5
+ *
6
+ * Builds prompts for callHaiku and validates [[wikilinks]] against a whitelist.
7
+ * Zero I/O, zero DB, zero side effects.
8
+ */
9
+
10
+ /**
11
+ * Build a prompt string for generating a wiki article section.
12
+ *
13
+ * @param {{ tag: string, slug: string, label: string }} topic
14
+ * @param {Array<{ title: string, content: string, confidence: number, search_count: number }>} facts
15
+ * @param {string} capsuleExcerpts - Optional background context (may be empty)
16
+ * @param {string[]} allowedSlugs - Whitelist of [[wikilink]] slugs
17
+ * @returns {string} Prompt string ready to pass to callHaiku
18
+ */
19
+ function buildWikiPrompt(topic, facts, capsuleExcerpts, allowedSlugs) {
20
+ const parts = [];
21
+
22
+ parts.push(`你是一个中文知识百科写作助手。请为以下主题撰写一段简洁、准确的百科条目正文。`);
23
+ parts.push('');
24
+ parts.push(`## 主题`);
25
+ parts.push(`标签:${topic.tag}`);
26
+ parts.push(`Slug:${topic.slug}`);
27
+ parts.push(`名称:${topic.label || topic.tag}`);
28
+ parts.push('');
29
+
30
+ if (facts && facts.length > 0) {
31
+ parts.push('## 参考事实');
32
+ facts.forEach((fact, i) => {
33
+ parts.push(`${i + 1}. **${fact.title}**(可信度:${fact.confidence},搜索次数:${fact.search_count})`);
34
+ parts.push(` ${fact.content}`);
35
+ });
36
+ parts.push('');
37
+ }
38
+
39
+ if (capsuleExcerpts && capsuleExcerpts.trim()) {
40
+ parts.push('## 背景补充');
41
+ parts.push(capsuleExcerpts.trim());
42
+ parts.push('');
43
+ }
44
+
45
+ if (allowedSlugs && allowedSlugs.length > 0) {
46
+ parts.push('## 允许的内链([[wikilinks]])');
47
+ parts.push('正文中如需引用以下条目,请使用 [[slug]] 格式;其余 slug 请勿使用 [[]] 包裹:');
48
+ parts.push(allowedSlugs.map(s => `- [[${s}]]`).join('\n'));
49
+ parts.push('');
50
+ } else {
51
+ parts.push('## 内链说明');
52
+ parts.push('正文中不得使用任何 [[wikilinks]] 格式。');
53
+ parts.push('');
54
+ }
55
+
56
+ parts.push('## 要求');
57
+ parts.push('- 用中文撰写');
58
+ parts.push('- 语言简洁准确,适合百科风格');
59
+ parts.push('- 只使用上方允许列表中的 [[slug]] 内链,其他 slug 直接用纯文本');
60
+ parts.push('- 不要重复本条目自身的 slug 作为内链');
61
+ parts.push('- 直接输出正文,不需要标题');
62
+
63
+ return parts.join('\n');
64
+ }
65
+
66
+ /**
67
+ * Validate and strip [[wikilinks]] not in the allowedSlugs whitelist.
68
+ *
69
+ * @param {string} content - Article body text possibly containing [[slug]] links
70
+ * @param {string[]} allowedSlugs - Whitelist of permitted slugs
71
+ * @returns {{ content: string, stripped: string[] }}
72
+ */
73
+ function validateWikilinks(content, allowedSlugs) {
74
+ const allowed = new Set(allowedSlugs || []);
75
+ const stripped = [];
76
+
77
+ const cleaned = content.replace(/\[\[([^\]]+)\]\]/g, (match, slug) => {
78
+ if (allowed.has(slug)) {
79
+ return match; // keep [[slug]]
80
+ }
81
+ stripped.push(slug);
82
+ return slug; // strip [[ ]]
83
+ });
84
+
85
+ return { content: cleaned, stripped };
86
+ }
87
+
88
+ module.exports = { buildWikiPrompt, validateWikilinks };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * toSlug(tag) → string
5
+ * Rules:
6
+ * - lowercase
7
+ * - keep only \w chars, Chinese [\u4e00-\u9fa5], spaces, hyphens
8
+ * - spaces → hyphens
9
+ * - collapse multiple hyphens → single hyphen
10
+ * - trim leading/trailing hyphens
11
+ * - truncate to 80 chars
12
+ * - if result is empty → throw Error
13
+ */
14
+ function toSlug(tag) {
15
+ if (typeof tag !== 'string') {
16
+ throw new Error('toSlug: input must be a string');
17
+ }
18
+
19
+ // lowercase
20
+ let s = tag.toLowerCase();
21
+
22
+ // keep only: word chars (\w = [a-z0-9_]), Chinese, spaces, hyphens
23
+ s = s.replace(/[^\w\u4e00-\u9fa5 -]/g, '');
24
+
25
+ // spaces → hyphens
26
+ s = s.replace(/ /g, '-');
27
+
28
+ // collapse multiple hyphens
29
+ s = s.replace(/-{2,}/g, '-');
30
+
31
+ // trim leading/trailing hyphens
32
+ s = s.replace(/^-+|-+$/g, '');
33
+
34
+ // truncate to 80 chars
35
+ s = s.slice(0, 80);
36
+
37
+ if (s.length === 0) {
38
+ throw new Error('toSlug: result is empty after normalization');
39
+ }
40
+
41
+ return s;
42
+ }
43
+
44
+ /**
45
+ * sanitizeFts5(input) → string | null
46
+ * Strips FTS5 special characters: " * ^ ( ) { } :
47
+ * Returns null if result is empty after trim.
48
+ */
49
+ function sanitizeFts5(input) {
50
+ if (typeof input !== 'string') {
51
+ return null;
52
+ }
53
+
54
+ // Remove FTS5 special chars: " * ^ ( ) { } :
55
+ let s = input.replace(/["*^(){}:]/g, '');
56
+
57
+ s = s.trim();
58
+
59
+ if (s.length === 0) {
60
+ return null;
61
+ }
62
+
63
+ return s;
64
+ }
65
+
66
+ module.exports = { toSlug, sanitizeFts5 };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * calcStaleness — pure function, no I/O, no DB.
5
+ *
6
+ * @param {number} newFacts number of new facts discovered
7
+ * @param {number} rawSourceCount number of already-indexed raw sources
8
+ * @returns {number} staleness in [0, 1]
9
+ * formula: newFacts / (rawSourceCount + newFacts)
10
+ * special: both zero → 0 (avoids division by zero)
11
+ */
12
+ function calcStaleness(newFacts, rawSourceCount) {
13
+ const denominator = rawSourceCount + newFacts;
14
+ if (denominator === 0) return 0;
15
+ return newFacts / denominator;
16
+ }
17
+
18
+ module.exports = { calcStaleness };
@@ -1,6 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const { normalizeEngineName: _normalizeEngine } = require('./daemon-utils');
4
+
5
+ function stripMd(s) {
6
+ return String(s || '')
7
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](url) → text
8
+ .replace(/[*_`#>~|]/g, ''); // inline markers
9
+ }
4
10
  const {
5
11
  getBoundProject,
6
12
  createWorkspaceAgent,
@@ -420,16 +426,16 @@ function createAgentCommandHandler(deps) {
420
426
  msg += '\n\n最近对话:';
421
427
  for (const item of recentDialogue) {
422
428
  const marker = item.role === 'assistant' ? '🤖' : '👤';
423
- const snippet = String(item.text || '').replace(/\n/g, ' ').slice(0, 120);
429
+ const snippet = stripMd(String(item.text || '').replace(/\n/g, ' ')).slice(0, 120);
424
430
  if (snippet) msg += `\n${marker} ${snippet}`;
425
431
  }
426
432
  } else if (recentCtx) {
427
433
  if (recentCtx.lastUser) {
428
- const snippet = recentCtx.lastUser.replace(/\n/g, ' ').slice(0, 80);
429
- msg += `\n\n💬 上次你说: _${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}_`;
434
+ const snippet = stripMd(recentCtx.lastUser.replace(/\n/g, ' ')).slice(0, 80);
435
+ msg += `\n\n💬 上次你说: ${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}`;
430
436
  }
431
437
  if (recentCtx.lastAssistant) {
432
- const snippet = recentCtx.lastAssistant.replace(/\n/g, ' ').slice(0, 80);
438
+ const snippet = stripMd(recentCtx.lastAssistant.replace(/\n/g, ' ')).slice(0, 80);
433
439
  msg += `\n🤖 上次回复: ${snippet}${recentCtx.lastAssistant.length > 80 ? '…' : ''}`;
434
440
  }
435
441
  }