llm-wiki-kit 0.2.9 → 0.2.11
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/README.md +10 -7
- package/docs/concepts.md +10 -8
- package/docs/integrations/claude-code.md +1 -1
- package/docs/integrations/codex.md +2 -2
- package/docs/manual.md +37 -8
- package/docs/operations.md +25 -3
- package/package.json +1 -1
- package/src/capture-policy.js +19 -19
- package/src/cli.js +19 -2
- package/src/consolidate.js +62 -29
- package/src/live-qa.js +321 -0
- package/src/maintenance.js +97 -2
- package/src/project.js +2 -26
- package/src/templates.js +19 -10
- package/src/wiki-lint.js +96 -9
- package/src/wiki-search.js +84 -19
- package/src/wiki-visibility.js +76 -0
package/src/live-qa.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { copyFile, readdir } from 'fs/promises';
|
|
2
|
+
import { basename, dirname, join, relative } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
appendText,
|
|
5
|
+
ensureDir,
|
|
6
|
+
exists,
|
|
7
|
+
readText,
|
|
8
|
+
sha256,
|
|
9
|
+
timeKst,
|
|
10
|
+
todayKst,
|
|
11
|
+
writeText,
|
|
12
|
+
} from './fs-utils.js';
|
|
13
|
+
import { redactText } from './redaction.js';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_LIVE_QA_MAX_LINES = 500;
|
|
16
|
+
export const DEFAULT_LIVE_QA_MAX_BYTES = 80 * 1024;
|
|
17
|
+
|
|
18
|
+
const ARCHIVE_STUB_MARKER = '<!-- llm-wiki-kit:archived-live-qa -->';
|
|
19
|
+
|
|
20
|
+
function positiveInteger(value, fallback) {
|
|
21
|
+
const parsed = Number(value);
|
|
22
|
+
if (!Number.isInteger(parsed) || parsed < 1) return fallback;
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function liveQaMaxLines(env = process.env) {
|
|
27
|
+
return positiveInteger(env.LLM_WIKI_KIT_LIVE_QA_MAX_LINES, DEFAULT_LIVE_QA_MAX_LINES);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function liveQaMaxBytes(env = process.env) {
|
|
31
|
+
return positiveInteger(env.LLM_WIKI_KIT_LIVE_QA_MAX_BYTES, DEFAULT_LIVE_QA_MAX_BYTES);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function liveQaDayDir(projectRoot, day = todayKst()) {
|
|
35
|
+
return join(projectRoot, 'llm-wiki', 'outputs', 'questions', day);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function liveQaLegacyPath(projectRoot, day) {
|
|
39
|
+
return join(projectRoot, 'llm-wiki', 'outputs', 'questions', `${day}-live-qa.md`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function chunkName(number) {
|
|
43
|
+
return `live-qa-${String(number).padStart(3, '0')}.md`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function chunkNumber(file) {
|
|
47
|
+
const match = file.match(/^live-qa-(\d+)\.md$/);
|
|
48
|
+
return match ? Number(match[1]) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function countLines(text) {
|
|
52
|
+
if (!text) return 0;
|
|
53
|
+
return String(text).split(/\r?\n/).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function countBlocks(text) {
|
|
57
|
+
return (String(text || '').match(/^## \d{2}:\d{2} KST - /gm) || []).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listChunkFiles(dayDir) {
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = await readdir(dayDir, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return entries
|
|
68
|
+
.filter((entry) => entry.isFile() && chunkNumber(entry.name) !== null)
|
|
69
|
+
.map((entry) => entry.name)
|
|
70
|
+
.sort((a, b) => chunkNumber(a) - chunkNumber(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function exceedsLimit(existing, addition, options = {}) {
|
|
74
|
+
if (!existing) return false;
|
|
75
|
+
const maxLines = options.maxLines ?? liveQaMaxLines();
|
|
76
|
+
const maxBytes = options.maxBytes ?? liveQaMaxBytes();
|
|
77
|
+
return (
|
|
78
|
+
countLines(existing) + countLines(addition) > maxLines ||
|
|
79
|
+
Buffer.byteLength(existing, 'utf8') + Buffer.byteLength(addition, 'utf8') > maxBytes
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function targetChunkPath(projectRoot, day, block, options = {}) {
|
|
84
|
+
const dayDir = liveQaDayDir(projectRoot, day);
|
|
85
|
+
await ensureDir(dayDir);
|
|
86
|
+
const files = await listChunkFiles(dayDir);
|
|
87
|
+
let number = files.length > 0 ? chunkNumber(files.at(-1)) : 1;
|
|
88
|
+
let path = join(dayDir, chunkName(number));
|
|
89
|
+
const existing = await readText(path, '');
|
|
90
|
+
if (exceedsLimit(existing, block, options)) {
|
|
91
|
+
number += 1;
|
|
92
|
+
path = join(dayDir, chunkName(number));
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatLiveQaBlock(entry) {
|
|
98
|
+
return [
|
|
99
|
+
`\n## ${timeKst()} KST - ${entry.topic || 'session turn'}`,
|
|
100
|
+
'',
|
|
101
|
+
'### Question',
|
|
102
|
+
entry.question || '(not captured)',
|
|
103
|
+
'',
|
|
104
|
+
'### Work',
|
|
105
|
+
entry.work || '(not captured)',
|
|
106
|
+
'',
|
|
107
|
+
'### Result',
|
|
108
|
+
entry.result || '(not captured)',
|
|
109
|
+
'',
|
|
110
|
+
'### Changed files',
|
|
111
|
+
entry.changedFiles || '(not captured)',
|
|
112
|
+
'',
|
|
113
|
+
'### Verification',
|
|
114
|
+
entry.verification || '(not captured)',
|
|
115
|
+
'',
|
|
116
|
+
'### Follow-up',
|
|
117
|
+
entry.followUp || '작업/결정 중심으로 저장된 live Q&A 기록이다. 재사용 가능한 사실은 사용자가 원하거나 명확히 중요할 때만 기존 durable wiki 문서에 합친다.',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function refreshLiveQaIndex(projectRoot, day = todayKst()) {
|
|
123
|
+
const dayDir = liveQaDayDir(projectRoot, day);
|
|
124
|
+
const files = await listChunkFiles(dayDir);
|
|
125
|
+
const rows = [];
|
|
126
|
+
let totalBlocks = 0;
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const rel = file;
|
|
129
|
+
const text = await readText(join(dayDir, file), '');
|
|
130
|
+
const blocks = countBlocks(text);
|
|
131
|
+
totalBlocks += blocks;
|
|
132
|
+
rows.push(`- [${rel}](${rel}) - ${blocks} turn(s), ${countLines(text)} line(s)`);
|
|
133
|
+
}
|
|
134
|
+
const content = [
|
|
135
|
+
`# Live Q&A ${day}`,
|
|
136
|
+
'',
|
|
137
|
+
'Chunked live Q&A archive for meaningful work and decision turns.',
|
|
138
|
+
'',
|
|
139
|
+
`- chunks: ${files.length}`,
|
|
140
|
+
`- turns: ${totalBlocks}`,
|
|
141
|
+
`- updated_at: ${new Date().toISOString()}`,
|
|
142
|
+
'',
|
|
143
|
+
'## Chunks',
|
|
144
|
+
'',
|
|
145
|
+
rows.length > 0 ? rows.join('\n') : '(none)',
|
|
146
|
+
'',
|
|
147
|
+
].join('\n');
|
|
148
|
+
await writeText(join(dayDir, 'index.md'), content);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function appendLiveQa(projectRoot, entry, options = {}) {
|
|
152
|
+
const day = todayKst();
|
|
153
|
+
const block = redactText(formatLiveQaBlock(entry), 12000);
|
|
154
|
+
const path = await targetChunkPath(projectRoot, day, block, options);
|
|
155
|
+
await appendText(path, block);
|
|
156
|
+
await refreshLiveQaIndex(projectRoot, day);
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function splitLiveQaBlocks(text) {
|
|
161
|
+
const blocks = [];
|
|
162
|
+
let current = [];
|
|
163
|
+
for (const line of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
|
|
164
|
+
if (/^## \d{2}:\d{2} KST - /.test(line) && current.some((item) => item.trim())) {
|
|
165
|
+
blocks.push(`\n${current.join('\n').trim()}\n`);
|
|
166
|
+
current = [line];
|
|
167
|
+
} else {
|
|
168
|
+
current.push(line);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (current.some((item) => item.trim())) {
|
|
172
|
+
blocks.push(`\n${current.join('\n').trim()}\n`);
|
|
173
|
+
}
|
|
174
|
+
return blocks;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function packBlocks(blocks, options = {}) {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let current = '';
|
|
180
|
+
for (const block of blocks) {
|
|
181
|
+
if (current && exceedsLimit(current, block, options)) {
|
|
182
|
+
chunks.push(current);
|
|
183
|
+
current = block;
|
|
184
|
+
} else {
|
|
185
|
+
current += block;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (current) chunks.push(current);
|
|
189
|
+
return chunks;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function writeGeneratedFile(path, content) {
|
|
193
|
+
const current = await readText(path, null);
|
|
194
|
+
if (current !== null && current !== content) {
|
|
195
|
+
throw new Error(`refusing to overwrite existing generated target with different content: ${path}`);
|
|
196
|
+
}
|
|
197
|
+
if (current === content) return false;
|
|
198
|
+
await writeText(path, content);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function backupTarget(projectRoot, sourcePath, checksum) {
|
|
203
|
+
const base = join(projectRoot, 'llm-wiki', 'outputs', 'questions', 'archive', 'originals', basename(sourcePath));
|
|
204
|
+
if (!(await exists(base))) return base;
|
|
205
|
+
const currentChecksum = await readText(`${base}.sha256`, '');
|
|
206
|
+
if (currentChecksum.includes(checksum)) return base;
|
|
207
|
+
const parsed = basename(sourcePath, '.md');
|
|
208
|
+
return join(projectRoot, 'llm-wiki', 'outputs', 'questions', 'archive', 'originals', `${parsed}-${checksum.slice(0, 12)}.md`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function archiveOne(projectRoot, day, options = {}) {
|
|
212
|
+
const sourcePath = liveQaLegacyPath(projectRoot, day);
|
|
213
|
+
const sourceRel = relative(projectRoot, sourcePath).split('\\').join('/');
|
|
214
|
+
if (!(await exists(sourcePath))) {
|
|
215
|
+
return { date: day, status: 'missing', source: sourceRel, chunks: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const text = await readText(sourcePath, '');
|
|
219
|
+
if (text.includes(ARCHIVE_STUB_MARKER)) {
|
|
220
|
+
return { date: day, status: 'already-archived', source: sourceRel, chunks: [] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const blocks = splitLiveQaBlocks(text);
|
|
224
|
+
if (blocks.length === 0) {
|
|
225
|
+
return { date: day, status: 'empty', source: sourceRel, chunks: [] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const checksum = sha256(text);
|
|
229
|
+
const backupPath = await backupTarget(projectRoot, sourcePath, checksum);
|
|
230
|
+
const backupRel = relative(projectRoot, backupPath).split('\\').join('/');
|
|
231
|
+
const chunks = packBlocks(blocks, options);
|
|
232
|
+
const dayDir = liveQaDayDir(projectRoot, day);
|
|
233
|
+
const existingChunks = await listChunkFiles(dayDir);
|
|
234
|
+
const firstChunkNumber = existingChunks.length > 0 ? chunkNumber(existingChunks.at(-1)) + 1 : 1;
|
|
235
|
+
const chunkTargets = chunks.map((content, index) => ({
|
|
236
|
+
path: join(dayDir, chunkName(firstChunkNumber + index)),
|
|
237
|
+
rel: relative(projectRoot, join(dayDir, chunkName(firstChunkNumber + index))).split('\\').join('/'),
|
|
238
|
+
content,
|
|
239
|
+
blocks: countBlocks(content),
|
|
240
|
+
lines: countLines(content),
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
const result = {
|
|
244
|
+
date: day,
|
|
245
|
+
status: options.dryRun ? 'planned' : 'archived',
|
|
246
|
+
source: sourceRel,
|
|
247
|
+
original: backupRel,
|
|
248
|
+
checksum,
|
|
249
|
+
blocks: blocks.length,
|
|
250
|
+
chunks: chunkTargets.map(({ rel, blocks: blockCount, lines }) => ({ path: rel, blocks: blockCount, lines })),
|
|
251
|
+
};
|
|
252
|
+
if (options.dryRun) return result;
|
|
253
|
+
|
|
254
|
+
await ensureDir(dirname(backupPath));
|
|
255
|
+
if (!(await exists(backupPath))) await copyFile(sourcePath, backupPath);
|
|
256
|
+
await writeText(`${backupPath}.sha256`, `${checksum} ${basename(sourcePath)}\n`);
|
|
257
|
+
await ensureDir(dayDir);
|
|
258
|
+
for (const target of chunkTargets) {
|
|
259
|
+
await writeGeneratedFile(target.path, target.content);
|
|
260
|
+
}
|
|
261
|
+
await refreshLiveQaIndex(projectRoot, day);
|
|
262
|
+
const stub = [
|
|
263
|
+
'# Live Q&A Archived',
|
|
264
|
+
ARCHIVE_STUB_MARKER,
|
|
265
|
+
'',
|
|
266
|
+
'This legacy daily live Q&A file was archived into chunked files.',
|
|
267
|
+
'',
|
|
268
|
+
`- original: ${backupRel}`,
|
|
269
|
+
`- sha256: ${checksum}`,
|
|
270
|
+
`- index: llm-wiki/outputs/questions/${day}/index.md`,
|
|
271
|
+
'',
|
|
272
|
+
].join('\n');
|
|
273
|
+
await writeText(sourcePath, stub);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function datesToArchive(projectRoot, options = {}) {
|
|
278
|
+
if (options.date) return [options.date];
|
|
279
|
+
const questionsDir = join(projectRoot, 'llm-wiki', 'outputs', 'questions');
|
|
280
|
+
let entries = [];
|
|
281
|
+
try {
|
|
282
|
+
entries = await readdir(questionsDir, { withFileTypes: true });
|
|
283
|
+
} catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
return entries
|
|
287
|
+
.filter((entry) => entry.isFile())
|
|
288
|
+
.map((entry) => entry.name.match(/^(\d{4}-\d{2}-\d{2})-live-qa\.md$/)?.[1])
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.sort();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function archiveQuestions(projectRoot, options = {}) {
|
|
294
|
+
const dates = await datesToArchive(projectRoot, options);
|
|
295
|
+
const files = [];
|
|
296
|
+
for (const day of dates) {
|
|
297
|
+
files.push(await archiveOne(projectRoot, day, options));
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
workspace: projectRoot,
|
|
301
|
+
dryRun: Boolean(options.dryRun),
|
|
302
|
+
date: options.date || null,
|
|
303
|
+
files,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function formatArchiveQuestionsResult(result) {
|
|
308
|
+
const lines = [
|
|
309
|
+
'llm-wiki archive-questions',
|
|
310
|
+
`- workspace: ${result.workspace}`,
|
|
311
|
+
`- dry-run: ${result.dryRun ? 'yes' : 'no'}`,
|
|
312
|
+
`- files: ${result.files.length}`,
|
|
313
|
+
];
|
|
314
|
+
for (const file of result.files) {
|
|
315
|
+
lines.push(`- ${file.date}: ${file.status}; blocks=${file.blocks || 0}; chunks=${file.chunks.length}`);
|
|
316
|
+
for (const chunk of file.chunks) {
|
|
317
|
+
lines.push(` - ${chunk.path} (${chunk.blocks} turn(s), ${chunk.lines} line(s))`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return lines.join('\n');
|
|
321
|
+
}
|
package/src/maintenance.js
CHANGED
|
@@ -4,11 +4,15 @@ import { appendText, exists, kitDataDir, readJson, readText, sha256, writeTextIf
|
|
|
4
4
|
import { classifyTurn, isMaintenanceRelatedQuery } from './capture-policy.js';
|
|
5
5
|
import { redactText, summarizeForStorage } from './redaction.js';
|
|
6
6
|
import { buildEntryFromTurnState, hasRecoverableTurnState } from './state.js';
|
|
7
|
+
import { collectWikiPages, DEFAULT_MAX_WIKI_FILES, MEMORY_BYTE_LIMIT } from './wiki-model.js';
|
|
7
8
|
|
|
8
9
|
export const MAINTENANCE_QUEUE_REL = 'llm-wiki/outputs/maintenance/queue.md';
|
|
9
10
|
const DEFAULT_STALE_TURN_MS = 10 * 60 * 1000;
|
|
10
11
|
const DEFAULT_STALE_PENDING_DAYS = 7;
|
|
11
12
|
const DEFAULT_PENDING_LIMIT = 20;
|
|
13
|
+
const DEFAULT_REVIEW_PENDING_LIMIT = 5;
|
|
14
|
+
const DEFAULT_REVIEW_INTERVAL_DAYS = 14;
|
|
15
|
+
const MEMORY_NEAR_BUDGET_BYTES = 20 * 1024;
|
|
12
16
|
|
|
13
17
|
function queuePath(projectRoot) {
|
|
14
18
|
return join(projectRoot, MAINTENANCE_QUEUE_REL);
|
|
@@ -125,12 +129,26 @@ export async function readMaintenanceQueue(projectRoot) {
|
|
|
125
129
|
export function summarizeMaintenanceQueue(queue, options = {}) {
|
|
126
130
|
const staleDays = options.staleDays ?? DEFAULT_STALE_PENDING_DAYS;
|
|
127
131
|
const pendingLimit = options.pendingLimit ?? DEFAULT_PENDING_LIMIT;
|
|
132
|
+
const reviewPendingLimit = options.reviewPendingLimit ?? DEFAULT_REVIEW_PENDING_LIMIT;
|
|
133
|
+
const reviewIntervalDays = options.reviewIntervalDays ?? DEFAULT_REVIEW_INTERVAL_DAYS;
|
|
128
134
|
const pending = queue.items.filter((item) => item.status === 'pending');
|
|
129
135
|
const staleCutoff = Date.now() - staleDays * 24 * 60 * 60 * 1000;
|
|
130
136
|
const stalePending = pending.filter((item) => {
|
|
131
137
|
const time = Date.parse(item.created_at || item.last_seen_at || '');
|
|
132
138
|
return Number.isFinite(time) && time < staleCutoff;
|
|
133
139
|
});
|
|
140
|
+
const reviewItems = queue.items.filter((item) => item.status === 'done' || item.status === 'skipped');
|
|
141
|
+
const reviewTimes = reviewItems
|
|
142
|
+
.map((item) => Date.parse(item.last_seen_at || item.created_at || ''))
|
|
143
|
+
.filter(Number.isFinite);
|
|
144
|
+
const lastReviewMs = reviewTimes.length > 0 ? Math.max(...reviewTimes) : null;
|
|
145
|
+
const reviewReasons = [];
|
|
146
|
+
if (pending.length >= reviewPendingLimit) reviewReasons.push(`pending queue has ${pending.length} items (threshold ${reviewPendingLimit})`);
|
|
147
|
+
if (stalePending.length > 0) reviewReasons.push(`${stalePending.length} pending item(s) older than ${staleDays} days`);
|
|
148
|
+
if (pending.some((item) => item.result_missing)) reviewReasons.push('pending recovered turn state needs review');
|
|
149
|
+
if (lastReviewMs && Date.now() - lastReviewMs >= reviewIntervalDays * 24 * 60 * 60 * 1000) {
|
|
150
|
+
reviewReasons.push(`last maintenance review is older than ${reviewIntervalDays} days`);
|
|
151
|
+
}
|
|
134
152
|
return {
|
|
135
153
|
path: queue.path,
|
|
136
154
|
exists: queue.exists,
|
|
@@ -142,14 +160,79 @@ export function summarizeMaintenanceQueue(queue, options = {}) {
|
|
|
142
160
|
doneCount: queue.items.filter((item) => item.status === 'done').length,
|
|
143
161
|
skippedCount: queue.items.filter((item) => item.status === 'skipped').length,
|
|
144
162
|
stalePending,
|
|
163
|
+
stalePendingCount: stalePending.length,
|
|
145
164
|
stalePendingDays: staleDays,
|
|
146
165
|
pendingLimit,
|
|
166
|
+
reviewPendingLimit,
|
|
167
|
+
reviewIntervalDays,
|
|
168
|
+
lastReviewAt: lastReviewMs ? new Date(lastReviewMs).toISOString() : null,
|
|
169
|
+
reviewDue: reviewReasons.length > 0,
|
|
170
|
+
reviewReasons,
|
|
147
171
|
tooManyPending: pending.length > pendingLimit,
|
|
148
172
|
};
|
|
149
173
|
}
|
|
150
174
|
|
|
151
175
|
export async function maintenanceSummary(projectRoot, options = {}) {
|
|
152
|
-
|
|
176
|
+
const summary = summarizeMaintenanceQueue(await readMaintenanceQueue(projectRoot), options);
|
|
177
|
+
const health = await maintenanceHealthSignals(projectRoot, options);
|
|
178
|
+
const reviewReasons = [...summary.reviewReasons];
|
|
179
|
+
if (health.memoryNearBudget) reviewReasons.push(`memory.md is near budget (${health.memoryBytes} bytes)`);
|
|
180
|
+
if (health.pageCountNearSearchCap) reviewReasons.push(`wiki page count ${health.pageCount} is near search cap ${health.searchCap}`);
|
|
181
|
+
if (health.lint && health.lint.issueCount > 0) {
|
|
182
|
+
reviewReasons.push(`lint has ${health.lint.errorCount} error(s) and ${health.lint.warningCount} warning(s)`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...summary,
|
|
186
|
+
reviewDue: reviewReasons.length > 0,
|
|
187
|
+
reviewReasons,
|
|
188
|
+
health,
|
|
189
|
+
recommendedCommands: recommendedMaintenanceCommands(projectRoot),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function recommendedMaintenanceCommands(projectRoot) {
|
|
194
|
+
return [
|
|
195
|
+
`llm-wiki lint --workspace ${projectRoot}`,
|
|
196
|
+
`llm-wiki maintenance --workspace ${projectRoot}`,
|
|
197
|
+
`llm-wiki consolidate --workspace ${projectRoot} --dry-run`,
|
|
198
|
+
`llm-wiki consolidate --workspace ${projectRoot}`,
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function maintenanceHealthSignals(projectRoot, options = {}) {
|
|
203
|
+
const memoryText = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'memory.md'), '');
|
|
204
|
+
const memoryBytes = Buffer.byteLength(memoryText, 'utf8');
|
|
205
|
+
let pageCount = 0;
|
|
206
|
+
const searchCap = options.searchCap || DEFAULT_MAX_WIKI_FILES;
|
|
207
|
+
try {
|
|
208
|
+
pageCount = (await collectWikiPages(projectRoot, { maxFiles: searchCap })).length;
|
|
209
|
+
} catch {
|
|
210
|
+
pageCount = 0;
|
|
211
|
+
}
|
|
212
|
+
let lint = null;
|
|
213
|
+
if (options.includeLint && !options.skipLint) {
|
|
214
|
+
try {
|
|
215
|
+
const module = await import('./wiki-lint.js');
|
|
216
|
+
const result = await module.runLint(projectRoot, { maxFiles: options.maxFiles || 1000, skipMaintenance: true });
|
|
217
|
+
lint = {
|
|
218
|
+
ok: result.ok,
|
|
219
|
+
issueCount: result.issueCount,
|
|
220
|
+
errorCount: result.errorCount,
|
|
221
|
+
warningCount: result.warningCount,
|
|
222
|
+
};
|
|
223
|
+
} catch {
|
|
224
|
+
lint = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
memoryBytes,
|
|
229
|
+
memoryNearBudget: memoryBytes >= MEMORY_NEAR_BUDGET_BYTES,
|
|
230
|
+
memoryOversized: memoryBytes > MEMORY_BYTE_LIMIT,
|
|
231
|
+
pageCount,
|
|
232
|
+
searchCap,
|
|
233
|
+
pageCountNearSearchCap: pageCount >= Math.floor(searchCap * 0.8),
|
|
234
|
+
lint,
|
|
235
|
+
};
|
|
153
236
|
}
|
|
154
237
|
|
|
155
238
|
export async function appendMaintenanceItem(projectRoot, item) {
|
|
@@ -225,11 +308,11 @@ export async function recoverStaleTurnStates(projectRoot, options = {}) {
|
|
|
225
308
|
}
|
|
226
309
|
|
|
227
310
|
export function formatMaintenanceContext(summary, options = {}) {
|
|
311
|
+
if (!summary.reviewDue) return '';
|
|
228
312
|
const eventName = options.eventName || '';
|
|
229
313
|
const defaultLimit = eventName === 'SessionStart' || eventName === 'InstructionsLoaded' ? 1 : 5;
|
|
230
314
|
const limit = options.limit || defaultLimit;
|
|
231
315
|
let pending = summary.pending.slice(0, limit);
|
|
232
|
-
if (pending.length === 0) return '';
|
|
233
316
|
|
|
234
317
|
if (eventName === 'UserPromptSubmit') {
|
|
235
318
|
if (!isMaintenanceRelatedQuery(options.query || '', summary.pending)) return '';
|
|
@@ -242,6 +325,7 @@ export function formatMaintenanceContext(summary, options = {}) {
|
|
|
242
325
|
eventName === 'UserPromptSubmit'
|
|
243
326
|
? 'LLM Wiki maintenance status:'
|
|
244
327
|
: 'LLM Wiki maintenance status:',
|
|
328
|
+
`- review due: yes (${(summary.reviewReasons || []).slice(0, 2).join('; ') || 'periodic review threshold met'}).`,
|
|
245
329
|
`- pending review items: ${summary.pendingCount}. 현재 요청이 우선이며, 관련 있을 때만 durable wiki 정리에 사용한다.`,
|
|
246
330
|
];
|
|
247
331
|
for (const item of pending) {
|
|
@@ -258,14 +342,25 @@ export function formatMaintenanceResult(summary) {
|
|
|
258
342
|
'llm-wiki maintenance',
|
|
259
343
|
`- queue: ${summary.path}`,
|
|
260
344
|
`- pending: ${summary.pendingCount}`,
|
|
345
|
+
`- stale pending: ${summary.stalePendingCount || 0}`,
|
|
261
346
|
`- done: ${summary.doneCount}`,
|
|
262
347
|
`- skipped: ${summary.skippedCount}`,
|
|
348
|
+
`- review due: ${summary.reviewDue ? 'yes' : 'no'}`,
|
|
263
349
|
];
|
|
350
|
+
if ((summary.reviewReasons || []).length > 0) {
|
|
351
|
+
lines.push(`- review reasons: ${summary.reviewReasons.join('; ')}`);
|
|
352
|
+
}
|
|
264
353
|
if (summary.pending.length > 0) {
|
|
265
354
|
lines.push('', 'Pending:');
|
|
266
355
|
for (const item of summary.pending.slice(0, 10)) {
|
|
267
356
|
lines.push(`- ${item.topic || item.id}: ${item.source} -> ${item.suggested_target}`);
|
|
268
357
|
}
|
|
269
358
|
}
|
|
359
|
+
if ((summary.recommendedCommands || []).length > 0) {
|
|
360
|
+
lines.push('', 'Recommended commands:');
|
|
361
|
+
for (const command of summary.recommendedCommands) {
|
|
362
|
+
lines.push(`- ${command}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
270
365
|
return lines.join('\n');
|
|
271
366
|
}
|
package/src/project.js
CHANGED
|
@@ -14,6 +14,7 @@ import { normalizeForStorage, redactText, summarizeForStorage } from './redactio
|
|
|
14
14
|
import { gitignore, indexPage, llmWikiAgents, logPage, memoryPage, procedure, rootAgentsPolicy } from './templates.js';
|
|
15
15
|
import { formatProjectMaintenanceContext, inspectProjectState, recordManagedTemplates } from './project-state.js';
|
|
16
16
|
import { buildContextPack, formatHookContextPack, searchWiki as searchWikiWithIndex } from './wiki-search.js';
|
|
17
|
+
import { appendLiveQa as appendChunkedLiveQa } from './live-qa.js';
|
|
17
18
|
|
|
18
19
|
export async function bootstrapProject(projectRoot, options = {}) {
|
|
19
20
|
if (process.env.LLM_WIKI_KIT_DISABLE_BOOTSTRAP === '1') return { created: false };
|
|
@@ -97,32 +98,7 @@ function hasCapturedQuestion(entry) {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
export async function appendLiveQa(projectRoot, entry) {
|
|
100
|
-
|
|
101
|
-
const path = join(projectRoot, 'llm-wiki', 'outputs', 'questions', `${day}-live-qa.md`);
|
|
102
|
-
const block = [
|
|
103
|
-
`\n## ${timeKst()} KST - ${entry.topic || 'session turn'}`,
|
|
104
|
-
'',
|
|
105
|
-
'### Question',
|
|
106
|
-
entry.question || '(not captured)',
|
|
107
|
-
'',
|
|
108
|
-
'### Work',
|
|
109
|
-
entry.work || '(not captured)',
|
|
110
|
-
'',
|
|
111
|
-
'### Result',
|
|
112
|
-
entry.result || '(not captured)',
|
|
113
|
-
'',
|
|
114
|
-
'### Changed files',
|
|
115
|
-
entry.changedFiles || '(not captured)',
|
|
116
|
-
'',
|
|
117
|
-
'### Verification',
|
|
118
|
-
entry.verification || '(not captured)',
|
|
119
|
-
'',
|
|
120
|
-
'### Follow-up',
|
|
121
|
-
entry.followUp || '다음 작업에서 이 turn의 reusable fact가 있으면 기존 wiki 문서에 합치고, 일회성 기록은 이 Q&A에만 보존한다.',
|
|
122
|
-
'',
|
|
123
|
-
].join('\n');
|
|
124
|
-
await appendText(path, redactText(block, 12000));
|
|
125
|
-
return path;
|
|
101
|
+
return appendChunkedLiveQa(projectRoot, entry);
|
|
126
102
|
}
|
|
127
103
|
|
|
128
104
|
export async function writeQueryPage(projectRoot, entry) {
|
package/src/templates.js
CHANGED
|
@@ -14,10 +14,10 @@ This repository uses llm-wiki-kit as a hook-first living Markdown wiki for Codex
|
|
|
14
14
|
- \`llm-wiki/wiki/\`는 agent가 관리하는 지식층이다. 결정, 구조, 디버깅, 개념, 절차, 맥락을 여기에 정리한다.
|
|
15
15
|
- \`llm-wiki/wiki/memory.md\`는 짧은 핵심 기억이다. 긴 설명 대신 현재 상태와 중요한 문서 링크만 유지한다.
|
|
16
16
|
- hook이 주입한 context를 참고하되, 현재 사용자 답변을 먼저 처리한다. 수동 확인이나 정리에는 \`llm-wiki context\`, \`llm-wiki lint\`, \`llm-wiki consolidate\`를 agent 보조 도구로 사용한다.
|
|
17
|
-
- hook은 redacted raw envelope와
|
|
18
|
-
- hook은 종료/시작 경계에서 정말 필요한 정리 후보를 \`llm-wiki/outputs/maintenance/queue.md\`에 남길 수 있다. pending 항목은 현재 응답을 지연시키지 않는 범위에서 agent가 병합하거나 done/skipped로 표시한다.
|
|
19
|
-
- 새 문서를 만들기 전에 기존 wiki 문서를 먼저 찾아 갱신한다. 반복해서 쓸 사실은 \`outputs/questions/\`에만 두지 말고 적절한 wiki 문서에 합친다.
|
|
20
|
-
- 일회성
|
|
17
|
+
- hook은 redacted raw envelope와 작업/결정 중심 live Q&A만 안전하게 남긴다. 단순 답변, 상태 확인, 키워드만 포함된 응답은 live Q&A나 durable wiki로 승격하지 않는다. \`wiki/queries\`/\`wiki/decisions\` 자동 승격은 기본값이 아니며, durable 지식은 중요도와 동의 흐름에 따라 agent가 기존 정식 wiki 문서에 합친다.
|
|
18
|
+
- hook은 종료/시작 경계에서 정말 필요한 정리 후보를 \`llm-wiki/outputs/maintenance/queue.md\`에 남길 수 있다. 정기 maintenance는 agent-side soft reminder이며, pending 항목은 현재 응답을 지연시키지 않는 범위에서 agent가 병합하거나 done/skipped로 표시한다.
|
|
19
|
+
- 새 문서를 만들기 전에 기존 wiki 문서를 먼저 찾아 갱신한다. 반복해서 쓸 사실은 chunked \`outputs/questions/\`에만 두지 말고 적절한 wiki 문서에 합친다.
|
|
20
|
+
- 일회성 작업/결정 기록은 필요할 때 \`llm-wiki/outputs/questions/YYYY-MM-DD/live-qa-001.md\` style chunk에 보존하고, 재사용 가능한 사실/지식은 승인된 경우 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 반영한다.
|
|
21
21
|
- 검증 명령, 근거 파일, 불확실한 점을 함께 남긴다. 추론은 추론이라고 표시하고, 모순은 지우지 말고 Open Questions 또는 Contradictions에 남긴다.
|
|
22
22
|
- 인증값, token, password, private key, \`.env\` 원문은 저장하지 않는다. 필요한 경우 redacted summary만 남긴다.
|
|
23
23
|
|
|
@@ -47,9 +47,10 @@ Codex와 Claude Code를 평소처럼 사용하는 동안 living Markdown LLM Wik
|
|
|
47
47
|
- 근거 없는 내용을 사실처럼 쓰지 않는다. 추론은 명시한다.
|
|
48
48
|
- 중요한 주장에는 \`source_ids\`, 파일 경로, 검증 명령 중 하나 이상을 남긴다.
|
|
49
49
|
- 오래 남길 내용이 생기면 새 문서부터 만들지 말고 기존 \`wiki/\` 문서를 먼저 찾아 갱신한다.
|
|
50
|
-
- 단순 답변, 상태 확인, 일회성 대화는
|
|
50
|
+
- 단순 답변, 상태 확인, 키워드만 포함된 응답, 일회성 대화는 live Q&A, \`wiki/queries\`, maintenance로 승격하지 않는다.
|
|
51
51
|
- 반복해서 쓸 지식은 중요도와 동의 흐름에 따라 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 합친다.
|
|
52
|
-
- hook이 만든 \`outputs/maintenance/queue.md\` pending 항목은 현재 요청과 관련 있을 때 확인하고, 기존 정식 wiki 문서에 병합한 뒤 done 또는 skipped로 표시한다.
|
|
52
|
+
- hook이 만든 \`outputs/maintenance/queue.md\` pending 항목은 현재 요청과 관련 있거나 review due 안내가 있을 때 확인하고, 기존 정식 wiki 문서에 병합한 뒤 done 또는 skipped로 표시한다.
|
|
53
|
+
- 정기 maintenance는 자동 수정이 아니라 agent review다. \`SessionStart\`/\`InstructionsLoaded\`에서만 짧게 안내되고, \`UserPromptSubmit\`에서는 사용자가 wiki/maintenance/정리 관련 질문을 한 경우에만 안내된다.
|
|
53
54
|
- \`wiki/memory.md\`는 짧게 유지한다. 긴 설명 대신 현재 상태와 중요한 문서 링크를 둔다.
|
|
54
55
|
- 모순은 덮어쓰지 말고 \`Contradictions\` 또는 \`Open Questions\`에 보존한다.
|
|
55
56
|
- 인증값, token, password, private key, \`.env\` 원문은 wiki에 저장하지 않는다.
|
|
@@ -107,7 +108,7 @@ Generated by llm-wiki-kit.
|
|
|
107
108
|
|
|
108
109
|
- 넓은 질문은 memory와 이 index에서 시작한다.
|
|
109
110
|
- 새 문서를 만들기 전에 관련 기존 문서 3-7개를 먼저 확인한다.
|
|
110
|
-
- 오래 쓸 사실/지식은 기존 정식 문서에 합치고, 일회성 기록은 outputs/questions에 둔다.
|
|
111
|
+
- 오래 쓸 사실/지식은 기존 정식 문서에 합치고, 작업/결정 중심 일회성 기록은 chunked outputs/questions에 둔다.
|
|
111
112
|
- 관련 페이지가 생기면 \`[[page-or-topic]]\` 링크를 추가한다.
|
|
112
113
|
|
|
113
114
|
<!-- llm-wiki-kit:index-start -->
|
|
@@ -182,15 +183,23 @@ export function procedure(name) {
|
|
|
182
183
|
3. 관련 \`wiki/\` 문서를 최소한으로 읽는다.
|
|
183
184
|
4. 정확한 근거가 필요할 때만 raw source를 확인한다.
|
|
184
185
|
5. 검증된 사실과 추론을 분리한다.
|
|
185
|
-
6. 수동 확인이 필요할 때만 \`llm-wiki context "<query>"\`를 쓴다. 과거 episodic query까지 봐야 할 때는 \`--include-episodic\`을 붙인다.
|
|
186
|
-
7. 일회성
|
|
186
|
+
6. 수동 확인이 필요할 때만 \`llm-wiki context "<query>"\`를 쓴다. 과거 episodic query/context까지 봐야 할 때는 \`--include-episodic\`을 붙이고, archived/superseded page까지 봐야 할 때만 \`--include-archived\`를 붙인다.
|
|
187
|
+
7. 일회성 답변, 상태 확인, 키워드만 포함된 응답은 durable wiki나 live Q&A로 승격하지 않는다. tool evidence, changed-file evidence, verification, 구조화된 decision/debugging 결론이 있는 작업 turn만 \`outputs/questions/YYYY-MM-DD/live-qa-001.md\` style chunk에 남긴다.
|
|
187
188
|
8. 반복해서 쓸 사실/지식은 중요도와 동의 흐름에 따라 \`wiki/architecture/\`, \`wiki/debugging/\`, \`wiki/decisions/\`, \`wiki/concepts/\`, \`procedures/\`에 합친다.
|
|
188
189
|
`,
|
|
189
190
|
'lint.md': `# Lint Procedure
|
|
190
191
|
|
|
191
192
|
\`llm-wiki lint --workspace <project>\`는 agent가 필요할 때 쓰는 wiki 건강 점검 도구다. 사용자가 매번 실행해야 하는 명령이 아니다.
|
|
192
193
|
|
|
193
|
-
점검 대상: stale page, orphan page, broken wiki/Markdown link, unsafe source id, secret-like content, missing source, duplicate concept/title, unsupported claim, unresolved contradiction, outdated managed rule.
|
|
194
|
+
점검 대상: stale page, orphan page, broken wiki/Markdown link, unsafe source id, secret-like content, missing source, duplicate concept/title, unsupported claim, unresolved contradiction, outdated managed rule, memory budget, page count growth, hidden episodic/context growth, stale/archived discoverability, maintenance review due, oversized legacy live Q&A, oversized live Q&A chunk.
|
|
195
|
+
|
|
196
|
+
정기 maintenance는 사용자가 매 turn 실행할 필요가 없는 agent-side task다. 필요할 때 agent가 다음 순서로 확인한다:
|
|
197
|
+
|
|
198
|
+
1. \`llm-wiki lint --workspace <project>\`
|
|
199
|
+
2. \`llm-wiki maintenance --workspace <project>\`
|
|
200
|
+
3. pending item을 기존 durable page에 병합한 뒤 \`done\` 또는 \`skipped\`로 표시한다.
|
|
201
|
+
4. \`llm-wiki consolidate --workspace <project> --dry-run\`
|
|
202
|
+
5. 필요할 때 \`llm-wiki consolidate --workspace <project>\`
|
|
194
203
|
|
|
195
204
|
자동 수정은 확실히 kit가 관리하는 영역에만 적용한다. 사용자 편집 가능성이 있는 문서는 덮어쓰지 말고 다음 작업 context에 정리 필요성을 올린다.
|
|
196
205
|
`,
|