@jerryjiao/knowflow 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,12 +5,37 @@ All notable changes to KnowFlow are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-08-27
9
+
8
10
  ### Added
9
11
 
12
+ - `knowflow index build [--incremental] [--stats]` and `knowflow index stats [--verbose] [--json]`
13
+ expose the vector index as a first-class CLI surface — no more running
14
+ `scripts/vector-store.mjs` by hand to build or inspect the semantic index.
15
+ - `knowflow ask <question>` answers questions from retrieved Wiki context with
16
+ a citation list, promoted from the vector-store script to the CLI.
17
+ - Pluggable embedding providers via the optional `embedding` section of
18
+ `.knowflowrc`. Built-in presets: `zhipu` (default, unchanged behavior) and
19
+ `openai`; `custom` points at any OpenAI-compatible endpoint
20
+ (`baseUrl`, `model`, `apiKeyEnv`, `dims`, `chatModel` all overridable).
21
+ `knowflow status` now shows the resolved provider and required key env;
22
+ malformed `embedding` sections fail fast naming the offending field.
10
23
  - Bilingual documentation website (Astro + Starlight) under `site/`, deployed to
11
24
  GitHub Pages via GitHub Actions. English landing at `/`, Chinese at `/zh/`,
12
25
  docs under `/en/` and `/zh/` with browser-language auto-switch.
13
26
 
27
+ ### Changed
28
+
29
+ - API-key checks for `query` (and the new index/ask commands) honor the
30
+ configured provider's key env instead of assuming `ZHIPUAI_API_KEY`.
31
+ - Version assertions in the CLI test suite read `package.json` instead of a
32
+ hardcoded string.
33
+
34
+ ### Removed
35
+
36
+ - Dead `scripts/wiki-health.py` and `scripts/vector_store.py` superseded by the
37
+ shell/Node implementations the CLI already invokes.
38
+
14
39
  ## [0.3.0] - 2026-08-18
15
40
 
16
41
  ### Added
@@ -36,8 +61,7 @@ All notable changes to KnowFlow are documented here. This project follows
36
61
 
37
62
  ### Changed
38
63
 
39
- - Both READMEs make the source checkout the primary install path until the
40
- npm package is published, instead of an `npx` command that 404s.
64
+ - Both READMEs use `npm install -g @jerryjiao/knowflow` as the primary install path, with the source checkout as an alternative.
41
65
 
42
66
  ## [0.2.1] - 2026-08-10
43
67
 
package/README.md CHANGED
@@ -79,7 +79,10 @@ KnowFlow is inspired by [Andrej Karpathy's LLM Wiki](https://karpathy.github.io/
79
79
  | `knowflow health` | Check broken links, small files, and isolated pages |
80
80
  | `knowflow tags` | Build `tag/<name>.md` hub pages from `[[tag/<name>]]` links |
81
81
  | `knowflow status` | Show raw, Wiki, graph, vector-index, and API-key status |
82
- | `knowflow query <text>` | Query an existing vector index |
82
+ | `knowflow index build [--incremental]` | Build the vector index for semantic search (incremental skips unchanged pages) |
83
+ | `knowflow index stats [--verbose] [--json]` | Report index coverage, cache, and token estimates |
84
+ | `knowflow query <text>` | Query an existing vector index (hybrid vector + keyword ranking) |
85
+ | `knowflow ask <question>` | Answer a question from retrieved Wiki context, with citations |
83
86
 
84
87
  KnowFlow searches upward from the current directory for the nearest `.knowflowrc`, so commands also work inside project subdirectories.
85
88
 
@@ -133,19 +136,39 @@ my-wiki/
133
136
 
134
137
  `health.excludeOrphanDirs` lists directories whose pages are expected to be unreferenced (daily-sync feeds, inboxes) and should not count as isolated pages. `knowflow tags` regenerates every hub page under `wiki/tag/`, so re-running it after new tagged pages arrive is safe and idempotent.
135
138
 
136
- Graph generation, health checks, capture, and status do not require an API key. Semantic search requires `ZHIPUAI_API_KEY` in the project environment or a project-root `.env` file:
139
+ Graph generation, health checks, capture, and status do not require an API key. Semantic search (`index build`, `query`, `ask`) needs an embedding-provider key in the project environment or a project-root `.env` file. The default provider is Zhipu:
137
140
 
138
141
  ```bash
139
142
  ZHIPUAI_API_KEY=your-key-here
140
143
  ```
141
144
 
142
- The current CLI can query an existing vector index but does not yet expose a standalone `index` command. See [Current limitations](#current-limitations) before relying on search.
145
+ The embedding provider is pluggable via the optional `embedding` section of `.knowflowrc`. Switch to OpenAI, or point at any OpenAI-compatible endpoint:
146
+
147
+ ```json
148
+ {
149
+ "embedding": { "provider": "openai" }
150
+ }
151
+ ```
152
+
153
+ ```json
154
+ {
155
+ "embedding": {
156
+ "provider": "custom",
157
+ "baseUrl": "https://your-relay.example.com/v1",
158
+ "model": "your-embedding-model",
159
+ "apiKeyEnv": "RELAY_API_KEY",
160
+ "chatModel": "your-chat-model"
161
+ }
162
+ }
163
+ ```
164
+
165
+ Presets (`zhipu`, `openai`) fill in endpoint, model, and key-env defaults; every field can be overridden individually. `chatModel` backs `knowflow ask`. Malformed `embedding` sections fail fast with the offending field named.
143
166
 
144
167
  ## Current limitations
145
168
 
146
169
  - `ingest` captures raw material; structured Wiki synthesis is a separate human or agent step.
147
170
  - URL capture uses Jina Reader. YouTube and some logged-in platforms may also require `yt-dlp` or an authenticated browser workflow.
148
- - `query` needs a prebuilt vector index and a [Zhipu AI API key](https://open.bigmodel.cn/). Until an `index` CLI command is added, advanced users can run `node <knowflow-install>/scripts/vector-store.mjs build`.
171
+ - `query` / `ask` need a vector index built with `knowflow index build` and a [Zhipu AI](https://open.bigmodel.cn/) (default) or other embedding-provider API key.
149
172
  - `bookmark_sync.sh` depends on the optional third-party `ft` command.
150
173
  - Generated graph HTML loads vis-network from a CDN when opened.
151
174
 
@@ -154,9 +177,9 @@ The current CLI can query an existing vector index but does not yet expose a sta
154
177
  - [x] Standalone project initialization and portable paths
155
178
  - [x] Raw URL/text capture, Wiki health checks, and interactive graphs
156
179
  - [x] CLI tests and CI across supported Node.js versions
180
+ - [x] `knowflow index` and pluggable embedding providers
157
181
  - [ ] A first-class agent workflow from raw capture to reviewed Wiki pages
158
182
  - [ ] Incremental ingestion and duplicate-source detection
159
- - [ ] `knowflow index` and pluggable embedding providers
160
183
  - [ ] Extractor/plugin system and a local Web UI
161
184
 
162
185
  Ideas and focused pull requests are welcome. Start with the [contribution guide](CONTRIBUTING.md), run the [text-to-graph example](examples/quickstart.md), or propose a use case in [GitHub Issues](https://github.com/jerryjiao/knowflow/issues).
package/README.zh-CN.md CHANGED
@@ -78,7 +78,10 @@ KnowFlow 的灵感来自 [Andrej Karpathy 的 LLM Wiki](https://karpathy.github.
78
78
  | `knowflow health` | 检查断链、小文件和孤立页面 |
79
79
  | `knowflow tags` | 根据 `[[tag/<名称>]]` 链接生成 `tag/<名称>.md` 聚合页 |
80
80
  | `knowflow status` | 显示原始素材、Wiki、图谱、向量索引和 API Key 状态 |
81
- | `knowflow query <text>` | 查询已有向量索引 |
81
+ | `knowflow index build [--incremental]` | 构建语义检索的向量索引(`--incremental` 跳过未变化页面) |
82
+ | `knowflow index stats [--verbose] [--json]` | 查看索引覆盖率、缓存与 token 估算 |
83
+ | `knowflow query <text>` | 查询已有向量索引(向量 + 关键词混合排序) |
84
+ | `knowflow ask <question>` | 基于检索到的 Wiki 上下文回答问题,附引用来源 |
82
85
 
83
86
  KnowFlow 会从当前目录向上查找最近的 `.knowflowrc`,因此也可以在项目子目录中运行命令。
84
87
 
@@ -132,19 +135,39 @@ my-wiki/
132
135
 
133
136
  `health.excludeOrphanDirs` 列出预期不会被引用的目录(每日同步流水页、收件箱等),其中的页面不计入孤立页面。`knowflow tags` 每次都会全量重建 `wiki/tag/` 下的聚合页,新增带标签页面后重跑即可,幂等安全。
134
137
 
135
- 图谱生成、健康检查、内容采集和状态查看均不需要 API Key。语义检索需要在项目环境变量或项目根目录 `.env` 中设置 `ZHIPUAI_API_KEY`:
138
+ 图谱生成、健康检查、内容采集和状态查看均不需要 API Key。语义检索(`index build`、`query`、`ask`)需要在项目环境变量或项目根目录 `.env` 中配置 embedding 提供商的 Key,默认提供商为智谱:
136
139
 
137
140
  ```bash
138
141
  ZHIPUAI_API_KEY=your-key-here
139
142
  ```
140
143
 
141
- 当前 CLI 可以查询已有向量索引,但尚未提供独立的 `index` 命令。依赖搜索功能前,请先阅读[当前限制](#当前限制)。
144
+ Embedding 提供商可通过 `.knowflowrc` 的可选 `embedding` 节插拔。切换到 OpenAI,或指向任意 OpenAI 兼容端点:
145
+
146
+ ```json
147
+ {
148
+ "embedding": { "provider": "openai" }
149
+ }
150
+ ```
151
+
152
+ ```json
153
+ {
154
+ "embedding": {
155
+ "provider": "custom",
156
+ "baseUrl": "https://your-relay.example.com/v1",
157
+ "model": "your-embedding-model",
158
+ "apiKeyEnv": "RELAY_API_KEY",
159
+ "chatModel": "your-chat-model"
160
+ }
161
+ }
162
+ ```
163
+
164
+ 内置预设(`zhipu`、`openai`)会填好端点、模型与 Key 环境变量名,所有字段均可单独覆盖;`chatModel` 供 `knowflow ask` 使用。`embedding` 节写错时会立刻报错并指出问题字段。
142
165
 
143
166
  ## 当前限制
144
167
 
145
168
  - `ingest` 只采集原始素材;将其提炼为结构化 Wiki 是独立的人工或 Agent 步骤。
146
169
  - URL 采集依赖 Jina Reader;YouTube 和部分需要登录的平台可能还需要 `yt-dlp` 或带登录态的浏览器工作流。
147
- - `query` 需要预先构建的向量索引和[智谱 AI API Key](https://open.bigmodel.cn/)。在加入 `index` 命令前,高级用户可运行 `node <knowflow-install>/scripts/vector-store.mjs build`。
170
+ - `query` / `ask` 需要先运行 `knowflow index build` 构建向量索引,并配置[智谱 AI](https://open.bigmodel.cn/)(默认)或其他 embedding 提供商的 API Key。
148
171
  - `bookmark_sync.sh` 依赖可选的第三方 `ft` 命令。
149
172
  - 生成的图谱 HTML 打开时会从 CDN 加载 vis-network。
150
173
 
@@ -153,9 +176,9 @@ ZHIPUAI_API_KEY=your-key-here
153
176
  - [x] 独立项目初始化和可移植路径
154
177
  - [x] URL / 文本原始采集、Wiki 健康检查和交互式图谱
155
178
  - [x] CLI 自动化测试与多 Node.js 版本 CI
179
+ - [x] `knowflow index` 与可插拔嵌入提供商
156
180
  - [ ] 从原始素材到已审核 Wiki 页面的一等 Agent 工作流
157
181
  - [ ] 增量采集和重复来源检测
158
- - [ ] `knowflow index` 与可插拔嵌入提供商
159
182
  - [ ] 提取器 / 插件系统和本地 Web UI
160
183
 
161
184
  欢迎提交想法和范围清晰的 PR。你可以先阅读[贡献指南](CONTRIBUTING.md)、运行[从文本到图谱的示例](examples/quickstart.md),或在 [GitHub Issues](https://github.com/jerryjiao/knowflow/issues) 中提出使用场景。
package/bin/knowflow.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from 'node:fs';
15
15
  import { dirname, join, parse, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { resolveEmbeddingConfig } from '../scripts/embedding-config.mjs';
17
18
 
18
19
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const SCRIPTS = join(PACKAGE_ROOT, 'scripts');
@@ -51,6 +52,7 @@ function loadProject(start = process.cwd()) {
51
52
  wiki: { ...DEFAULT_CONFIG.wiki, ...userConfig.wiki },
52
53
  graph: { ...DEFAULT_CONFIG.graph, ...userConfig.graph },
53
54
  health: { ...DEFAULT_CONFIG.health, ...userConfig.health },
55
+ embedding: userConfig.embedding ?? {},
54
56
  };
55
57
  const pathValue = (value, label) => {
56
58
  if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} 必须是非空路径`);
@@ -120,17 +122,38 @@ function countLines(dir) {
120
122
  return lines;
121
123
  }
122
124
 
123
- function hasApiKey(project) {
124
- if (process.env.ZHIPUAI_API_KEY) return true;
125
+ function hasApiKey(project, apiKeyEnv = 'ZHIPUAI_API_KEY') {
126
+ if (process.env[apiKeyEnv]) return true;
125
127
  try {
126
128
  return readFileSync(join(project.root, '.env'), 'utf8')
127
129
  .split(/\r?\n/)
128
- .some(line => /^\s*ZHIPUAI_API_KEY\s*=\s*[^$\s]/.test(line));
130
+ .some(line => line.startsWith(apiKeyEnv) && /^\s*=\s*[^$\s]/.test(line.slice(apiKeyEnv.length)));
129
131
  } catch {
130
132
  return false;
131
133
  }
132
134
  }
133
135
 
136
+ // Resolves the embedding provider from project config; returns null (and sets
137
+ // the exit code) when the user's `embedding` section is malformed.
138
+ function embeddingFor(project) {
139
+ try {
140
+ return resolveEmbeddingConfig(project.config.embedding);
141
+ } catch (error) {
142
+ console.error(chalk.red('❌ embedding 配置错误:'), error.message);
143
+ process.exitCode = 1;
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function requireApiKey(project, embedding, action) {
149
+ if (!hasApiKey(project, embedding.apiKeyEnv)) {
150
+ console.error(chalk.yellow(`⚠️ 未检测到 ${embedding.apiKeyEnv}(embedding.provider=${embedding.provider}),请在项目 .env 或环境变量中配置后再${action}。`));
151
+ process.exitCode = 1;
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+
134
157
  function initialise(directory) {
135
158
  const root = resolve(directory);
136
159
  mkdirSync(root, { recursive: true });
@@ -221,11 +244,9 @@ program
221
244
  }, 5)
222
245
  .action((text, opts) => {
223
246
  const project = loadProject();
224
- if (!hasApiKey(project)) {
225
- console.error(chalk.yellow('⚠️ 未检测到 ZHIPUAI_API_KEY,请在项目 .env 或环境变量中配置。'));
226
- process.exitCode = 1;
227
- return;
228
- }
247
+ const embedding = embeddingFor(project);
248
+ if (!embedding) return;
249
+ if (!requireApiKey(project, embedding, '检索')) return;
229
250
  try {
230
251
  run(process.execPath, [join(SCRIPTS, 'vector-store.mjs'), 'query', text, '--top', String(opts.top)], {
231
252
  cwd: project.root,
@@ -306,6 +327,79 @@ program
306
327
  }
307
328
  });
308
329
 
330
+ const index = program
331
+ .command('index')
332
+ .description('向量索引管理(语义检索的数据底座,provider 可在 .knowflowrc 的 embedding 节配置)')
333
+ .action(() => index.help());
334
+
335
+ index
336
+ .command('build')
337
+ .description('构建向量索引(默认全量;--incremental 只处理新增/修改文件)')
338
+ .option('-i, --incremental', '增量构建,跳过未变化文件')
339
+ .option('--stats', '构建完成后显示索引统计')
340
+ .action(opts => {
341
+ const project = loadProject();
342
+ const embedding = embeddingFor(project);
343
+ if (!embedding) return;
344
+ if (!requireApiKey(project, embedding, '构建索引')) return;
345
+ console.log(chalk.blue('🧱 构建向量索引...'));
346
+ try {
347
+ run(process.execPath, [
348
+ join(SCRIPTS, 'vector-store.mjs'), 'build',
349
+ ...(opts.incremental ? ['--incremental'] : []),
350
+ ...(opts.stats ? ['--stats'] : []),
351
+ ], {
352
+ cwd: project.root,
353
+ env: projectEnv(project),
354
+ });
355
+ console.log(chalk.green('✅ 索引构建完成!'));
356
+ } catch (error) {
357
+ console.error(chalk.red('❌ 索引构建失败:'), error.message);
358
+ process.exitCode = 1;
359
+ }
360
+ });
361
+
362
+ index
363
+ .command('stats')
364
+ .description('查看索引统计(页数 / 向量覆盖率 / 缓存 / token 估算)')
365
+ .option('-v, --verbose', '显示 per-file 明细表')
366
+ .option('--json', 'JSON 结构化输出')
367
+ .action(opts => {
368
+ const project = loadProject();
369
+ try {
370
+ run(process.execPath, [
371
+ join(SCRIPTS, 'vector-store.mjs'), 'stats',
372
+ ...(opts.verbose ? ['--verbose'] : []),
373
+ ...(opts.json ? ['--json'] : []),
374
+ ], {
375
+ cwd: project.root,
376
+ env: projectEnv(project),
377
+ });
378
+ } catch (error) {
379
+ console.error(chalk.red('❌ 统计失败:'), error.message);
380
+ process.exitCode = 1;
381
+ }
382
+ });
383
+
384
+ program
385
+ .command('ask <question>')
386
+ .description('基于知识库检索上下文回答问题(附引用来源,需已构建索引)')
387
+ .action(question => {
388
+ const project = loadProject();
389
+ const embedding = embeddingFor(project);
390
+ if (!embedding) return;
391
+ if (!requireApiKey(project, embedding, '提问')) return;
392
+ try {
393
+ run(process.execPath, [join(SCRIPTS, 'vector-store.mjs'), 'ask', question], {
394
+ cwd: project.root,
395
+ env: projectEnv(project),
396
+ });
397
+ } catch (error) {
398
+ console.error(chalk.red('❌ 问答失败:'), error.message);
399
+ process.exitCode = 1;
400
+ }
401
+ });
402
+
309
403
  program
310
404
  .command('health')
311
405
  .description('Wiki 健康检查(断链、空文件、孤立页面)')
@@ -350,13 +444,18 @@ program
350
444
  vectorStatus = chalk.green(`✅ ${pages.length} 页,${embedded} 页已向量化`);
351
445
  } catch { vectorStatus = chalk.yellow('⚠️ 索引损坏'); }
352
446
  }
447
+ const embedding = embeddingFor(project);
448
+ const keyStatus = embedding
449
+ ? (hasApiKey(project, embedding.apiKeyEnv) ? `✅ 已配置 (${embedding.apiKeyEnv})` : `❌ 未配置(需要 ${embedding.apiKeyEnv})`)
450
+ : '❌ embedding 配置错误';
353
451
  console.log(`\n${chalk.bold.cyan(' 📊 KnowFlow 状态概览')}`);
354
452
  console.log(` Wiki 文章数: ${articleCount} 篇`);
355
453
  console.log(` 总行数: ${lineCount} 行`);
356
454
  console.log(` 原始素材: ${rawCount} 个`);
357
455
  console.log(` 向量索引: ${vectorStatus}`);
456
+ console.log(` Embedding: ${embedding ? `${embedding.provider} · ${embedding.model}` : '-'}`);
358
457
  console.log(` 图谱: ${nodes} 个节点 / ${edges} 条关系`);
359
- console.log(` API Key: ${hasApiKey(project) ? '✅ 已配置' : '❌ 未配置'}`);
458
+ console.log(` API Key: ${keyStatus}`);
360
459
  console.log(chalk.dim(` 项目目录: ${project.root}`));
361
460
  console.log(chalk.dim(` Wiki 目录: ${project.wikiDir}\n`));
362
461
  });
@@ -32,8 +32,8 @@
32
32
  │ Step 4: Graph │ │ Step 5: Vector │
33
33
  │ 知识图谱构建 │ │ 向量索引构建 │
34
34
  │ scripts/graph_ │ │ scripts/vector-store.mjs │
35
- │ builder.py │ │ scripts/vector_store.py
36
- │ → graph.json │ │ → vector-store.mjs-data/
35
+ │ builder.py │ │ (provider 可插拔,默认智谱)
36
+ │ → graph.json │ │ → .vector-index.json
37
37
  │ → graph.html │ │ │
38
38
  └─────────────────────┘ └─────────────────────────────────┘
39
39
  ```
@@ -50,8 +50,8 @@ knowflow/
50
50
  │ ├── enrich-wiki.js # Wiki 后处理(链接补全、摘要生成)
51
51
  │ ├── graph_builder.py # 从 Wiki 页面构建知识图谱
52
52
  │ ├── graph_relation_labeler.py # LLM 分析关系类型
53
- │ ├── vector-store.mjs # 向量索引(Embedding + 存储)
54
- │ ├── vector_store.py # 向量检索接口
53
+ │ ├── vector-store.mjs # 向量索引(Embedding + 存储,provider 可插拔)
54
+ │ ├── embedding-config.mjs # embedding 提供商解析(zhipu/openai/custom)
55
55
  │ ├── pipeline.sh # 5 步全自动化管线
56
56
  │ ├── bookmark_sync.sh # X/Twitter 书签同步
57
57
  │ ├── wechat_sync.sh # 微信公众号文章同步
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryjiao/knowflow",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-native Markdown workspace for raw capture, linked wikis, knowledge graphs, and semantic search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "scripts": {
24
24
  "knowflow": "node bin/knowflow.js",
25
25
  "test": "node --test",
26
- "check": "node --check bin/knowflow.js && node --check scripts/batch-ingest.cjs && node --check scripts/enrich-wiki.js && node --check scripts/vector-store.mjs"
26
+ "check": "node --check bin/knowflow.js && node --check scripts/batch-ingest.cjs && node --check scripts/enrich-wiki.js && node --check scripts/vector-store.mjs && node --check scripts/embedding-config.mjs"
27
27
  },
28
28
  "repository": {
29
29
  "type": "git",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pluggable embedding provider configuration for KnowFlow.
3
+ *
4
+ * Reads the optional `embedding` section of .knowflowrc and resolves it into
5
+ * a concrete endpoint description. Zero side effects — safe to import from
6
+ * tests and the CLI.
7
+ *
8
+ * Resolved shape: { provider, baseUrl, model, dims, apiKeyEnv, chatModel }
9
+ * - dims: number sent as `dimensions`, or null to use the model default
10
+ * - chatModel: model used by `knowflow ask`, or null when not applicable
11
+ */
12
+
13
+ export const EMBEDDING_PRESETS = {
14
+ zhipu: {
15
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
16
+ model: 'embedding-3',
17
+ dims: 1024,
18
+ apiKeyEnv: 'ZHIPUAI_API_KEY',
19
+ chatModel: 'glm-4-flash',
20
+ },
21
+ openai: {
22
+ baseUrl: 'https://api.openai.com/v1',
23
+ model: 'text-embedding-3-small',
24
+ dims: null,
25
+ apiKeyEnv: 'OPENAI_API_KEY',
26
+ chatModel: 'gpt-4o-mini',
27
+ },
28
+ };
29
+
30
+ const DEFAULT_PROVIDER = 'zhipu';
31
+ const CUSTOM_API_KEY_ENV = 'EMBEDDING_API_KEY';
32
+
33
+ export function resolveEmbeddingConfig(userConfig = {}) {
34
+ const provider = userConfig.provider ?? DEFAULT_PROVIDER;
35
+
36
+ if (provider === 'custom') {
37
+ if (!userConfig.baseUrl) throw new Error("embedding.provider 'custom' requires embedding.baseUrl");
38
+ if (!userConfig.model) throw new Error("embedding.provider 'custom' requires embedding.model");
39
+ } else if (!EMBEDDING_PRESETS[provider]) {
40
+ const known = [...Object.keys(EMBEDDING_PRESETS), 'custom'].join(', ');
41
+ throw new Error(`Unknown embedding provider "${provider}". Available: ${known}`);
42
+ }
43
+
44
+ const preset = EMBEDDING_PRESETS[provider] ?? {};
45
+ const dims = userConfig.dims !== undefined ? userConfig.dims : preset.dims ?? null;
46
+ if (dims !== null && (!Number.isInteger(dims) || dims <= 0)) {
47
+ throw new Error('embedding.dims must be a positive integer or null');
48
+ }
49
+
50
+ return {
51
+ provider,
52
+ baseUrl: stripTrailingSlash(userConfig.baseUrl ?? preset.baseUrl),
53
+ model: userConfig.model ?? preset.model,
54
+ dims,
55
+ apiKeyEnv: userConfig.apiKeyEnv ?? preset.apiKeyEnv ?? CUSTOM_API_KEY_ENV,
56
+ chatModel: userConfig.chatModel ?? preset.chatModel ?? null,
57
+ };
58
+ }
59
+
60
+ function stripTrailingSlash(url) {
61
+ return typeof url === 'string' ? url.replace(/\/+$/, '') : url;
62
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * KnowFlow Vector Store v2.1
4
- * 智谱 embedding-3 向量检索引擎
3
+ * KnowFlow Vector Store v2.2
4
+ * 向量检索引擎(embedding provider 可插拔,默认智谱 embedding-3;.knowflowrc 的 embedding 节可切换 openai / 自定义 OpenAI 兼容端点)
5
5
  *
6
6
  * 用法:
7
7
  * node vector-store.mjs build 全量构建索引
@@ -21,6 +21,7 @@
21
21
  import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'fs';
22
22
  import { dirname, join, relative, extname, resolve } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
+ import { resolveEmbeddingConfig } from './embedding-config.mjs';
24
25
 
25
26
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26
27
  const PROJECT_ROOT = resolve(process.env.KNOWFLOW_ROOT || PACKAGE_ROOT);
@@ -28,11 +29,27 @@ const WIKI_DIR = resolve(process.env.KNOWFLOW_WIKI_DIR || join(PROJECT_ROOT, 'wi
28
29
  const INDEX_FILE = join(WIKI_DIR, '.vector-index.json');
29
30
  const CACHE_FILE = join(WIKI_DIR, '.embed-cache.json');
30
31
  const MANIFEST_FILE = join(WIKI_DIR, '.vector-manifest.json'); // 增量用:记录文件 mtime
31
- const API_URL = 'https://open.bigmodel.cn/api/paas/v4/embeddings';
32
- const MODEL = 'embedding-3';
33
- const CHAT_API_URL = 'https://open.bigmodel.cn/api/paas/v4/chat/completions';
34
- const CHAT_MODEL = 'glm-4-flash';
35
- const DIMS = 1024;
32
+
33
+ // Pluggable embedding provider: `embedding` section of .knowflowrc, resolved
34
+ // against built-in presets (zhipu default, openai) or a custom OpenAI-compatible endpoint.
35
+ function loadUserEmbeddingConfig() {
36
+ const rcPath = join(PROJECT_ROOT, '.knowflowrc');
37
+ if (!existsSync(rcPath)) return {};
38
+ try {
39
+ return JSON.parse(readFileSync(rcPath, 'utf8')).embedding ?? {};
40
+ } catch (error) {
41
+ console.error(`⚠️ 无法解析 ${rcPath} 的 embedding 配置(${error.message}),使用默认 provider zhipu`);
42
+ return {};
43
+ }
44
+ }
45
+
46
+ let EMBEDDING;
47
+ try {
48
+ EMBEDDING = resolveEmbeddingConfig(loadUserEmbeddingConfig());
49
+ } catch (error) {
50
+ console.error(`❌ ${error.message}`);
51
+ process.exit(1);
52
+ }
36
53
  const BATCH_SIZE = 20;
37
54
  const MIN_SIZE = 300; // 最小文件大小阈值
38
55
  const QUERY_CACHE_TTL = 60_000; // 查询缓存 TTL: 60 秒
@@ -58,14 +75,14 @@ if (existsSync(envPath)) {
58
75
  if (k?.trim() && !process.env[k.trim()]) process.env[k.trim()] = v.join('=').trim();
59
76
  }
60
77
  }
61
- const API_KEY = process.env.ZHIPUAI_API_KEY || '';
62
-
63
- if (!API_KEY) {
64
- console.error('❌ Error: ZHIPUAI_API_KEY environment variable is required');
65
- console.error(' export ZHIPUAI_API_KEY="your-key-here"');
66
- // Don't exit for stats/help commands, only block LLM calls
67
- const isLlmCommand = process.argv[2] === 'ask' || process.argv[2] === 'query';
68
- if (isLlmCommand) process.exit(1);
78
+ const API_KEY = process.env[EMBEDDING.apiKeyEnv] || '';
79
+
80
+ // LLM 命令(build/query/ask)缺 key 时立即报错退出;纯本地命令(stats)不受影响。
81
+ // 通过 knowflow CLI 跑时,门面层已按 provider 前置拦截,这里是直跑脚本的兜底。
82
+ if (!API_KEY && ['build', 'query', 'search', 'ask'].includes(process.argv[2])) {
83
+ console.error(`❌ Error: ${EMBEDDING.apiKeyEnv} environment variable is required (embedding.provider=${EMBEDDING.provider})`);
84
+ console.error(` export ${EMBEDDING.apiKeyEnv}="your-key-here"`);
85
+ process.exit(1);
69
86
  }
70
87
 
71
88
  // ─── 工具函数 ───
@@ -120,13 +137,15 @@ function formatDuration(ms) {
120
137
  // ─── Embedding API ───
121
138
 
122
139
  async function getEmbedding(texts) {
123
- const res = await fetch(API_URL, {
140
+ const body = { model: EMBEDDING.model, input: texts };
141
+ if (EMBEDDING.dims) body.dimensions = EMBEDDING.dims;
142
+ const res = await fetch(`${EMBEDDING.baseUrl}/embeddings`, {
124
143
  method: 'POST',
125
144
  headers: {
126
145
  'Content-Type': 'application/json',
127
146
  'Authorization': `Bearer ${API_KEY}`
128
147
  },
129
- body: JSON.stringify({ model: MODEL, input: texts, dimensions: DIMS })
148
+ body: JSON.stringify(body)
130
149
  });
131
150
  if (!res.ok) {
132
151
  const err = await res.text();
@@ -150,7 +169,7 @@ function saveManifest(manifest) {
150
169
  // ─── Build 索引 ───
151
170
 
152
171
  async function buildIndex(incremental = false) {
153
- if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量');
172
+ if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量`);
154
173
 
155
174
  log('📂 扫描 wiki 目录...');
156
175
  const files = getAllMdFiles(WIKI_DIR);
@@ -254,7 +273,7 @@ function keywordMatch(queryTerms, content, title) {
254
273
  // ─── Query 查询(混合检索:向量 + 关键词) ───
255
274
 
256
275
  async function queryIndex(queryText, topK = 10) {
257
- if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量,请先运行 knowflow init 配置');
276
+ if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量,请先在项目 .env 或环境变量中配置`);
258
277
  if (!existsSync(INDEX_FILE)) { log('❌ 向量索引不存在,请先运行 knowflow ingest 添加内容后自动构建索引'); return []; }
259
278
 
260
279
  const raw = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
@@ -550,7 +569,7 @@ function showStats(verbose = false, json = false) {
550
569
  // ─── Ask (LLM Q&A) ───
551
570
 
552
571
  async function askQuestion(queryText) {
553
- if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量');
572
+ if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量`);
554
573
  if (!existsSync(INDEX_FILE)) { log('❌ 索引不存在,先运行 build'); return; }
555
574
 
556
575
  // Step 1 & 2: Find top 5 relevant passages using existing queryIndex logic
@@ -589,14 +608,15 @@ ${contextText}`;
589
608
 
590
609
  log('🤖 生成回答...');
591
610
 
592
- const res = await fetch(CHAT_API_URL, {
611
+ if (!EMBEDDING.chatModel) throw new Error(`当前 embedding provider (${EMBEDDING.provider}) 未配置 chatModel,knowflow ask 不可用`);
612
+ const res = await fetch(`${EMBEDDING.baseUrl}/chat/completions`, {
593
613
  method: 'POST',
594
614
  headers: {
595
615
  'Content-Type': 'application/json',
596
616
  'Authorization': `Bearer ${API_KEY}`
597
617
  },
598
618
  body: JSON.stringify({
599
- model: CHAT_MODEL,
619
+ model: EMBEDDING.chatModel,
600
620
  messages: [
601
621
  { role: 'system', content: systemPrompt },
602
622
  { role: 'user', content: queryText }
@@ -1,225 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- KnowFlow Vector Store — 基于智谱 embedding-3 的语义检索
4
- 用法:
5
- python3 vector_store.py build # 对所有 wiki 页面建索引
6
- python3 vector_store.py query "AI Agent" # 语义查询
7
- python3 vector_store.py stats # 查看索引状态
8
- """
9
- import os, sys, json, glob, hashlib
10
- from pathlib import Path
11
-
12
- PROJECT_ROOT = Path(os.environ.get("KNOWFLOW_ROOT", Path(__file__).resolve().parent.parent))
13
- WIKI_DIR = Path(os.environ.get("KNOWFLOW_WIKI_DIR", PROJECT_ROOT / "wiki")).resolve()
14
- INDEX_FILE = WIKI_DIR / ".vector-index.json"
15
- EMBED_CACHE = WIKI_DIR / ".embed-cache.json"
16
-
17
- # ====== Embedding ======
18
- def get_embedding(text: str, api_key: str = None) -> list:
19
- """调用智谱 embedding-3 API 获取向量"""
20
- import urllib.request, urllib.error
21
-
22
- key = api_key or os.environ.get("ZHIPUAI_API_KEY", "")
23
- if not key:
24
- # 尝试从 openclaw 配置读取
25
- cfg_path = Path.home() / ".openclaw" / "config.yaml"
26
- if cfg_path.exists():
27
- import yaml
28
- try:
29
- cfg = yaml.safe_load(cfg_path.read_text())
30
- key = cfg.get("zhipu", {}).get("apiKey", "") or cfg.get("providers", {}).get("zhipu", {}).get("apiKey", "")
31
- except: pass
32
-
33
- if not key:
34
- print("⚠️ 未找到 ZHIPUAI_API_KEY,请设置环境变量或在 .env 中配置")
35
- return None
36
-
37
- url = "https://open.bigmodel.cn/api/paas/v4/embeddings"
38
- payload = json.dumps({
39
- "model": "embedding-3",
40
- "input": text[:8000], # 截断过长文本
41
- "dimensions": 1024
42
- }).encode()
43
-
44
- req = urllib.request.Request(url, data=payload, headers={
45
- "Content-Type": "application/json",
46
- "Authorization": f"Bearer {key}"
47
- })
48
-
49
- try:
50
- with urllib.request.urlopen(req, timeout=30) as resp:
51
- data = json.loads(resp.read())
52
- return data["data"][0]["embedding"]
53
- except Exception as e:
54
- print(f"⚠️ Embedding API 错误: {e}")
55
- return None
56
-
57
- # ====== 文件扫描 ======
58
- def scan_wiki_files() -> list:
59
- """扫描所有 wiki markdown 文件,返回 (path, content, meta) 列表"""
60
- files = []
61
- for md_file in sorted(WIKI_DIR.rglob("*.md")):
62
- # 跳过隐藏文件和特殊文件
63
- if any(p.startswith(".") for p in md_file.parts):
64
- continue
65
-
66
- text = md_file.read_text(encoding="utf-8", errors="ignore")
67
- if len(text.strip()) < 50: # 跳过太短的文件
68
- continue
69
-
70
- # 提取 frontmatter 之后的正文用于 embedding
71
- body = text
72
- if text.startswith("---"):
73
- parts = text.split("---", 2)
74
- if len(parts) >= 3:
75
- body = parts[2].strip()
76
-
77
- # 截取前 2000 字符作为 embedding 内容(标题+摘要+关键内容)
78
- embed_text = body[:2000]
79
-
80
- rel_path = str(md_file.relative_to(WIKI_DIR))
81
- files.append({
82
- "path": rel_path,
83
- "full_path": str(md_file),
84
- "title": md_file.stem,
85
- "body": body,
86
- "embed_text": embed_text,
87
- "size": len(text),
88
- "category": rel_path.split("/")[0] if "/" in rel_path else "root"
89
- })
90
-
91
- return files
92
-
93
- # ====== Build Index ======
94
- def build_index(force=False):
95
- """构建/更新向量索引"""
96
- print(f"📚 扫描 Wiki 目录: {WIKI_DIR}")
97
- files = scan_wiki_files()
98
- print(f"📊 找到 {len(files)} 个页面")
99
-
100
- # 加载已有缓存
101
- cache = {}
102
- if EMBED_CACHE.exists() and not force:
103
- cache = json.loads(EMBED_CACHE.read_text())
104
-
105
- index = []
106
- new_count = 0
107
- cache_count = 0
108
-
109
- for i, f in enumerate(files):
110
- # 用文件路径+大小+修改时间做 hash 判断是否需要重新 embedding
111
- file_hash = hashlib.md5(f"{f['path']}:{f['size']}".encode()).hexdigest()
112
-
113
- if file_hash in cache and not force:
114
- index.append({**f, "embedding": cache[file_hash], "_hash": file_hash})
115
- cache_count += 1
116
- else:
117
- print(f" [{i+1}/{len(files)}] Embedding: {f['path']} ...", end=" ", flush=True)
118
- emb = get_embedding(f["embed_text"])
119
- if emb:
120
- f["embedding"] = emb
121
- f["_hash"] = file_hash
122
- index.append(f)
123
- cache[file_hash] = emb
124
- new_count += 1
125
- print("✅")
126
- else:
127
- print("❌ 跳过")
128
-
129
- # 每 10 个保存一次缓存
130
- if (i + 1) % 20 == 0:
131
- EMBED_CACHE.write_text(json.dumps(cache))
132
-
133
- # 保存最终结果
134
- EMBED_CACHE.write_text(json.dumps(cache))
135
- INDEX_FILE.write_text(json.dumps(index, ensure_ascii=False, indent=2))
136
-
137
- print(f"\n✅ 索引构建完成!")
138
- print(f" 新增: {new_count} | 缓存: {cache_count} | 总计: {len(index)}")
139
- print(f" 索引文件: {INDEX_FILE} ({INDEX_FILE.stat().st_size / 1024:.1f} KB)")
140
- print(f" 缓存文件: {EMBED_CACHE} ({EMBED_CACHE.stat().st_size / 1024:.1f} KB)")
141
-
142
- # ====== Query ======
143
- def query(text: str, top_k: int = 5, category_filter: str = None) -> list:
144
- """语义查询,返回最相关的页面"""
145
- if not INDEX_FILE.exists():
146
- print("❌ 索引不存在,请先运行: python3 vector_store.py build")
147
- return []
148
-
149
- index = json.loads(INDEX_FILE.read_text())
150
- if not index:
151
- print("❌ 索引为空")
152
- return []
153
-
154
- print(f"🔍 查询: \"{text}\"")
155
- query_emb = get_embedding(text)
156
- if not query_emb:
157
- return []
158
-
159
- # 余弦相似度
160
- def cosine_similarity(a, b):
161
- dot = sum(x * y for x, y in zip(a, b))
162
- norm_a = sum(x * x for x in a) ** 0.5
163
- norm_b = sum(x * x for x in b) ** 0.5
164
- if norm_a == 0 or norm_b == 0: return 0
165
- return dot / (norm_a * norm_b)
166
-
167
- results = []
168
- for item in index:
169
- if category_filter and item.get("category") != category_filter:
170
- continue
171
- score = cosine_similarity(query_emb, item["embedding"])
172
- results.append({**item, "score": round(score, 4)})
173
-
174
- results.sort(key=lambda x: x["score"], reverse=True)
175
- top = results[:top_k]
176
-
177
- print(f"\n📋 Top {len(top)} 结果:\n")
178
- for r in top:
179
- cat_emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(r.get("category"), "📄")
180
- print(f" {cat_emoji} [{r['score']:.3f}] {r['path']}")
181
- print(f" ({r['size']} chars | {r['category']})")
182
- # 显示匹配到的关键词上下文
183
- body_preview = r.get("body", "")[:200].replace("\n", " ")
184
- print(f" 预览: {body_preview}...")
185
- print()
186
-
187
- return top
188
-
189
- # ====== Stats ======
190
- def show_stats():
191
- """显示索引统计"""
192
- if not INDEX_FILE.exists():
193
- print("❌ 索引不存在"); return
194
-
195
- index = json.loads(INDEX_FILE.read_text())
196
- categories = {}
197
- for item in index:
198
- c = item.get("category", "root")
199
- categories[c] = categories.get(c, 0) + 1
200
-
201
- print(f"📊 Vector Store 统计:")
202
- print(f" 总页面数: {len(index)}")
203
- print(f" 索引大小: {INDEX_FILE.stat().st_size / 1024:.1f} KB")
204
- print(f" 缓存大小: {EMBED_CACHE.stat().st_size / 1024:.1f} KB" if EMBED_CACHE.exists() else "")
205
- print(f"\n 按分类:")
206
- for c, cnt in sorted(categories.items(), key=lambda x: -x[1]):
207
- emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(c, "📁")
208
- print(f" {emoji} {c}: {cnt}")
209
-
210
- # ====== Main ======
211
- if __name__ == "__main__":
212
- cmd = sys.argv[1] if len(sys.argv) > 1 else "stats"
213
-
214
- if cmd == "build":
215
- build_index("--force" in sys.argv)
216
- elif cmd == "query":
217
- q = " ".join(sys.argv[2:])
218
- if not q:
219
- print("用法: python3 vector_store.py query \"搜索内容\"")
220
- else:
221
- query(q)
222
- elif cmd == "stats":
223
- show_stats()
224
- else:
225
- print(__doc__)
@@ -1,285 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Wiki Health Check — validates links, file sizes, and orphan pages.
3
-
4
- Usage:
5
- python scripts/wiki-health.py [WIKI_DIR]
6
-
7
- Checks:
8
- 1. Broken links: [[wiki-links]] and [markdown](links) pointing to missing files
9
- 2. Tiny files: .md files under 100 bytes
10
- 3. Orphan pages: .md files not linked from any other page (excludes index.md, topics.md, overview.md, log.md)
11
-
12
- Exit codes:
13
- 0 — all checks pass (or only warnings)
14
- 1 — broken links found
15
- 2 — errors during execution
16
- """
17
-
18
- import os
19
- import re
20
- import sys
21
- import urllib.request
22
- import urllib.parse
23
- from pathlib import Path
24
- from collections import defaultdict
25
-
26
- # --- Configuration ---
27
- WIKI_ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("wiki")
28
- TINY_THRESHOLD = 100 # bytes
29
- INDEX_FILES = {"index.md", "topics.md", "overview.md", "log.md"}
30
-
31
- # --- Helpers ---
32
-
33
- def resolve_wiki_link(link_target: str, source_file: Path, wiki_root: Path) -> Path:
34
- """Resolve a [[wiki-link]] target to an actual file path.
35
-
36
- Wiki-links are relative to wiki_root, not to the source file.
37
- Supports optional display text: [[target|Display Text]] -> target
38
- """
39
- # Strip optional display text: [[target|display]] -> target
40
- target = link_target.split("|")[0].strip()
41
- if not target:
42
- return None
43
-
44
- # Wiki-links are relative to wiki root
45
- resolved = wiki_root / target
46
-
47
- # Try exact path first
48
- if resolved.is_file():
49
- return resolved
50
-
51
- # Try with .md extension
52
- md_path = resolved.with_suffix(".md")
53
- if md_path.is_file():
54
- return md_path
55
-
56
- return None
57
-
58
-
59
- def resolve_md_link(link_target: str, source_file: Path, wiki_root: Path) -> Path:
60
- """Resolve a [markdown](link) target to an actual file path.
61
-
62
- Markdown links are relative to the source file's directory.
63
- """
64
- if not link_target or link_target.startswith(("http://", "https://", "#", "mailto:")):
65
- return None # External or anchor links — skip
66
-
67
- # URL-decode for Chinese filenames
68
- decoded = urllib.parse.unquote(link_target)
69
-
70
- # Markdown links are relative to the source file directory
71
- source_dir = source_file.parent
72
- resolved = (source_dir / decoded).resolve()
73
-
74
- if resolved.is_file():
75
- return resolved
76
-
77
- return None
78
-
79
-
80
- def strip_code_blocks(content: str) -> str:
81
- """Remove fenced code blocks and inline code to avoid false positive links."""
82
- # Remove fenced code blocks (```...```)
83
- content = re.sub(r'```.*?```', '', content, flags=re.DOTALL)
84
- # Remove inline code (`...`)
85
- content = re.sub(r'`[^`]+`', '', content)
86
- return content
87
-
88
-
89
- def extract_links(content: str, source_file: Path, wiki_root: Path):
90
- """Extract all link targets from markdown content.
91
-
92
- Returns:
93
- wiki_links: set of (raw_target, resolved_path_or_None) for [[]] links
94
- md_links: set of (raw_target, resolved_path_or_None) for []() links
95
- """
96
- wiki_links = set()
97
- md_links = set()
98
-
99
- # Strip code blocks to avoid false positives
100
- clean = strip_code_blocks(content)
101
-
102
- # [[wiki-links]] — may contain | for display text
103
- for match in re.finditer(r'\[\[([^\]]+)\]\]', clean):
104
- raw = match.group(1)
105
- target = raw.split("|")[0].strip()
106
- resolved = resolve_wiki_link(target, source_file, wiki_root)
107
- wiki_links.add((target, resolved))
108
-
109
- # [markdown](links) — standard markdown
110
- for match in re.finditer(r'\[([^\]]*)\]\(([^)]+)\)', clean):
111
- raw_target = match.group(2).strip()
112
- resolved = resolve_md_link(raw_target, source_file, wiki_root)
113
- if resolved is not None or not raw_target.startswith(("http://", "https://", "#", "mailto:")):
114
- md_links.add((raw_target, resolved))
115
-
116
- return wiki_links, md_links
117
-
118
-
119
- # --- Checks ---
120
-
121
- def check_broken_links(wiki_root: Path) -> list[dict]:
122
- """Find all broken links across the wiki."""
123
- broken = []
124
-
125
- for md_file in sorted(wiki_root.rglob("*.md")):
126
- try:
127
- content = md_file.read_text(encoding="utf-8")
128
- except Exception as e:
129
- broken.append({
130
- "file": str(md_file.relative_to(wiki_root)),
131
- "error": f"Cannot read file: {e}"
132
- })
133
- continue
134
-
135
- wiki_links, md_links = extract_links(content, md_file, wiki_root)
136
-
137
- rel_path = str(md_file.relative_to(wiki_root))
138
-
139
- for raw_target, resolved in wiki_links:
140
- if resolved is None:
141
- broken.append({
142
- "type": "wiki-link",
143
- "file": rel_path,
144
- "target": raw_target,
145
- "detail": f"[[{raw_target}]] -> file not found"
146
- })
147
-
148
- for raw_target, resolved in md_links:
149
- # Skip external links
150
- if raw_target.startswith(("http://", "https://", "#", "mailto:")):
151
- continue
152
- if resolved is None:
153
- broken.append({
154
- "type": "md-link",
155
- "file": rel_path,
156
- "target": raw_target,
157
- "detail": f"[]({raw_target}) -> file not found"
158
- })
159
-
160
- return broken
161
-
162
-
163
- def check_tiny_files(wiki_root: Path, threshold: int = TINY_THRESHOLD) -> list[dict]:
164
- """Find .md files smaller than threshold bytes."""
165
- tiny = []
166
-
167
- for md_file in sorted(wiki_root.rglob("*.md")):
168
- try:
169
- size = md_file.stat().st_size
170
- except OSError:
171
- continue
172
-
173
- if size < threshold:
174
- tiny.append({
175
- "file": str(md_file.relative_to(wiki_root)),
176
- "size": size,
177
- "detail": f"{size}B < {threshold}B threshold"
178
- })
179
-
180
- return tiny
181
-
182
-
183
- def check_orphan_pages(wiki_root: Path) -> list[dict]:
184
- """Find .md pages not referenced by any other page."""
185
- # Collect all existing pages
186
- all_pages = set()
187
- for md_file in wiki_root.rglob("*.md"):
188
- all_pages.add(md_file)
189
-
190
- # Collect all link targets across all files
191
- referenced = set()
192
- for md_file in wiki_root.rglob("*.md"):
193
- try:
194
- content = md_file.read_text(encoding="utf-8")
195
- except Exception:
196
- continue
197
-
198
- wiki_links, md_links = extract_links(content, md_file, wiki_root)
199
-
200
- for _, resolved in wiki_links:
201
- if resolved is not None:
202
- referenced.add(resolved)
203
-
204
- for _, resolved in md_links:
205
- if resolved is not None:
206
- referenced.add(resolved)
207
-
208
- orphans = []
209
- for page in sorted(all_pages):
210
- rel = str(page.relative_to(wiki_root))
211
- # Index files are never considered orphans
212
- if page.name in INDEX_FILES:
213
- continue
214
- if page not in referenced:
215
- orphans.append({
216
- "file": rel,
217
- "detail": "not linked from any other page"
218
- })
219
-
220
- return orphans
221
-
222
-
223
- # --- Main ---
224
-
225
- def main():
226
- if not WIKI_ROOT.is_dir():
227
- print(f"ERROR: wiki directory not found: {WIKI_ROOT}")
228
- sys.exit(2)
229
-
230
- print(f"Wiki Health Check — {WIKI_ROOT.resolve()}")
231
- print(f"{'=' * 60}")
232
-
233
- # Count files
234
- md_files = list(WIKI_ROOT.rglob("*.md"))
235
- print(f"Total .md files: {len(md_files)}")
236
-
237
- exit_code = 0
238
-
239
- # 1. Broken links
240
- print(f"\n--- Broken Links ---")
241
- broken = check_broken_links(WIKI_ROOT)
242
- if broken:
243
- errors = [b for b in broken if "error" in b]
244
- link_issues = [b for b in broken if "error" not in b]
245
- if link_issues:
246
- print(f" BROKEN LINKS: {len(link_issues)}")
247
- for item in link_issues:
248
- print(f" [{item['type']}] {item['file']} -> {item['target']}")
249
- exit_code = 1
250
- if errors:
251
- print(f" READ ERRORS: {len(errors)}")
252
- for item in errors:
253
- print(f" {item['file']}: {item['error']}")
254
- else:
255
- print(" OK — no broken links found")
256
-
257
- # 2. Tiny files
258
- print(f"\n--- Tiny Files (< {TINY_THRESHOLD}B) ---")
259
- tiny = check_tiny_files(WIKI_ROOT)
260
- if tiny:
261
- print(f" TINY FILES: {len(tiny)}")
262
- for item in tiny:
263
- print(f" {item['file']} ({item['detail']})")
264
- else:
265
- print(" OK — no tiny files found")
266
-
267
- # 3. Orphan pages
268
- print(f"\n--- Orphan Pages ---")
269
- orphans = check_orphan_pages(WIKI_ROOT)
270
- if orphans:
271
- print(f" ORPHAN PAGES: {len(orphans)}")
272
- for item in orphans:
273
- print(f" {item['file']}")
274
- else:
275
- print(" OK — no orphan pages found")
276
-
277
- print(f"\n{'=' * 60}")
278
- total_issues = len(broken) + len(tiny) + len(orphans)
279
- print(f"Total issues: {total_issues}")
280
-
281
- sys.exit(exit_code)
282
-
283
-
284
- if __name__ == "__main__":
285
- main()