metame-cli 1.5.25 → 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 +1 -1
- package/scripts/agent-layer.js +36 -0
- package/scripts/core/wiki-db.js +404 -0
- package/scripts/core/wiki-prompt.js +88 -0
- package/scripts/core/wiki-slug.js +66 -0
- package/scripts/core/wiki-staleness.js +18 -0
- package/scripts/daemon-agent-commands.js +10 -4
- package/scripts/daemon-bridges.js +64 -3
- package/scripts/daemon-claude-engine.js +62 -8
- package/scripts/daemon-command-router.js +15 -0
- package/scripts/daemon-default.yaml +2 -3
- package/scripts/daemon-ops-commands.js +9 -18
- package/scripts/daemon-session-commands.js +4 -0
- package/scripts/daemon-warm-pool.js +15 -0
- package/scripts/daemon-wiki.js +298 -0
- package/scripts/daemon.js +6 -3
- package/scripts/distill.js +1 -1
- package/scripts/docs/file-transfer.md +0 -1
- package/scripts/docs/maintenance-manual.md +2 -55
- package/scripts/docs/pointer-map.md +0 -34
- package/scripts/hooks/intent-file-transfer.js +1 -2
- package/scripts/memory-search.js +17 -2
- package/scripts/memory-wiki-schema.js +96 -0
- package/scripts/memory.js +88 -3
- package/scripts/signal-capture.js +1 -1
- package/scripts/skill-evolution.js +2 -11
- package/scripts/wiki-reflect-build.js +117 -0
- package/scripts/wiki-reflect-export.js +333 -0
- package/scripts/wiki-reflect-query.js +109 -0
- package/scripts/wiki-reflect.js +305 -0
package/package.json
CHANGED
package/scripts/agent-layer.js
CHANGED
|
@@ -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💬 上次你说:
|
|
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
|
}
|