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.
@@ -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. They 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 durable candidates get a checkpoint plus queue item. 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.
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.10",
4
- "description": "Hook-first living LLM Wiki runtime for Codex and Claude Code.",
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",
@@ -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 = /(?:debug|bug|fix|failure|failed|error|root cause|architecture|runtime|hook|install|update|deploy|release|publish|migration|security|policy|procedure|decision|decided|verification|test|lint|build|doctor|디버깅|버그|오류|에러|실패|수정|원인|아키텍처|구조|런타임|훅|설치|업데이트|배포|릴리스|마이그레이션|보안|정책|절차|결정|선택|채택|확정|검증|테스트|문서|위키|wiki)/i;
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 longWork = work.length > 800;
111
- const durableKeyword = hasDurableKeyword(text);
112
- const simple =
113
- !hasWork &&
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: 'simple',
123
- archive: false,
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 (durableKeyword) {
129
+ if (durableConclusion) {
130
130
  return {
131
- kind: 'suggest-durable',
131
+ kind: 'decision',
132
132
  archive: true,
133
- suggestDurable: true,
133
+ suggestDurable: false,
134
134
  queueIfMissingDurable: false,
135
135
  };
136
136
  }
137
137
 
138
138
  return {
139
- kind: hasWork || hasFiles || hasVerification || longWork || result.length > 800 ? 'archive-only' : 'simple',
140
- archive: hasWork || hasFiles || hasVerification || longWork || result.length > 800,
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
- '- 현재 사용자 요청을 먼저 처리하되, 사용자가 기록/문서화를 요청했으므로 기존 durable wiki 문서를 찾아 갱신한다.',
153
- '- 문서는 관련 기존 문서가 없을 때만 만들고, credentials/private data는 저장하지 않는다.',
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 ? [`- 권장 실행: ${commandForProject('update', value.workspace)}`] : []),
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 ? [`- 권장 실행: ${commandForProject('update', value.workspace)}`] : []),
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
- ? ' turn 저장할 만한 내용이 있어 보입니다. 사용자가 승인하면 기존 durable wiki 문서에 합친다.'
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
  }
@@ -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
+ }