llm-wiki-kit 0.2.10 → 0.2.12
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 +24 -9
- package/docs/concepts.md +6 -5
- package/docs/integrations/claude-code.md +3 -1
- package/docs/integrations/codex.md +3 -2
- package/docs/manual.md +132 -383
- package/docs/operations.md +4 -1
- package/package.json +12 -2
- package/src/capture-policy.js +30 -23
- package/src/cli.js +17 -2
- package/src/hook.js +15 -7
- package/src/language.js +73 -0
- package/src/live-qa.js +321 -0
- package/src/maintenance.js +13 -8
- package/src/project-state.js +20 -9
- package/src/project.js +7 -29
- package/src/templates.js +89 -82
- package/src/update-notice.js +14 -4
- package/src/wiki-lint.js +49 -0
- package/src/wiki-search.js +35 -13
package/docs/operations.md
CHANGED
|
@@ -104,6 +104,7 @@ llm-wiki context "search phrase" --workspace /path/to/project
|
|
|
104
104
|
llm-wiki lint --workspace /path/to/project
|
|
105
105
|
llm-wiki consolidate --workspace /path/to/project
|
|
106
106
|
llm-wiki maintenance --workspace /path/to/project
|
|
107
|
+
llm-wiki archive-questions --workspace /path/to/project --dry-run
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
`status` is offline and answers whether the local installation is internally consistent:
|
|
@@ -143,7 +144,9 @@ After a plain `npm install -g llm-wiki-kit@latest`, existing hooks keep working
|
|
|
143
144
|
|
|
144
145
|
Daily use should be Claude Code/Codex first. The user should not need to run a chain of `llm-wiki` commands while working. Hooks inject context automatically, but the current user answer takes priority over wiki cleanup. The active agent updates durable wiki pages when reusable project knowledge appears and the turn's importance or user consent justifies persistence. Hook context policy is function-first: memory, search, maintenance, and update signals remain available, while user-visible context is formatted as functional compact context instead of a raw dump.
|
|
145
146
|
|
|
146
|
-
In the default `LLM_WIKI_KIT_CAPTURE_MODE=answer-first` mode, `Stop` and `SessionEnd` append live Q&A only for meaningful work turns.
|
|
147
|
+
In the default `LLM_WIKI_KIT_CAPTURE_MODE=answer-first` mode, `Stop` and `SessionEnd` append live Q&A only for meaningful work evidence or structured decision turns. Simple answers, status checks, and keyword-only responses are not archived. Live Q&A uses chunked files under `llm-wiki/outputs/questions/YYYY-MM-DD/` and rolls over by line/byte budget. Hooks do not auto-create `wiki/queries/` or `wiki/decisions/`. If the user explicitly asked for recording/documentation and no durable wiki update is detected, a pending cleanup candidate is written to `llm-wiki/outputs/maintenance/queue.md`. `PreCompact` performs the same answer-first classification before context compaction: simple turns get only a context note, archive-worthy turns get a live Q&A checkpoint, and explicit durable candidates get a checkpoint plus queue item only when needed. If checkpoint storage fails, compaction still proceeds and the hook prepares an important-only compact recovery packet for the next legal context-injection event. `SessionStart` and `UserPromptSubmit` also recover stale per-turn state into the same queue when the previous stop hook did not complete. `SessionStart` injects a one-item queue summary; `UserPromptSubmit` injects a soft reminder only when the prompt is wiki/maintenance related or matches a queue topic. This is a recovery and reminder layer, not a full transcript capture path.
|
|
148
|
+
|
|
149
|
+
Use `llm-wiki archive-questions --workspace <project> --dry-run` to review splitting legacy `outputs/questions/YYYY-MM-DD-live-qa.md` files into the chunked layout. Running it without `--dry-run` preserves the original under `outputs/questions/archive/originals/` with a checksum sidecar and replaces the legacy file with a pointer stub.
|
|
147
150
|
|
|
148
151
|
Pre-compact preservation defaults to `LLM_WIKI_KIT_PRECOMPACT_ENFORCEMENT=limited`, but compaction is never blocked by llm-wiki-kit. `limited` and `soft` emit non-blocking failure warnings, and `off` suppresses failure output. `LLM_WIKI_KIT_PRECOMPACT_TRANSCRIPT_TAIL_BYTES` controls the small bounded transcript tail used for checkpoint context. Authentication values and the raw transcript path are redacted before storage.
|
|
149
152
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-wiki-kit",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Hook-first living
|
|
3
|
+
"version": "0.2.12",
|
|
4
|
+
"description": "Hook-first living Markdown wiki runtime for Codex and Claude Code with Korean/English prompt-aware guidance.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin/",
|
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"llm-wiki": "bin/llm-wiki.js"
|
|
17
17
|
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"codex",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"llm-wiki",
|
|
22
|
+
"agent-memory",
|
|
23
|
+
"markdown",
|
|
24
|
+
"hooks",
|
|
25
|
+
"korean",
|
|
26
|
+
"english"
|
|
27
|
+
],
|
|
18
28
|
"scripts": {
|
|
19
29
|
"test": "node --test",
|
|
20
30
|
"doctor": "node bin/llm-wiki.js doctor",
|
package/src/capture-policy.js
CHANGED
|
@@ -2,7 +2,9 @@ const NOT_CAPTURED = '(not captured)';
|
|
|
2
2
|
|
|
3
3
|
const EXPLICIT_DURABLE_RE = /(?:기록해|기록해줘|문서화|문서화해|wiki에\s*남겨|위키에\s*남겨|wiki[^.\n]{0,40}(?:남겨|저장|기록)|위키[^.\n]{0,40}(?:남겨|저장|기록)|remember\s+this|save\s+this|document\s+this|record\s+this|persist\s+this)/i;
|
|
4
4
|
|
|
5
|
-
const DURABLE_KEYWORD_RE = /(?:
|
|
5
|
+
const DURABLE_KEYWORD_RE = /(?:root cause|architecture|policy|procedure|decision|decided|migration|security|디버깅|원인|아키텍처|구조|정책|절차|결정|선택|채택|확정)/i;
|
|
6
|
+
|
|
7
|
+
const DURABLE_CONCLUSION_RE = /(?:^|\n)\s*(?:Decision|Decided|Policy|Procedure|Root cause|Resolution|Fix|Verification|결정|정책|절차|원인|해결|수정|검증)\s*[::-]/i;
|
|
6
8
|
|
|
7
9
|
const MAINTENANCE_QUERY_RE = /(?:llm-wiki|wiki|위키|maintenance|maintain|문서|문서화|기록|정리|consolidate|lint|queue|AGENTS\.md)/i;
|
|
8
10
|
|
|
@@ -44,6 +46,11 @@ export function hasDurableKeyword(value) {
|
|
|
44
46
|
return DURABLE_KEYWORD_RE.test(text);
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
export function hasDurableConclusion(value) {
|
|
50
|
+
const text = typeof value === 'string' ? value : entrySearchText(value);
|
|
51
|
+
return DURABLE_CONCLUSION_RE.test(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
export function isMaintenanceRelatedQuery(query, pendingItems = []) {
|
|
48
55
|
const text = capturedText(query);
|
|
49
56
|
if (!text) return false;
|
|
@@ -107,50 +114,50 @@ export function classifyTurn(entry, eventName = '') {
|
|
|
107
114
|
const hasWork = Boolean(work);
|
|
108
115
|
const hasFiles = Boolean(changedFiles);
|
|
109
116
|
const hasVerification = Boolean(verification);
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
!hasFiles &&
|
|
115
|
-
!hasVerification &&
|
|
116
|
-
question.length <= 120 &&
|
|
117
|
-
result.length <= 800 &&
|
|
118
|
-
!durableKeyword;
|
|
119
|
-
|
|
120
|
-
if (simple) {
|
|
117
|
+
const hasWorkEvidence = hasWork || hasFiles || hasVerification;
|
|
118
|
+
const durableConclusion = hasDurableConclusion(text);
|
|
119
|
+
|
|
120
|
+
if (hasWorkEvidence) {
|
|
121
121
|
return {
|
|
122
|
-
kind: '
|
|
123
|
-
archive:
|
|
122
|
+
kind: hasDetectedDurableWikiChange(entry) ? 'durable-updated' : 'work',
|
|
123
|
+
archive: true,
|
|
124
124
|
suggestDurable: false,
|
|
125
125
|
queueIfMissingDurable: false,
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
if (
|
|
129
|
+
if (durableConclusion) {
|
|
130
130
|
return {
|
|
131
|
-
kind: '
|
|
131
|
+
kind: 'decision',
|
|
132
132
|
archive: true,
|
|
133
|
-
suggestDurable:
|
|
133
|
+
suggestDurable: false,
|
|
134
134
|
queueIfMissingDurable: false,
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
return {
|
|
139
|
-
kind:
|
|
140
|
-
archive:
|
|
139
|
+
kind: 'simple',
|
|
140
|
+
archive: false,
|
|
141
141
|
suggestDurable: false,
|
|
142
142
|
queueIfMissingDurable: false,
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
export function formatDurableCaptureGuidance(query) {
|
|
146
|
+
export function formatDurableCaptureGuidance(query, options = {}) {
|
|
147
147
|
const text = capturedText(query);
|
|
148
148
|
if (!text) return '';
|
|
149
149
|
if (explicitDurableRequested(text)) {
|
|
150
|
+
if (options.language === 'ko') {
|
|
151
|
+
return [
|
|
152
|
+
'LLM Wiki 문서화 요청 감지:',
|
|
153
|
+
'- 현재 사용자 요청을 먼저 처리하되, 사용자가 기록/문서화를 요청했으므로 기존 durable wiki 문서를 찾아 갱신한다.',
|
|
154
|
+
'- 새 문서는 관련 기존 문서가 없을 때만 만들고, credentials/private data는 저장하지 않는다.',
|
|
155
|
+
].join('\n');
|
|
156
|
+
}
|
|
150
157
|
return [
|
|
151
|
-
'LLM Wiki
|
|
152
|
-
'-
|
|
153
|
-
'-
|
|
158
|
+
'LLM Wiki documentation request detected:',
|
|
159
|
+
'- Answer the current user request first, then update the existing durable wiki page because the user asked to record or document this.',
|
|
160
|
+
'- Create a new page only when no related durable page exists, and never store credentials or private data.',
|
|
154
161
|
].join('\n');
|
|
155
162
|
}
|
|
156
163
|
return '';
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { migrate } from './migrate.js';
|
|
|
12
12
|
import { postUpdate, update } from './update.js';
|
|
13
13
|
import { buildContextPack, formatContextPack } from './wiki-search.js';
|
|
14
14
|
import { formatLintResult, runLint } from './wiki-lint.js';
|
|
15
|
+
import { archiveQuestions, formatArchiveQuestionsResult } from './live-qa.js';
|
|
15
16
|
|
|
16
17
|
function parseOptions(args) {
|
|
17
18
|
const options = {};
|
|
@@ -32,6 +33,13 @@ function parseOptions(args) {
|
|
|
32
33
|
} else if (arg === '--to') {
|
|
33
34
|
options.to = optionValue(arg, i);
|
|
34
35
|
i += 1;
|
|
36
|
+
} else if (arg === '--date') {
|
|
37
|
+
const value = optionValue(arg, i);
|
|
38
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
39
|
+
throw new Error('--date must use YYYY-MM-DD');
|
|
40
|
+
}
|
|
41
|
+
options.date = value;
|
|
42
|
+
i += 1;
|
|
35
43
|
} else if (arg === '--timeout-ms') {
|
|
36
44
|
const value = optionValue(arg, i);
|
|
37
45
|
const timeout = Number(value);
|
|
@@ -122,6 +130,7 @@ Usage:
|
|
|
122
130
|
llm-wiki lint --workspace <project>
|
|
123
131
|
llm-wiki consolidate --workspace <project> [--dry-run]
|
|
124
132
|
llm-wiki maintenance --workspace <project> [--json]
|
|
133
|
+
llm-wiki archive-questions --workspace <project> [--date YYYY-MM-DD] [--dry-run] [--json]
|
|
125
134
|
`);
|
|
126
135
|
return;
|
|
127
136
|
}
|
|
@@ -240,6 +249,12 @@ Usage:
|
|
|
240
249
|
return;
|
|
241
250
|
}
|
|
242
251
|
|
|
252
|
+
if (command === 'archive-questions') {
|
|
253
|
+
const projectRoot = resolve(options.workspace || process.cwd());
|
|
254
|
+
printJsonOrText(await archiveQuestions(projectRoot, options), options, formatArchiveQuestionsResult);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
243
258
|
if (command === 'hook') {
|
|
244
259
|
const provider = rest[0] || 'codex';
|
|
245
260
|
const eventName = rest[1];
|
|
@@ -309,7 +324,7 @@ function formatUpdate(value) {
|
|
|
309
324
|
`- installed: ${value.installedVersion}`,
|
|
310
325
|
`- latest: ${value.latestVersion}`,
|
|
311
326
|
`- update available: ${value.updateAvailable ? 'yes' : 'no'}`,
|
|
312
|
-
...(value.updateAvailable ? [`-
|
|
327
|
+
...(value.updateAvailable ? [`- recommended command: ${commandForProject('update', value.workspace)}`] : []),
|
|
313
328
|
`- project applied runtime: ${value.project?.lastRuntimeVersionApplied || 'unknown'}`,
|
|
314
329
|
].join('\n');
|
|
315
330
|
}
|
|
@@ -326,7 +341,7 @@ function formatUpdate(value) {
|
|
|
326
341
|
`- installed: ${value.installedVersion}`,
|
|
327
342
|
`- latest: ${value.latestVersion}`,
|
|
328
343
|
`- update available: ${value.updateAvailable ? 'yes' : 'no'}`,
|
|
329
|
-
...(value.updateAvailable ? [`-
|
|
344
|
+
...(value.updateAvailable ? [`- recommended command: ${commandForProject('update', value.workspace)}`] : []),
|
|
330
345
|
`- scope: ${value.scope || 'current'}`,
|
|
331
346
|
...(projects.length > 0 ? [`- projects checked: ${projects.length}`] : []),
|
|
332
347
|
`- project template changes: ${changed}`,
|
package/src/hook.js
CHANGED
|
@@ -9,6 +9,7 @@ import { summarizeForStorage } from './redaction.js';
|
|
|
9
9
|
import { buildEntryFromState, clearTurnState, rememberQuestion, rememberTool } from './state.js';
|
|
10
10
|
import { updateNoticeContext } from './update-notice.js';
|
|
11
11
|
import { removeLegacyOmxWikiSurfaces } from './legacy-omx-wiki.js';
|
|
12
|
+
import { resolveLanguage } from './language.js';
|
|
12
13
|
import { relative } from 'path';
|
|
13
14
|
|
|
14
15
|
async function readStdinJson() {
|
|
@@ -53,9 +54,10 @@ function contextOutput(provider, eventName, context) {
|
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
async function hookContext(projectRoot, eventName, context, payload) {
|
|
57
|
+
async function hookContext(projectRoot, eventName, context, payload, language) {
|
|
57
58
|
const notice = await updateNoticeContext(projectRoot, eventName, {
|
|
58
59
|
session: payload.session_id || payload.sessionId,
|
|
60
|
+
language,
|
|
59
61
|
});
|
|
60
62
|
return [context, notice].filter(Boolean).join('\n\n');
|
|
61
63
|
}
|
|
@@ -85,7 +87,7 @@ async function handleAnswerFirstStop(projectRoot, eventName, payload, entry) {
|
|
|
85
87
|
const archiveEntry = {
|
|
86
88
|
...entry,
|
|
87
89
|
followUp: classification.suggestDurable
|
|
88
|
-
? '
|
|
90
|
+
? 'This turn may be worth preserving. If the user approves, merge it into an existing durable wiki page.'
|
|
89
91
|
: entry.followUp,
|
|
90
92
|
};
|
|
91
93
|
const liveQaPath = await appendLiveQa(projectRoot, archiveEntry);
|
|
@@ -128,6 +130,10 @@ export async function handleHook(provider, explicitEvent) {
|
|
|
128
130
|
const cwd = payload.cwd || process.cwd();
|
|
129
131
|
const projectRoot = await findProjectRoot(cwd);
|
|
130
132
|
await bootstrapProject(projectRoot);
|
|
133
|
+
const requestLanguage = await resolveLanguage(projectRoot, {
|
|
134
|
+
provider,
|
|
135
|
+
prompt: eventName === 'UserPromptSubmit' ? promptText(payload) : '',
|
|
136
|
+
}).catch(() => 'en');
|
|
131
137
|
await recordProject(projectRoot, 'hook').catch(() => {});
|
|
132
138
|
await autoUpdateManagedProject(projectRoot, eventName).catch(() => {});
|
|
133
139
|
await removeLegacyWikiSurfaces(projectRoot, eventName).catch(() => {});
|
|
@@ -143,8 +149,9 @@ export async function handleHook(provider, explicitEvent) {
|
|
|
143
149
|
const context = await hookContext(
|
|
144
150
|
projectRoot,
|
|
145
151
|
eventName,
|
|
146
|
-
[recovery, await buildContextBrief(projectRoot, 'SessionStart')].filter(Boolean).join('\n\n'),
|
|
147
|
-
payload
|
|
152
|
+
[recovery, await buildContextBrief(projectRoot, 'SessionStart', '', { language: requestLanguage })].filter(Boolean).join('\n\n'),
|
|
153
|
+
payload,
|
|
154
|
+
requestLanguage
|
|
148
155
|
);
|
|
149
156
|
return contextOutput(provider, eventName, context);
|
|
150
157
|
}
|
|
@@ -152,13 +159,14 @@ export async function handleHook(provider, explicitEvent) {
|
|
|
152
159
|
if (eventName === 'UserPromptSubmit') {
|
|
153
160
|
const prompt = promptText(payload);
|
|
154
161
|
await rememberQuestion(projectRoot, payload, prompt);
|
|
155
|
-
const guidance = formatDurableCaptureGuidance(prompt);
|
|
162
|
+
const guidance = formatDurableCaptureGuidance(prompt, { language: requestLanguage });
|
|
156
163
|
const recovery = await consumeCompactRecoveryContext(projectRoot, payload).catch(() => '');
|
|
157
164
|
const context = await hookContext(
|
|
158
165
|
projectRoot,
|
|
159
166
|
eventName,
|
|
160
|
-
[recovery, await buildContextBrief(projectRoot, eventName, prompt), guidance].filter(Boolean).join('\n\n'),
|
|
161
|
-
payload
|
|
167
|
+
[recovery, await buildContextBrief(projectRoot, eventName, prompt, { language: requestLanguage }), guidance].filter(Boolean).join('\n\n'),
|
|
168
|
+
payload,
|
|
169
|
+
requestLanguage
|
|
162
170
|
);
|
|
163
171
|
return contextOutput(provider, eventName, context);
|
|
164
172
|
}
|
package/src/language.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { homeDir, readJson, readText } from './fs-utils.js';
|
|
3
|
+
|
|
4
|
+
const HANGUL_RE = /[\u3131-\u318e\uac00-\ud7a3]/g;
|
|
5
|
+
const LATIN_RE = /[A-Za-z]/g;
|
|
6
|
+
|
|
7
|
+
export function normalizeLanguage(value) {
|
|
8
|
+
const text = String(value || '').trim().toLowerCase();
|
|
9
|
+
if (!text) return null;
|
|
10
|
+
if (/^(ko|kor|korean|한국어|한국|한글)$/.test(text)) return 'ko';
|
|
11
|
+
if (/^(en|eng|english|영어)$/.test(text)) return 'en';
|
|
12
|
+
if (text.includes('korean') || text.includes('한국')) return 'ko';
|
|
13
|
+
if (text.includes('english') || text.includes('영어')) return 'en';
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectTextLanguage(value) {
|
|
18
|
+
const text = String(value || '').normalize('NFC');
|
|
19
|
+
if (!text.trim()) return null;
|
|
20
|
+
const hangul = (text.match(HANGUL_RE) || []).length;
|
|
21
|
+
const latin = (text.match(LATIN_RE) || []).length;
|
|
22
|
+
if (hangul >= 2) return 'ko';
|
|
23
|
+
if (hangul === 1 && latin < 12) return 'ko';
|
|
24
|
+
if (latin >= 3 && hangul === 0) return 'en';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function claudeSettingsLanguage(projectRoot) {
|
|
29
|
+
const paths = [
|
|
30
|
+
join(homeDir(), '.claude', 'settings.json'),
|
|
31
|
+
join(projectRoot, '.claude', 'settings.json'),
|
|
32
|
+
join(projectRoot, '.claude', 'settings.local.json'),
|
|
33
|
+
];
|
|
34
|
+
let language = null;
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
const settings = await readJson(path, null);
|
|
37
|
+
const next = normalizeLanguage(settings?.language);
|
|
38
|
+
if (next) language = next;
|
|
39
|
+
}
|
|
40
|
+
return language;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function instructionFileLanguage(projectRoot) {
|
|
44
|
+
const paths = [
|
|
45
|
+
join(projectRoot, 'CLAUDE.md'),
|
|
46
|
+
join(projectRoot, '.claude', 'CLAUDE.md'),
|
|
47
|
+
join(projectRoot, 'AGENTS.md'),
|
|
48
|
+
join(projectRoot, 'llm-wiki', 'AGENTS.md'),
|
|
49
|
+
];
|
|
50
|
+
for (const path of paths) {
|
|
51
|
+
const text = await readText(path, '');
|
|
52
|
+
const explicit = text.match(/(?:preferred\s+language|response\s+language|language|언어|응답\s*언어)\s*[::-]\s*([^\n]+)/i);
|
|
53
|
+
const normalized = normalizeLanguage(explicit?.[1]);
|
|
54
|
+
if (normalized) return normalized;
|
|
55
|
+
const detected = detectTextLanguage(text.slice(0, 4000));
|
|
56
|
+
if (detected) return detected;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function resolveLanguage(projectRoot, options = {}) {
|
|
62
|
+
const promptLanguage = detectTextLanguage(options.prompt || '');
|
|
63
|
+
if (promptLanguage) return promptLanguage;
|
|
64
|
+
if (String(options.provider || '').toLowerCase() === 'claude') {
|
|
65
|
+
const settingsLanguage = await claudeSettingsLanguage(projectRoot);
|
|
66
|
+
if (settingsLanguage) return settingsLanguage;
|
|
67
|
+
}
|
|
68
|
+
return (await instructionFileLanguage(projectRoot)) || 'en';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function languageName(language) {
|
|
72
|
+
return language === 'ko' ? 'Korean' : 'English';
|
|
73
|
+
}
|
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 || 'Work/decision-focused live Q&A record. Merge reusable facts into existing durable wiki pages only when the user asks or the fact is clearly important.',
|
|
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
|
+
}
|