From c328fdf9e2fe59ad101b049301298ce9ff24caca Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 9 Apr 2026 14:54:38 +0800 Subject: [PATCH] feat: add wiki mvp commands (#532) --- src/commands.ts | 2 + src/commands/wiki/index.ts | 12 +++ src/commands/wiki/wiki.tsx | 123 ++++++++++++++++++++++++++ src/services/wiki/indexBuilder.ts | 68 +++++++++++++++ src/services/wiki/ingest.test.ts | 48 ++++++++++ src/services/wiki/ingest.ts | 93 ++++++++++++++++++++ src/services/wiki/init.test.ts | 54 ++++++++++++ src/services/wiki/init.ts | 140 ++++++++++++++++++++++++++++++ src/services/wiki/paths.ts | 18 ++++ src/services/wiki/status.test.ts | 55 ++++++++++++ src/services/wiki/status.ts | 82 +++++++++++++++++ src/services/wiki/types.ts | 33 +++++++ src/services/wiki/utils.ts | 36 ++++++++ 13 files changed, 764 insertions(+) create mode 100644 src/commands/wiki/index.ts create mode 100644 src/commands/wiki/wiki.tsx create mode 100644 src/services/wiki/indexBuilder.ts create mode 100644 src/services/wiki/ingest.test.ts create mode 100644 src/services/wiki/ingest.ts create mode 100644 src/services/wiki/init.test.ts create mode 100644 src/services/wiki/init.ts create mode 100644 src/services/wiki/paths.ts create mode 100644 src/services/wiki/status.test.ts create mode 100644 src/services/wiki/status.ts create mode 100644 src/services/wiki/types.ts create mode 100644 src/services/wiki/utils.ts diff --git a/src/commands.ts b/src/commands.ts index cba5bc2e..4f5a7a10 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -143,6 +143,7 @@ import heapDump from './commands/heapdump/index.js' import mockLimits from './commands/mock-limits/index.js' import bridgeKick from './commands/bridge-kick.js' import version from './commands/version.js' +import wiki from './commands/wiki/index.js' import summary from './commands/summary/index.js' import { resetLimits, @@ -324,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [ usage, usageReport, vim, + wiki, ...(webCmd ? [webCmd] : []), ...(forkCmd ? [forkCmd] : []), ...(buddy ? [buddy] : []), diff --git a/src/commands/wiki/index.ts b/src/commands/wiki/index.ts new file mode 100644 index 00000000..7f786575 --- /dev/null +++ b/src/commands/wiki/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const wiki = { + type: 'local-jsx', + name: 'wiki', + description: 'Initialize and inspect the OpenClaude project wiki', + argumentHint: '[init|status]', + immediate: true, + load: () => import('./wiki.js'), +} satisfies Command + +export default wiki diff --git a/src/commands/wiki/wiki.tsx b/src/commands/wiki/wiki.tsx new file mode 100644 index 00000000..2ccea01e --- /dev/null +++ b/src/commands/wiki/wiki.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js' +import { ingestLocalWikiSource } from '../../services/wiki/ingest.js' +import { initializeWiki } from '../../services/wiki/init.js' +import { getWikiStatus } from '../../services/wiki/status.js' +import type { + LocalJSXCommandCall, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { getCwd } from '../../utils/cwd.js' + +function renderHelp(): string { + return `Usage: /wiki [init|status|ingest ] + +Manage the OpenClaude project wiki stored in .openclaude/wiki. + +Commands: + /wiki init Initialize the wiki structure in the current project + /wiki status Show wiki status and page/source counts + /wiki ingest Ingest a local file into wiki sources + +Examples: + /wiki init + /wiki status + /wiki ingest README.md` +} + +function formatInitResult(result: Awaited>): string { + const lines = [`Initialized OpenClaude wiki at ${result.root}`] + + if (result.alreadyExisted) { + lines.push('', 'Wiki already existed. No new files were created.') + return lines.join('\n') + } + + if (result.createdFiles.length > 0) { + lines.push('', 'Created files:') + for (const file of result.createdFiles) { + lines.push(`- ${file}`) + } + } + + return lines.join('\n') +} + +function formatStatus(status: Awaited>): string { + if (!status.initialized) { + return `OpenClaude wiki is not initialized in this project.\n\nRun /wiki init to create ${status.root}.` + } + + return [ + 'OpenClaude wiki status', + '', + `Root: ${status.root}`, + `Pages: ${status.pageCount}`, + `Sources: ${status.sourceCount}`, + `Schema: ${status.hasSchema ? 'present' : 'missing'}`, + `Index: ${status.hasIndex ? 'present' : 'missing'}`, + `Log: ${status.hasLog ? 'present' : 'missing'}`, + `Last updated: ${status.lastUpdatedAt ?? 'unknown'}`, + ].join('\n') +} + +function formatIngestResult( + result: Awaited>, +): string { + return [ + `Ingested ${result.sourceFile} into the OpenClaude wiki.`, + '', + `Title: ${result.title}`, + `Source note: ${result.sourceNote}`, + `Summary: ${result.summary}`, + ].join('\n') +} + +async function runWikiCommand( + onDone: LocalJSXCommandOnDone, + args: string, +): Promise { + const cwd = getCwd() + const normalized = args.trim().toLowerCase() + + if (COMMON_HELP_ARGS.includes(normalized) || COMMON_INFO_ARGS.includes(normalized)) { + onDone(renderHelp(), { display: 'system' }) + return + } + + if (!normalized || normalized === 'status') { + onDone(formatStatus(await getWikiStatus(cwd)), { display: 'system' }) + return + } + + if (normalized === 'init') { + onDone(formatInitResult(await initializeWiki(cwd)), { display: 'system' }) + return + } + + if (normalized.startsWith('ingest')) { + const pathArg = args.trim().slice('ingest'.length).trim() + if (!pathArg) { + onDone('Usage: /wiki ingest ', { display: 'system' }) + return + } + + onDone(formatIngestResult(await ingestLocalWikiSource(cwd, pathArg)), { + display: 'system', + }) + return + } + + onDone(`Unknown wiki subcommand: ${args.trim()}\n\n${renderHelp()}`, { + display: 'system', + }) +} + +export const call: LocalJSXCommandCall = async ( + onDone, + _context, + args, +): Promise => { + await runWikiCommand(onDone, args ?? '') + return null +} diff --git a/src/services/wiki/indexBuilder.ts b/src/services/wiki/indexBuilder.ts new file mode 100644 index 00000000..84695a2c --- /dev/null +++ b/src/services/wiki/indexBuilder.ts @@ -0,0 +1,68 @@ +import { readdir, readFile, writeFile } from 'fs/promises' +import { basename, relative } from 'path' +import { getWikiPaths } from './paths.js' + +async function listMarkdownFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = `${dir}/${entry.name}` + if (entry.isDirectory()) { + files.push(...(await listMarkdownFiles(fullPath))) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(fullPath) + } + } + + return files.sort() +} + +async function getPageTitle(path: string): Promise { + const content = await readFile(path, 'utf8') + const titleLine = content + .split('\n') + .map(line => line.trim()) + .find(line => line.startsWith('# ')) + + return titleLine ? titleLine.replace(/^#\s+/, '') : basename(path, '.md') +} + +export async function rebuildWikiIndex(cwd: string): Promise { + const paths = getWikiPaths(cwd) + const pageFiles = await listMarkdownFiles(paths.pagesDir) + const sourceFiles = await listMarkdownFiles(paths.sourcesDir) + + const pageLinks = await Promise.all( + pageFiles.map(async file => { + const rel = relative(paths.root, file) + const title = await getPageTitle(file) + return `- [${title}](./${rel.replace(/\\/g, '/')})` + }), + ) + + const sourceLinks = sourceFiles.map(file => { + const rel = relative(paths.root, file).replace(/\\/g, '/') + const title = basename(file, '.md') + return `- [${title}](./${rel})` + }) + + const content = `# ${basename(cwd)} Wiki + +This wiki is maintained by OpenClaude as a durable project knowledge layer. + +## Core Pages + +${pageLinks.length > 0 ? pageLinks.join('\n') : '- No pages yet'} + +## Sources + +${sourceLinks.length > 0 ? sourceLinks.join('\n') : '- No sources yet'} + +## Recent Updates + +- See [log.md](./log.md) +` + + await writeFile(paths.indexFile, content, 'utf8') +} diff --git a/src/services/wiki/ingest.test.ts b/src/services/wiki/ingest.test.ts new file mode 100644 index 00000000..d7be2370 --- /dev/null +++ b/src/services/wiki/ingest.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { ingestLocalWikiSource } from './ingest.js' +import { getWikiPaths } from './paths.js' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) +}) + +async function makeProjectDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-ingest-')) + tempDirs.push(dir) + return dir +} + +test('ingestLocalWikiSource creates a source note and updates log/index', async () => { + const cwd = await makeProjectDir() + const sourcePath = join(cwd, 'notes.md') + await writeFile( + sourcePath, + '# Design Notes\n\nThis subsystem coordinates provider routing and session state.\nIt should be documented for future contributors.\n', + 'utf8', + ) + + const result = await ingestLocalWikiSource(cwd, 'notes.md') + const paths = getWikiPaths(cwd) + + expect(result.sourceFile).toBe('notes.md') + expect(result.title).toBe('Design Notes') + expect(result.sourceNote.startsWith('.openclaude/wiki/sources/')).toBe(true) + + const sourceNote = await readFile(join(cwd, result.sourceNote), 'utf8') + expect(sourceNote).toContain('# Design Notes') + expect(sourceNote).toContain('Path: `notes.md`') + + const log = await readFile(paths.logFile, 'utf8') + expect(log).toContain('Ingested `notes.md`') + + const index = await readFile(paths.indexFile, 'utf8') + expect(index).toContain('./sources/') + expect(index).toContain(result.sourceNote.replace('.openclaude/wiki/', './')) +}) diff --git a/src/services/wiki/ingest.ts b/src/services/wiki/ingest.ts new file mode 100644 index 00000000..c91ede7b --- /dev/null +++ b/src/services/wiki/ingest.ts @@ -0,0 +1,93 @@ +import { appendFile, readFile, stat, writeFile } from 'fs/promises' +import { basename, extname, isAbsolute, relative, resolve } from 'path' +import { initializeWiki } from './init.js' +import { rebuildWikiIndex } from './indexBuilder.js' +import { getWikiPaths } from './paths.js' +import type { WikiIngestResult } from './types.js' +import { + extractTitleFromText, + sanitizeWikiSlug, + summarizeText, +} from './utils.js' + +function buildSourceNote(params: { + title: string + sourcePath: string + ingestedAt: string + summary: string + excerpt: string +}): string { + const { title, sourcePath, ingestedAt, summary, excerpt } = params + + return `# ${title} + +## Source + +- Path: \`${sourcePath}\` +- Ingested at: ${ingestedAt} + +## Summary + +${summary} + +## Excerpt + +\`\`\` +${excerpt} +\`\`\` + +## Linked Pages + +- [Architecture](../pages/architecture.md) +` +} + +function buildLogEntry(sourcePath: string, title: string, ingestedAt: string): string { + return `- ${ingestedAt}: Ingested \`${sourcePath}\` into source note "${title}"` +} + +export async function ingestLocalWikiSource( + cwd: string, + rawPath: string, +): Promise { + await initializeWiki(cwd) + + const resolvedPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath) + const fileInfo = await stat(resolvedPath) + if (!fileInfo.isFile()) { + throw new Error(`Not a file: ${resolvedPath}`) + } + + const content = await readFile(resolvedPath, 'utf8') + const relSourcePath = relative(cwd, resolvedPath).replace(/\\/g, '/') + const ingestedAt = new Date().toISOString() + const baseName = basename(resolvedPath, extname(resolvedPath)) + const title = extractTitleFromText(baseName, content) + const summary = summarizeText(content) + const excerpt = content.split('\n').slice(0, 20).join('\n').trim() + const slug = sanitizeWikiSlug(`${baseName}-${Date.now()}`) || `source-${Date.now()}` + + const paths = getWikiPaths(cwd) + const sourceNotePath = `${paths.sourcesDir}/${slug}.md` + + await writeFile( + sourceNotePath, + buildSourceNote({ + title, + sourcePath: relSourcePath, + ingestedAt, + summary, + excerpt, + }), + 'utf8', + ) + await appendFile(paths.logFile, `${buildLogEntry(relSourcePath, title, ingestedAt)}\n`, 'utf8') + await rebuildWikiIndex(cwd) + + return { + sourceFile: relSourcePath, + sourceNote: relative(cwd, sourceNotePath).replace(/\\/g, '/'), + summary, + title, + } +} diff --git a/src/services/wiki/init.test.ts b/src/services/wiki/init.test.ts new file mode 100644 index 00000000..db767366 --- /dev/null +++ b/src/services/wiki/init.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { initializeWiki } from './init.js' +import { getWikiPaths } from './paths.js' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) +}) + +async function makeProjectDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-init-')) + tempDirs.push(dir) + return dir +} + +test('initializeWiki creates the expected wiki scaffold', async () => { + const cwd = await makeProjectDir() + const result = await initializeWiki(cwd) + const paths = getWikiPaths(cwd) + + expect(result.alreadyExisted).toBe(false) + expect(result.createdFiles).toEqual([ + '.openclaude/wiki/schema.md', + '.openclaude/wiki/index.md', + '.openclaude/wiki/log.md', + '.openclaude/wiki/pages/architecture.md', + ]) + expect(await readFile(paths.schemaFile, 'utf8')).toContain( + '# OpenClaude Wiki Schema', + ) + expect(await readFile(paths.indexFile, 'utf8')).toContain('Wiki') + expect(await readFile(paths.logFile, 'utf8')).toContain( + 'Wiki initialized by OpenClaude', + ) + expect(await readFile(join(paths.pagesDir, 'architecture.md'), 'utf8')).toContain( + '# Architecture', + ) +}) + +test('initializeWiki is idempotent and preserves existing files', async () => { + const cwd = await makeProjectDir() + + await initializeWiki(cwd) + const second = await initializeWiki(cwd) + + expect(second.alreadyExisted).toBe(true) + expect(second.createdFiles).toEqual([]) +}) diff --git a/src/services/wiki/init.ts b/src/services/wiki/init.ts new file mode 100644 index 00000000..965ca19b --- /dev/null +++ b/src/services/wiki/init.ts @@ -0,0 +1,140 @@ +import { mkdir, writeFile } from 'fs/promises' +import { basename, relative } from 'path' +import { getWikiPaths } from './paths.js' +import type { WikiInitResult } from './types.js' + +function buildSchemaTemplate(projectName: string): string { + return `# OpenClaude Wiki Schema + +This wiki stores durable, human-readable project knowledge for ${projectName}. + +## Goals + +- Keep useful project knowledge in markdown, not only in chat history +- Prefer synthesized facts over raw copy-paste +- Keep source attribution explicit +- Make pages easy for both humans and agents to update + +## Structure + +- \`index.md\`: top-level navigation and major topics +- \`log.md\`: append-only update log +- \`pages/\`: durable topic and architecture pages +- \`sources/\`: source ingestion notes and summaries + +## Page Rules + +- Keep pages focused on one topic +- Use stable headings such as: + - \`## Summary\` + - \`## Key Facts\` + - \`## Relationships\` + - \`## Open Questions\` + - \`## Sources\` +- Add or update facts only when they are grounded in project files or explicit source notes +- Prefer editing an existing page over creating duplicates +` +} + +function buildIndexTemplate(projectName: string): string { + return `# ${projectName} Wiki + +This wiki is maintained by OpenClaude as a durable project knowledge layer. + +## Core Pages + +- [Architecture](./pages/architecture.md) + +## Sources + +- Source notes live in [sources/](./sources/) + +## Recent Updates + +- See [log.md](./log.md) +` +} + +function buildLogTemplate(timestamp: string): string { + return `# Wiki Update Log + +- ${timestamp}: Wiki initialized by OpenClaude +` +} + +function buildArchitectureTemplate(projectName: string): string { + return `# Architecture + +## Summary + +High-level architecture notes for ${projectName}. + +## Key Facts + +- This page is the starting point for durable architecture knowledge. + +## Relationships + +- Link this page to major subsystems as the wiki grows. + +## Open Questions + +- What are the most important runtime subsystems? +- Which files best represent the system architecture? + +## Sources + +- Wiki bootstrap +` +} + +async function ensureFile( + filePath: string, + content: string, + createdFiles: string[], +): Promise { + try { + await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' }) + createdFiles.push(filePath) + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EEXIST' + ) { + return + } + throw error + } +} + +export async function initializeWiki(cwd: string): Promise { + const paths = getWikiPaths(cwd) + const createdDirectories: string[] = [] + const createdFiles: string[] = [] + + for (const dir of [paths.root, paths.pagesDir, paths.sourcesDir]) { + await mkdir(dir, { recursive: true }) + createdDirectories.push(dir) + } + + const projectName = basename(cwd) + const timestamp = new Date().toISOString() + + await ensureFile(paths.schemaFile, buildSchemaTemplate(projectName), createdFiles) + await ensureFile(paths.indexFile, buildIndexTemplate(projectName), createdFiles) + await ensureFile(paths.logFile, buildLogTemplate(timestamp), createdFiles) + await ensureFile( + `${paths.pagesDir}/architecture.md`, + buildArchitectureTemplate(projectName), + createdFiles, + ) + + return { + root: paths.root, + createdFiles: createdFiles.map(file => relative(cwd, file)), + createdDirectories: createdDirectories.map(dir => relative(cwd, dir)), + alreadyExisted: createdFiles.length === 0, + } +} diff --git a/src/services/wiki/paths.ts b/src/services/wiki/paths.ts new file mode 100644 index 00000000..4fade8f7 --- /dev/null +++ b/src/services/wiki/paths.ts @@ -0,0 +1,18 @@ +import { join } from 'path' +import type { WikiPaths } from './types.js' + +export const OPENCLAUDE_DIRNAME = '.openclaude' +export const WIKI_DIRNAME = 'wiki' + +export function getWikiPaths(cwd: string): WikiPaths { + const root = join(cwd, OPENCLAUDE_DIRNAME, WIKI_DIRNAME) + + return { + root, + pagesDir: join(root, 'pages'), + sourcesDir: join(root, 'sources'), + schemaFile: join(root, 'schema.md'), + indexFile: join(root, 'index.md'), + logFile: join(root, 'log.md'), + } +} diff --git a/src/services/wiki/status.test.ts b/src/services/wiki/status.test.ts new file mode 100644 index 00000000..c3ceaa3f --- /dev/null +++ b/src/services/wiki/status.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { initializeWiki } from './init.js' +import { getWikiPaths } from './paths.js' +import { getWikiStatus } from './status.js' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) +}) + +async function makeProjectDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-status-')) + tempDirs.push(dir) + return dir +} + +test('getWikiStatus reports uninitialized wiki state', async () => { + const cwd = await makeProjectDir() + const status = await getWikiStatus(cwd) + + expect(status.initialized).toBe(false) + expect(status.pageCount).toBe(0) + expect(status.sourceCount).toBe(0) + expect(status.lastUpdatedAt).toBeNull() +}) + +test('getWikiStatus counts pages and sources for initialized wiki', async () => { + const cwd = await makeProjectDir() + await initializeWiki(cwd) + const paths = getWikiPaths(cwd) + + await writeFile(join(paths.pagesDir, 'commands.md'), '# Commands\n', 'utf8') + await mkdir(join(paths.sourcesDir, 'external'), { recursive: true }) + await writeFile( + join(paths.sourcesDir, 'external', 'spec.md'), + '# Spec\n', + 'utf8', + ) + + const status = await getWikiStatus(cwd) + + expect(status.initialized).toBe(true) + expect(status.pageCount).toBe(2) + expect(status.sourceCount).toBe(1) + expect(status.hasSchema).toBe(true) + expect(status.hasIndex).toBe(true) + expect(status.hasLog).toBe(true) + expect(status.lastUpdatedAt).not.toBeNull() +}) diff --git a/src/services/wiki/status.ts b/src/services/wiki/status.ts new file mode 100644 index 00000000..5cb781f4 --- /dev/null +++ b/src/services/wiki/status.ts @@ -0,0 +1,82 @@ +import { readdir, stat } from 'fs/promises' +import { getWikiPaths } from './paths.js' +import type { WikiStatus } from './types.js' + +async function pathExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function listMarkdownFiles(dir: string): Promise { + if (!(await pathExists(dir))) { + return [] + } + + const entries = await readdir(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = `${dir}/${entry.name}` + if (entry.isDirectory()) { + files.push(...(await listMarkdownFiles(fullPath))) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(fullPath) + } + } + + return files +} + +async function getLastUpdatedAt(pathsToCheck: string[]): Promise { + const mtimes: number[] = [] + + for (const path of pathsToCheck) { + try { + const info = await stat(path) + mtimes.push(info.mtimeMs) + } catch { + continue + } + } + + if (mtimes.length === 0) { + return null + } + + return new Date(Math.max(...mtimes)).toISOString() +} + +export async function getWikiStatus(cwd: string): Promise { + const paths = getWikiPaths(cwd) + + const [hasRoot, hasSchema, hasIndex, hasLog, pages, sources] = + await Promise.all([ + pathExists(paths.root), + pathExists(paths.schemaFile), + pathExists(paths.indexFile), + pathExists(paths.logFile), + listMarkdownFiles(paths.pagesDir), + listMarkdownFiles(paths.sourcesDir), + ]) + + return { + initialized: hasRoot && hasSchema && hasIndex && hasLog, + root: paths.root, + pageCount: pages.length, + sourceCount: sources.length, + hasSchema, + hasIndex, + hasLog, + lastUpdatedAt: await getLastUpdatedAt([ + paths.schemaFile, + paths.indexFile, + paths.logFile, + ...pages, + ...sources, + ]), + } +} diff --git a/src/services/wiki/types.ts b/src/services/wiki/types.ts new file mode 100644 index 00000000..caa67d6b --- /dev/null +++ b/src/services/wiki/types.ts @@ -0,0 +1,33 @@ +export type WikiPaths = { + root: string + pagesDir: string + sourcesDir: string + schemaFile: string + indexFile: string + logFile: string +} + +export type WikiInitResult = { + root: string + createdFiles: string[] + createdDirectories: string[] + alreadyExisted: boolean +} + +export type WikiStatus = { + initialized: boolean + root: string + pageCount: number + sourceCount: number + hasSchema: boolean + hasIndex: boolean + hasLog: boolean + lastUpdatedAt: string | null +} + +export type WikiIngestResult = { + sourceFile: string + sourceNote: string + summary: string + title: string +} diff --git a/src/services/wiki/utils.ts b/src/services/wiki/utils.ts new file mode 100644 index 00000000..2717b56c --- /dev/null +++ b/src/services/wiki/utils.ts @@ -0,0 +1,36 @@ +export function sanitizeWikiSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-') +} + +export function summarizeText(input: string, maxLength = 280): string { + const normalized = input.replace(/\s+/g, ' ').trim() + if (!normalized) { + return 'No summary available.' + } + + if (normalized.length <= maxLength) { + return normalized + } + + return `${normalized.slice(0, maxLength - 1).trimEnd()}…` +} + +export function extractTitleFromText( + fallbackName: string, + content: string, +): string { + const firstNonEmptyLine = content + .split('\n') + .map(line => line.trim()) + .find(Boolean) + + if (!firstNonEmptyLine) { + return fallbackName + } + + return firstNonEmptyLine.replace(/^#+\s*/, '') || fallbackName +}