Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ec760e71 | ||
|
|
ee302f615c | ||
|
|
89502f76c4 | ||
|
|
3b6dc2e9fc | ||
|
|
a58453865b | ||
|
|
94d4617299 | ||
|
|
4d35d576ea | ||
|
|
141acce64a | ||
|
|
fef97597cf | ||
|
|
1c45805457 |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"/Users/junjian/GitHub/wang-junjian/wikillm/raw/images"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
# 编译缓存与临时文件
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# 上游子仓库元数据(raw/llm_wiki 的来源仓库备份,恢复上游更新时改名为 .git)
|
||||
raw/llm_wiki/.git.bak
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"showUnsupportedFiles": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": true,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"collapse-filter": false,
|
||||
"search": "path:wiki",
|
||||
"showTags": false,
|
||||
"showAttachments": false,
|
||||
"hideUnresolved": false,
|
||||
"showOrphans": false,
|
||||
"collapse-color-groups": false,
|
||||
"colorGroups": [
|
||||
{
|
||||
"query": "path:wiki/practices ",
|
||||
"color": {
|
||||
"a": 1,
|
||||
"rgb": 14048348
|
||||
}
|
||||
},
|
||||
{
|
||||
"query": "path:raw ",
|
||||
"color": {
|
||||
"a": 1,
|
||||
"rgb": 6056662
|
||||
}
|
||||
},
|
||||
{
|
||||
"query": "path:wiki/concepts ",
|
||||
"color": {
|
||||
"a": 1,
|
||||
"rgb": 11392604
|
||||
}
|
||||
},
|
||||
{
|
||||
"query": "path:wiki/queries ",
|
||||
"color": {
|
||||
"a": 1,
|
||||
"rgb": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"collapse-display": true,
|
||||
"showArrow": false,
|
||||
"textFadeMultiplier": 0,
|
||||
"nodeSizeMultiplier": 1,
|
||||
"lineSizeMultiplier": 1,
|
||||
"collapse-forces": false,
|
||||
"centerStrength": 0.458860759493671,
|
||||
"repelStrength": 10.3574397339983,
|
||||
"linkStrength": 0.848101265822785,
|
||||
"linkDistance": 322,
|
||||
"scale": 1.0890794994553836,
|
||||
"close": false
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "d1b946394494730d",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "bafcef641122da54",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "7e8c0fba09ad238c",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "raw/期货/20-品种新闻/焦煤/日报/2026-08-06_焦煤日报.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "2026-08-06_焦煤日报"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "25233c1a01ae40dd",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "556d942fb29f95c8",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "6841af199caa58fd",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "文件列表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "efb5da93abae8a42",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "搜索"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d47b0191e9ea948e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "书签"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "2b042b53367f20f0",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "103e3ddcc6065b7a",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "709f6cac006d06bd",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "raw/llm_wiki/mcp-server/README.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "反向链接"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a885af2199869ca7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "raw/llm_wiki/mcp-server/README.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "出链"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bf9b9d9c7779db2e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "标签"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0cc1103a1470f556",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-archive",
|
||||
"title": "添加笔记属性"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c828df96a07dc63e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "raw/llm_wiki/mcp-server/README.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "大纲"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:打开快速切换": false,
|
||||
"graph:查看关系图谱": false,
|
||||
"canvas:新建白板": false,
|
||||
"daily-notes:打开/创建今天的日记": false,
|
||||
"templates:插入模板": false,
|
||||
"command-palette:打开命令面板": false,
|
||||
"bases:新建数据库": false
|
||||
}
|
||||
},
|
||||
"active": "6841af199caa58fd",
|
||||
"lastOpenFiles": [
|
||||
"wiki/concepts/焦煤2026年8月市场回顾.md",
|
||||
"raw/期货/01-基本分析逻辑/焦煤日报/scripts/__pycache__/auto_daily_report.cpython-314.pyc",
|
||||
"raw/期货/20-品种新闻/焦煤/日报/2026-07-24_焦煤日报.md",
|
||||
"raw/期货/01-基本分析逻辑/焦煤日报/scripts/auto_daily_report.py",
|
||||
"raw/期货/20-品种新闻/焦煤/事件/2026-08-06_山西焦煤西曲矿安全事故停产.md",
|
||||
"raw/期货/20-品种新闻/焦煤/日报/2026-08-05_焦煤日报.md",
|
||||
"raw/期货/20-品种新闻/焦煤/日报/2026-08-04_焦煤日报.md",
|
||||
"raw/期货/20-品种新闻/焦煤/日报/2026-08-03_焦煤日报.md",
|
||||
"raw/期货/20-品种新闻/焦煤/日报/2026-07-31_焦煤日报.pdf",
|
||||
"verify_tushare_jm.py",
|
||||
"资本小论.md",
|
||||
"wiki/queries/What-is-Harness-Engineering-in-Simple-Terms.md",
|
||||
"wiki/practices/玻璃期货研究框架.md",
|
||||
"wiki/practices/LangChain-Harness-Engineering.md",
|
||||
"wiki/sources.md",
|
||||
"raw/期货/10-品种相关/烧碱/烧碱期货品种手册-郑商所-2025-12.md",
|
||||
"raw/工程技术:在智能体优先的世界中利用 Codex.md",
|
||||
"raw/期货/10-品种相关/烧碱/烧碱期货投教材料.docx",
|
||||
"raw/股票/01-投资方法/股票投资要义-胡斐-2015.md",
|
||||
"raw/期货/10-品种相关/玻璃/README.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃周期、产能出清与研究问题.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃行业基础与供应链.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃数据字典与数据库设计.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃生产工艺与成本模型.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃期货投教材料-郑商所-2021-06.md",
|
||||
"raw/期货/10-品种相关/玻璃/玻璃期货合约与研究方法.md",
|
||||
"raw/期货/_meta/目录迁移记录.md",
|
||||
"raw/期货/README.md",
|
||||
"raw/期货/90-待核验/期货价格走势分析框架-原始剪藏.md",
|
||||
"wiki/concepts/奇异型期限结构与期现套利分析.md",
|
||||
"wiki/concepts/中国房地产市场制度演进与周期阶段.md",
|
||||
"raw/期货/01-基本分析逻辑/焦煤分析技能",
|
||||
"raw/期货/01-基本分析逻辑/事件驱动",
|
||||
"raw/期货/01-基本分析逻辑/焦煤日报/scripts/Generate-JmDailyReport.ps1",
|
||||
"raw/期货/01-基本分析逻辑/焦煤日报/scripts/extract_pdf_sections.py",
|
||||
"raw/期货/01-基本分析逻辑/焦煤日报/scripts/extract_pdf_preview.py",
|
||||
"未命名.canvas",
|
||||
"images/webui.jpeg",
|
||||
"images/skill.png",
|
||||
"images/obsidian-index.png",
|
||||
"未命名 3.canvas",
|
||||
"未命名 1.canvas",
|
||||
"未命名 2.canvas",
|
||||
"raw/期货/01-基本分析逻辑/技术分析图.jpg",
|
||||
"raw/llm_wiki/src/assets/logo.jpg",
|
||||
"raw/llm_wiki/src-tauri/icons/icon.png",
|
||||
"raw/llm_wiki/src-tauri/icons/32x32.png",
|
||||
"raw/llm_wiki/src-tauri/icons/128x128@2x.png",
|
||||
"raw/llm_wiki/src-tauri/icons/128x128.png",
|
||||
"raw/images/4f67b1c10566552aec514a716ea43544ab330e0b-668x243.webp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
WikiLLM is a system for building LLM-powered personal knowledge bases. The workflow consists of:
|
||||
|
||||
1. **Data Ingest**: Source documents (articles, papers, repos, datasets, images) are indexed into a `raw/` directory
|
||||
2. **Wiki Compilation**: An LLM incrementally "compiles" the raw data into a wiki of markdown files with summaries, backlinks, categorized concepts, and interlinked articles
|
||||
3. **IDE**: Obsidian is used as the frontend to view raw data, compiled wiki, and visualizations
|
||||
4. **Q&A**: The LLM can answer complex questions against the wiki by researching the related data
|
||||
5. **Output**: Results are rendered as markdown files, Marp slides, or matplotlib images, viewable in Obsidian
|
||||
6. **Linting**: LLM "health checks" find inconsistencies, impute missing data, suggest new article candidates
|
||||
7. **Extra Tools**: Additional tools like a naive search engine over the wiki
|
||||
|
||||
## Directory Structure
|
||||
|
||||
The project will eventually include these key directories:
|
||||
|
||||
- `raw/` - Source documents and unprocessed data
|
||||
- `wiki/` - LLM-compiled markdown wiki with articles, summaries, and links
|
||||
- `tools/` - CLI tools for searching, processing, and enhancing the wiki
|
||||
|
||||
## Core Principles
|
||||
|
||||
- The LLM writes and maintains all wiki data; manual edits are rare
|
||||
- User explorations and queries are filed back into the wiki to enhance it
|
||||
- The system focuses on markdown files and Obsidian-compatible formats
|
||||
- Images are downloaded locally for easy LLM reference
|
||||
@@ -0,0 +1,108 @@
|
||||
# WikiLLM
|
||||
|
||||
利用 LLM 构建个人知识库的系统。WikiLLM 将原始素材"编译"成结构化、交叉链接的高质量中文 Wiki,可在 Obsidian 中查看。
|
||||
|
||||
本项目基于 **Andrej Karpathy** 提出的理念构建。详见:[LLM Knowledge Bases](https://x.com/karpathy/status/2039805659525644595)
|
||||
|
||||
## 项目概述
|
||||
|
||||
WikiLLM 的工作流包括:
|
||||
|
||||
1. **数据摄入**:源文档(文章、论文、代码库、数据集、图像)被索引到 `raw/` 目录
|
||||
2. **Wiki 编译**:LLM 增量地"编译"原始数据成 markdown 文件的 wiki,包含摘要、反向链接、分类概念和相互链接的文章
|
||||
3. **IDE**:Obsidian 用作前端查看原始数据、编译后的 wiki 和可视化
|
||||
4. **问答**:LLM 可以通过研究相关数据来回答针对 wiki 的复杂问题
|
||||
5. **输出**:结果渲染为 markdown 文件、Marp 幻灯片或 matplotlib 图像,可在 Obsidian 中查看
|
||||
6. **Linting**:LLM"健康检查"发现不一致、填补缺失数据、建议新文章候选
|
||||
7. **额外工具**:诸如 wiki 上的朴素搜索引擎等额外工具
|
||||
|
||||
## 核心原则
|
||||
|
||||
- **LLM 编写和维护所有 wiki 数据**;手动编辑很少见
|
||||
- **用户探索和查询被归档回 wiki** 以增强它
|
||||
- **系统专注于 markdown 文件和 Obsidian 兼容格式**
|
||||
- **图像被下载到本地** 以便 LLM 轻松引用
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
wikillm/
|
||||
├── raw/ # 源文档和未处理数据
|
||||
├── wiki/ # LLM 编译的 markdown wiki
|
||||
│ ├── concepts/ # 核心概念文章
|
||||
│ ├── practices/ # 实践指南文章
|
||||
│ ├── visual/ # 可视化内容
|
||||
│ ├── queries/ # 查询存档
|
||||
│ ├── assets/ # 图像和资源文件
|
||||
│ ├── INDEX.md # 首页索引
|
||||
│ └── Glossary.md # 术语表
|
||||
├── skills/ # Claude Code 技能
|
||||
├── CLAUDE.md # 项目特定的 Claude 指令
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 当前内容
|
||||
|
||||
本 wiki 当前包含关于 **Harness Engineering**的综合知识库,基于以下来源编译:
|
||||
|
||||
- [Externalization in LLM Agents: 智能体记忆/技能/协议/Harness工程统一综述](https://arxiv.org/html/2604.08224v1)
|
||||
- [Meta-Harness: 模型Harness的端到端优化](https://arxiv.org/html/2603.28052v1)
|
||||
- [Anthropic: 托管智能体的架构设计:脑手分离](https://www.anthropic.com/engineering/managed-agents)
|
||||
- [Anthropic: 长生命周期应用的Harness设计](https://www.anthropic.com/engineering/harness-design-long-running-apps)
|
||||
- [OpenAI: 智能体优先世界中的Codex Harness工程](https://openai.com/zh-Hans-CN/index/harness-engineering/)
|
||||
- [OpenAI: 英文原版Harness工程指南](https://openai.com/index/harness-engineering/)
|
||||
- [MiniMax M2.7 模型自我进化发布公告](https://www.minimaxi.com/news/minimax-m27-zh)
|
||||
- [RedHat: AI辅助开发的结构化Harness工作流](https://developers.redhat.com/articles/2026/04/07/harness-engineering-structured-workflows-ai-assisted-development#the_fix__a_two_phase_workflow)
|
||||
- [Mitchell Hashimoto (HashiCorp创始人)的AI应用落地历程](https://mitchellh.com/writing/my-ai-adoption-journey)
|
||||
- [NxCode: Harness工程完整指南 2026](https://www.nxcode.io/resources/news/harness-engineering-complete-guide-ai-agent-codex-2026)
|
||||
- [LangChain: 基于Harness工程优化深度智能体](https://blog.langchain.com/improving-deep-agents-with-harness-engineering/)
|
||||
- [Martin Fowler: 编码智能体用户的Harness工程实践](https://martinfowler.com/articles/harness-engineering.html)
|
||||
- [Martin Fowler: Harness工程早期思考笔记](https://martinfowler.com/articles/exploring-gen-ai/harness-engineering-memo.html)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 在 Obsidian 中查看
|
||||
|
||||
1. 下载并安装 [Obsidian](https://obsidian.md/)
|
||||
2. 在 Obsidian 中打开本仓库作为 vault
|
||||
3. 从 `wiki/INDEX.md` 开始探索
|
||||
|
||||

|
||||
|
||||
### 在 Web 浏览器中查看
|
||||
|
||||
本项目包含一个 Next.js Web 应用,用于在浏览器中查看知识库:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
然后访问 http://localhost:3000 即可查看。
|
||||
|
||||

|
||||
|
||||
**Web 应用功能:**
|
||||
- Markdown 渲染,支持 GFM 格式
|
||||
- Obsidian 风格 wiki 链接解析(`[[Page|Label]]`)
|
||||
- 侧边栏导航,按分类组织页面
|
||||
- 图片资源支持
|
||||
- 响应式设计
|
||||
|
||||
### 使用技能
|
||||
|
||||
本项目包含 Claude Code 技能用于生成 wiki:
|
||||
|
||||
```bash
|
||||
# 在 Claude Code 中
|
||||
/skills wiki 编译
|
||||
```
|
||||
|
||||

|
||||
|
||||
详细说明请参阅 [skills/SKILL.md](skills/SKILL.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License - 详见 [LICENSE](LICENSE) 文件。
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: "Harness Engineering - first thoughts"
|
||||
source: "https://martinfowler.com/articles/exploring-gen-ai/harness-engineering-memo.html"
|
||||
author:
|
||||
- "[[Birgitta Böckeler Birgitta is a Distinguished Engineer and AI-assisted delivery expert at Thoughtworks. She has over 20 years of experience as a software developer]]"
|
||||
- "[[architect and technical leader.]]"
|
||||
published:
|
||||
created: 2026-04-11
|
||||
description: "Notes from my Thoughtworks colleagues on AI-assisted software delivery"
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
Since writing this memo, I have had time to analyze the topic further and write a [more considered article describing Harness Engineering](https://martinfowler.com/articles/harness-engineering.html). This frames the elements of a harness as guides and sensors, which may be computational or inferential. Harness templates allow us to share common guides and sensors around a larger software organization. Harnesses attempt to externalise and make explicit what human developer experience brings to the table, but they can only go so far. A good harness should not necessarily aim to fully eliminate human input, but to direct it to where our input is most important.
|
||||
|
||||
It was very interesting to read [OpenAI’s recent write-up on “Harness engineering”](https://openai.com/index/harness-engineering/) which describes how a team used “no manually typed code at all” as a forcing function to build a harness for maintaining a large application with AI agents. After 5 months, they’ve built a real product that’s now over 1 million lines of code.
|
||||
|
||||
The article is titled “Harness engineering: leveraging Codex in an agent-first world”, but only mentions “harness” once in the text. Maybe the term was an afterthought inspired by [Mitchell Hashimoto](https://mitchellh.com/writing/my-ai-adoption-journey#step-5-engineer-the-harness) ’s recent blog post. Either way, I like “harness” as a word to describe the tooling and practices we can use to keep AI agents in check.
|
||||
|
||||
The OpenAI team’s harness components mix deterministic and LLM-based approaches across 3 categories (grouping based on my interpretation):
|
||||
|
||||
1. **Context engineering**: Continuously enhanced knowledge base in the codebase, plus agent access to dynamic context like observability data and browser navigation
|
||||
2. **Architectural constraints**: Monitored not only by the LLM-based agents, but also deterministic custom linters and structural tests
|
||||
3. **“Garbage collection”**: Agents that run periodically to find inconsistencies in documentation or violations of architectural constraints, fighting entropy and decay
|
||||
|
||||
They also highlight how iterative this is: “When the agent struggles, we treat it as a signal: identify what is missing — tools, guardrails, documentation — and feed it back into the repository, always by having Codex itself write the fix.”
|
||||
|
||||
All of the described measures focus on increasing long-term internal quality and maintainability. What I am missing in the write-up is verification of functionality and behaviour.
|
||||
|
||||
Leaving that gap aside, and assuming we can trust OpenAI’s representation of the success of this (with respect to the author and the team, OpenAI do have a vested interest in us believing in AI-maintainable code) — here are my thoughts on what *is* in the article.
|
||||
|
||||
### Harnesses - the future service templates?
|
||||
|
||||
Most organizations have just two or three main tech stacks — not every application is its own snowflake. The article made me imagine a future where teams pick from a set of harnesses for common application topologies to get started. This evokes today’s service templates, which help teams instantiate new services on a “golden path”. Will harnesses — with custom linters, structural tests, basic context and knowledge documentation, and additional context providers — become the new service templates? Will teams use them as a starting point, then shape them over time for their application’s specifics?
|
||||
|
||||
With service templates, teams contribute back as they gain experience, then other teams often struggle to incorporate updates. Would we see a similar forking and synchronization challenge with harnesses?
|
||||
|
||||
The article also made me revisit some of my older hypotheses:
|
||||
|
||||
### The runtime has to be constrained for more AI autonomy?
|
||||
|
||||
A lot of early and current AI coding hype assumes LLMs will give us unlimited flexibility of the target runtime. Generate in any language, any pattern, without constraints — the LLM will figure it out. But for maintainable, AI-generated code at scale that we can trust, something has to give.
|
||||
|
||||
The harness described suggests that increasing trust and reliability required constraining the solution space: specific architectural patterns, enforced boundaries, standardized structures. That means giving up some “generate anything” flexibility for prompts, rules, and harnesses full of technical specifics.
|
||||
|
||||
### A convergence on a limited number of tech stacks and topologies?
|
||||
|
||||
As coding becomes less about typing code and more about steering its generation, AI might push us toward fewer tech stacks. Usability of frameworks and SDKs still matters — we’re seeing repeatedly that what’s good for humans is good for AI. But developer tastes will matter less at that level of detail. Little inefficiencies and idiosyncracies in interfaces will be less annoying since we don’t deal with them directly. We might choose stacks with good harnesses available and prioritize “AI-friendliness”.
|
||||
|
||||
This might apply not just to tech stacks, but also to codebase structures and topologies. We might default to structures that are easier to maintain with AI because they’re easier to harness. The OpenAI team discusses architectural rigidity and enforcement rules. The main focus areas I can see are keeping data structures stable and defining and enforcing module boundaries. Sounds reasonable — but without concrete examples, I’m still struggling to imagine what “we require Codex to parse data shapes at the boundary” looks like in practice in their harness.
|
||||
|
||||
But if we can figure out widely how to harness codebase design patterns, will these topologies become the new abstraction layer, not natural language itself like so many AI enthusiasts hope?
|
||||
|
||||
### Two future worlds: Pre-AI vs post-AI application maintenance?
|
||||
|
||||
Say we develop good harnessing techniques to turn AI autonomy up to 9 and increase our confidence in the results. Which techniques could we apply to existing applications, and which would only work for applications built from scratch with a harness in mind?
|
||||
|
||||
For older codebases, we’d need to consider whether retrofitting a harness is worth the effort. AI can help us do that faster, but those applications are often so non-standardized and full of entropy that it might not be worthwhile. It makes me think of running a static code analysis tool on a codebase that’s never had one, and then drowning in alerts.
|
||||
|
||||
### What’s your harness today?
|
||||
|
||||
That this team worked on their harness for 5 months shows this isn’t something you can jump into for quick results. But it’s worth reflecting on what your harness is today. Do you have a pre-commit hook? What’s in it? Do you have ideas for custom linters? What architectural constraints would you like to impose on your codebase? Have you experimented with structural testing frameworks like ArchUnit?
|
||||
|
||||
### Final thoughts
|
||||
|
||||
Unsurprisingly, what they describe sounds like much more work than just generating and maintaining a bunch of Markdown rules files. They built extensive tooling for the deterministic part of the harness. Their context engineering involved not only curating a knowledge base, but also significant design work — the code design itself is a huge part of the context.
|
||||
|
||||
The OpenAI team says: “Our most difficult challenges now center on designing environments, feedback loops, and control systems.” This reminded me of [Chad Fowler’s recent post on “Relocating Rigor”](https://aicoding.leaflet.pub/3mbrvhyye4k2e). It’s refreshing to hear concrete ideas and experiences about where that rigor might go, rather than just hoping “better models” will magically solve maintainability issues.
|
||||
|
||||
And finally, for once, I like a term in this space. Though it’s only 2 weeks old — I can probably hold my metaphorical breath until somebody calls their one-prompt, LLM-based code review agent a harness…
|
||||
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "Harness Engineering: The Complete Guide to Building Systems That Make AI Agents Actually Work (2026)"
|
||||
source: "https://www.nxcode.io/resources/news/harness-engineering-complete-guide-ai-agent-codex-2026"
|
||||
author:
|
||||
- "[[NxCode Team]]"
|
||||
published: 2026-03-01
|
||||
created: 2026-04-11
|
||||
description: "Harness engineering is the new discipline of designing environments, constraints, and feedback loops that make AI coding agents reliable at scale. OpenAI built 1M+ lines of code with zero human-written code using this approach."
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
Turn your idea into a working app — no coding required.[Start Free](https://studio.nxcode.io/?ref=article_top_harness-engineering-complete-guide-ai-agent-codex-2026&article=harness-engineering-complete-guide-ai-agent-codex-2026)
|
||||
|
||||
## Harness Engineering: The Complete Guide to Building Systems That Make AI Agents Actually Work
|
||||
|
||||
**March 2026** — If 2025 was the year AI agents proved they could write code, 2026 is the year we learned that **the agent isn't the hard part — the harness is.**
|
||||
|
||||
OpenAI's Codex team just built a production application with **over 1 million lines of code** where **zero lines were written by human hands**. The engineers didn't write code. They designed the system that let AI write code reliably. That system — the constraints, feedback loops, documentation, linters, and lifecycle management — is what the industry now calls a **harness**.
|
||||
|
||||
**Harness engineering** is the new discipline of designing these systems. And it's changing what it means to be a software engineer.
|
||||
|
||||
---
|
||||
|
||||
## What Is Harness Engineering?
|
||||
|
||||
### The Horse Metaphor
|
||||
|
||||
The term "harness" comes from horse tack — reins, saddle, bit — the complete set of equipment for channeling a powerful but unpredictable animal in the right direction. The metaphor is deliberate:
|
||||
|
||||
- The **horse** is the AI model — powerful, fast, but it doesn't know where to go on its own
|
||||
- The **harness** is the infrastructure — constraints, guardrails, feedback loops that channel the model's power productively
|
||||
- The **rider** is the human engineer — providing direction, not doing the running
|
||||
|
||||
Without a harness, an AI agent is a thoroughbred in an open field. Fast, impressive, and completely useless for getting anything done.
|
||||
|
||||
### The Formal Definition
|
||||
|
||||
**Harness engineering** is the design and implementation of systems that:
|
||||
|
||||
1. **Constrain** what an AI agent can do (architectural boundaries, dependency rules)
|
||||
2. **Inform** the agent about what it should do (context engineering, documentation)
|
||||
3. **Verify** that the agent did it correctly (testing, linting, CI validation)
|
||||
4. **Correct** the agent when it goes wrong (feedback loops, self-repair mechanisms)
|
||||
|
||||
Martin Fowler describes it as *"the tooling and practices we can use to keep AI agents in check"* — but it's more than just safety. A good harness makes agents **more capable**, not just more controlled.
|
||||
|
||||
---
|
||||
|
||||
## Why Harness Engineering Matters Now
|
||||
|
||||
### The Model Is Commodity. The Harness Is Moat.
|
||||
|
||||
Here's the uncomfortable truth the AI industry is confronting: **the underlying model matters less than the system around it.**
|
||||
|
||||
LangChain proved this definitively. Their coding agent went from **52.8% to 66.5%** on Terminal Bench 2.0 — jumping from **Top 30 to Top 5** — by changing nothing about the model. They only changed the harness:
|
||||
|
||||
| Change | What They Did | Impact |
|
||||
| --- | --- | --- |
|
||||
| Self-verification loop | Added pre-completion checklist middleware | Caught errors before submission |
|
||||
| Context engineering | Mapped directory structures at startup | Agent understood codebase from the start |
|
||||
| Loop detection | Tracked repeated file edits | Prevented "doom loops" |
|
||||
| Reasoning sandwich | High reasoning for planning/verification, medium for implementation | Better quality within time budgets |
|
||||
|
||||
**Same model. Different harness. Dramatically better results.**
|
||||
|
||||
### OpenAI's 1 Million Line Proof Point
|
||||
|
||||
OpenAI's experiment is the most compelling evidence yet:
|
||||
|
||||
- **5 months** of development
|
||||
- **1 million+ lines of code** in the final product
|
||||
- **Zero manually written lines** — every line was produced by Codex agents
|
||||
- **Built in ~1/10th the time** it would have taken humans
|
||||
- The product has **internal daily users and external alpha testers**
|
||||
- It **ships, deploys, breaks, and gets fixed** — all by agents within the harness
|
||||
|
||||
The engineers' job? Designing the harness. Specifying intent. Providing feedback. Not writing code.
|
||||
|
||||
---
|
||||
|
||||
## The Three Pillars of Harness Engineering
|
||||
|
||||
OpenAI's framework organizes harness engineering into three core categories:
|
||||
|
||||
### 1\. Context Engineering
|
||||
|
||||
Context engineering is about ensuring the agent has the right information at the right time.
|
||||
|
||||
**Static context:**
|
||||
|
||||
- Repository-local documentation (architecture specs, API contracts, style guides)
|
||||
- `AGENTS.md` or `CLAUDE.md` files that encode project-specific rules
|
||||
- Cross-linked design documents validated by linters
|
||||
|
||||
**Dynamic context:**
|
||||
|
||||
- Observability data (logs, metrics, traces) accessible to agents
|
||||
- Directory structure mapping at agent startup
|
||||
- CI/CD pipeline status and test results
|
||||
|
||||
**The critical rule:** From the agent's perspective, anything it can't access in-context doesn't exist. Knowledge in Google Docs, Slack threads, or people's heads is invisible to the system. **The repository must be the single source of truth.**
|
||||
|
||||
### 2\. Architectural Constraints
|
||||
|
||||
This is where harness engineering diverges most sharply from traditional AI prompting. Instead of telling the agent "write good code," you **mechanically enforce what good code looks like.**
|
||||
|
||||
**Dependency layering:**
|
||||
|
||||
```
|
||||
Types → Config → Repo → Service → Runtime → UI
|
||||
```
|
||||
|
||||
Each layer can only import from layers to its left. This isn't a suggestion — it's enforced by structural tests and CI validation.
|
||||
|
||||
**Constraint enforcement tools:**
|
||||
|
||||
- **Deterministic linters** — Custom rules that flag violations automatically
|
||||
- **LLM-based auditors** — Agents that review other agents' code for architectural compliance
|
||||
- **Structural tests** — Like ArchUnit, but for AI-generated code
|
||||
- **Pre-commit hooks** — Automated checks before any code is committed
|
||||
|
||||
**Why constraints improve output:** Paradoxically, constraining the solution space makes agents **more productive**, not less. When an agent can generate anything, it wastes tokens exploring dead ends. When the harness defines clear boundaries, the agent converges faster on correct solutions.
|
||||
|
||||
### 3\. Entropy Management ("Garbage Collection")
|
||||
|
||||
This is the most underappreciated component. Over time, AI-generated codebases accumulate entropy — documentation drifts from reality, naming conventions diverge, dead code accumulates.
|
||||
|
||||
Harness engineering addresses this with **periodic cleanup agents:**
|
||||
|
||||
- **Documentation consistency agents** — Verify that docs match current code
|
||||
- **Constraint violation scanners** — Find code that slipped past earlier checks
|
||||
- **Pattern enforcement agents** — Identify and fix deviations from established patterns
|
||||
- **Dependency auditors** — Track and resolve circular or unnecessary dependencies
|
||||
|
||||
These agents run on schedules — daily, weekly, or triggered by specific events — keeping the codebase healthy for both human reviewers and future AI agents.
|
||||
|
||||
---
|
||||
|
||||
## Harness Engineering in Practice: How Teams Actually Do It
|
||||
|
||||
### The OpenAI Approach: Zero Human Code
|
||||
|
||||
OpenAI's team structure for harness engineering:
|
||||
|
||||
| Role | Traditional | Harness Engineering |
|
||||
| --- | --- | --- |
|
||||
| Writing code | Primary job | Never |
|
||||
| Designing architecture | Part of the job | Primary job |
|
||||
| Writing documentation | Afterthought | Critical infrastructure |
|
||||
| Reviewing PRs | Code review | Reviewing agent output + harness effectiveness |
|
||||
| Debugging | Reading code | Analyzing agent behavior patterns |
|
||||
| Testing | Writing tests | Designing test strategies agents execute |
|
||||
|
||||
### The Stripe Approach: Minions at Scale
|
||||
|
||||
Stripe's internal coding agents, called **Minions**, now produce **over 1,000 merged pull requests per week**:
|
||||
|
||||
1. Developer posts a task in Slack
|
||||
2. Minion writes the code
|
||||
3. Minion passes CI
|
||||
4. Minion opens a PR
|
||||
5. Human reviews and merges
|
||||
|
||||
No developer interaction between step 1 and step 5. The harness handles everything — test execution, CI validation, style compliance, and documentation updates.
|
||||
|
||||
### The LangChain Approach: Middleware-First
|
||||
|
||||
LangChain structures their harness as composable middleware layers:
|
||||
|
||||
```
|
||||
Agent Request
|
||||
→ LocalContextMiddleware (maps codebase)
|
||||
→ LoopDetectionMiddleware (prevents repetition)
|
||||
→ ReasoningSandwichMiddleware (optimizes compute)
|
||||
→ PreCompletionChecklistMiddleware (enforces verification)
|
||||
→ Agent Response
|
||||
```
|
||||
|
||||
Each middleware layer adds a specific capability without modifying the core agent logic. This modular approach makes the harness testable and evolvable.
|
||||
|
||||
---
|
||||
|
||||
## Building Your First Harness: A Practical Framework
|
||||
|
||||
### Level 1: Basic Harness (Single Developer)
|
||||
|
||||
If you're using Claude Code, Cursor, or Codex for individual projects:
|
||||
|
||||
**What to set up:**
|
||||
|
||||
- `CLAUDE.md` or `.cursorrules` file with project conventions
|
||||
- Pre-commit hooks for linting and formatting
|
||||
- A test suite the agent can run to self-verify
|
||||
- Clear directory structure with consistent naming
|
||||
|
||||
**Time to set up:** 1-2 hours **Impact:** Prevents the most common agent mistakes
|
||||
|
||||
### Level 2: Team Harness (Small Team)
|
||||
|
||||
For teams of 3-10 developers sharing a codebase:
|
||||
|
||||
**Add to Level 1:**
|
||||
|
||||
- `AGENTS.md` with team-wide conventions
|
||||
- Architectural constraints enforced by CI
|
||||
- Shared prompt templates for common tasks
|
||||
- Documentation-as-code validated by linters
|
||||
- Code review checklists specifically for agent-generated PRs
|
||||
|
||||
**Time to set up:** 1-2 days **Impact:** Consistent agent behavior across the team
|
||||
|
||||
### Level 3: Production Harness (Engineering Organization)
|
||||
|
||||
For organizations running dozens of concurrent agents:
|
||||
|
||||
**Add to Level 2:**
|
||||
|
||||
- Custom middleware layers (loop detection, reasoning optimization)
|
||||
- Observability integration (agents read logs and metrics)
|
||||
- Entropy management agents on scheduled runs
|
||||
- Harness versioning and A/B testing
|
||||
- Agent performance monitoring dashboards
|
||||
- Escalation policies for when agents get stuck
|
||||
|
||||
**Time to set up:** 1-2 weeks **Impact:** Agents operate as autonomous contributors
|
||||
|
||||
---
|
||||
|
||||
## Common Harness Engineering Mistakes
|
||||
|
||||
### 1\. Over-Engineering the Control Flow
|
||||
|
||||
> *"If you over-engineer the control flow, the next model update will break your system."*
|
||||
|
||||
Models improve rapidly. Capabilities that required complex pipelines in 2024 are now handled by a single context-window prompt. Build your harness to be **rippable** — you should be able to remove "smart" logic when the model gets smart enough to not need it.
|
||||
|
||||
### 2\. Treating the Harness as Static
|
||||
|
||||
The harness needs to evolve with the model. When a new model release improves reasoning, your reasoning-optimization middleware might become counterproductive. Review and update harness components with every major model update.
|
||||
|
||||
### 3\. Ignoring the Documentation Layer
|
||||
|
||||
The most impactful harness improvement is often the simplest: **better documentation**. If your `AGENTS.md` is vague, your agent output will be vague. Invest in precise, machine-readable documentation that serves as the agent's ground truth.
|
||||
|
||||
### 4\. No Feedback Loop
|
||||
|
||||
A harness without feedback is a cage, not a guide. The agent needs to know when it's succeeding and when it's failing. Build in:
|
||||
|
||||
- Self-verification steps before task completion
|
||||
- Test execution as part of the agent workflow
|
||||
- Metrics on agent success rates by task type
|
||||
|
||||
### 5\. Human-Only Documentation
|
||||
|
||||
If your architectural decisions live in people's heads or in Confluence pages the agent can't access, the harness has a gap. **Everything the agent needs must be in the repository.**
|
||||
|
||||
---
|
||||
|
||||
## Harness Engineering vs. Related Concepts
|
||||
|
||||
| Concept | Scope | Focus |
|
||||
| --- | --- | --- |
|
||||
| **Prompt Engineering** | Single interaction | Crafting effective prompts |
|
||||
| **Context Engineering** | Model context window | What information the model sees |
|
||||
| **Harness Engineering** | Entire agent system | Environment, constraints, feedback, lifecycle |
|
||||
| **Agent Engineering** | Agent architecture | Internal agent design and routing |
|
||||
| **Platform Engineering** | Infrastructure | Deployment, scaling, operations |
|
||||
|
||||
Harness engineering **includes** context engineering and draws from prompt engineering, but it operates at a higher level — it's about the complete system that makes agents reliable, not just the inputs to a single interaction.
|
||||
|
||||
---
|
||||
|
||||
## What This Means for Software Engineers
|
||||
|
||||
### The Job Is Changing
|
||||
|
||||
Harness engineering represents a genuine evolution in what software engineers do:
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
| Write code | Design environments where AI writes code |
|
||||
| Debug code | Debug agent behavior |
|
||||
| Review code | Review agent output + harness effectiveness |
|
||||
| Write tests | Design test strategies |
|
||||
| Maintain docs | Build documentation as machine-readable infrastructure |
|
||||
|
||||
This doesn't mean engineers become less technical. If anything, harness engineering requires **deeper** architectural thinking — you're designing systems that must work without your constant intervention.
|
||||
|
||||
### The Skills That Matter
|
||||
|
||||
Based on what we've seen building AI-powered products at [NxCode](https://www.nxcode.io/):
|
||||
|
||||
1. **Systems thinking** — Understanding how constraints, feedback loops, and documentation interact
|
||||
2. **Architecture design** — Defining boundaries that are enforceable and productive
|
||||
3. **Specification writing** — Articulating intent precisely enough for agents to execute
|
||||
4. **Observability** — Building monitoring that reveals agent behavior patterns
|
||||
5. **Iteration speed** — Rapidly testing and refining harness configurations
|
||||
|
||||
### Our Experience: What Works in Practice
|
||||
|
||||
We've been building AI-powered web applications using multiple agent systems (Claude Code, Codex, Cursor). The patterns that have made the biggest difference for us:
|
||||
|
||||
- **Repository-first documentation**: Every architectural decision, naming convention, and deployment process is in the repo. Nothing lives in Slack or Google Docs.
|
||||
- **Incremental constraint building**: Start with basic linting, add architectural constraints as patterns emerge, don't try to design the perfect harness upfront.
|
||||
- **Agent-specific review checklists**: AI-generated code has different failure modes than human code. Our review process accounts for common agent patterns (over-abstraction, unnecessary error handling, documentation drift).
|
||||
- **Multi-provider harness design**: Our harness works with Claude, GPT, and Gemini models. Provider-agnostic design means we can switch models without rebuilding the entire system.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Harness engineering is the new discipline** of designing systems that make AI agents reliable — constraints, feedback loops, documentation, and lifecycle management
|
||||
2. **The model is commodity; the harness is moat** — LangChain jumped from Top 30 to Top 5 on benchmarks by only changing the harness
|
||||
3. **OpenAI built 1M+ lines with zero human code** — proving harness engineering works at production scale
|
||||
4. **Three pillars**: Context engineering, architectural constraints, and entropy management
|
||||
5. **Start simple**: A good `AGENTS.md` and pre-commit hooks are more impactful than complex middleware
|
||||
6. **The engineer's job is evolving** — from writing code to designing environments where AI writes code
|
||||
7. **Build rippable harnesses** — over-engineering breaks when models improve; keep it adaptable
|
||||
|
||||
---
|
||||
|
||||
## Related Articles
|
||||
|
||||
- [Best AI for Coding in 2026: 10 Tools Ranked by Real-World Performance](https://www.nxcode.io/resources/news/best-ai-for-coding-2026-complete-ranking)
|
||||
- [OpenAI Frontier Guide: Enterprise AI Agent Platform for Building AI Coworkers (2026)](https://www.nxcode.io/resources/news/openai-frontier-enterprise-ai-agent-platform-guide-2026)
|
||||
- [Cursor Tutorial 2026: Learn AI Coding in 15 Minutes (Beginner Guide)](https://www.nxcode.io/resources/news/cursor-tutorial-beginners-2026)
|
||||
|
||||
[Back to all news](https://www.nxcode.io/resources/news)
|
||||
|
||||
Enjoyed this article?
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
title: "Harness design for long-running application development"
|
||||
source: "https://www.anthropic.com/engineering/harness-design-long-running-apps"
|
||||
author:
|
||||
published:
|
||||
created: 2026-04-11
|
||||
description: "Anthropic is an AI safety and research company that's working to build reliable, interpretable, and steerable AI systems."
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
*Written by Prithvi Rajasekaran, a member of our [Labs](https://www.anthropic.com/news/introducing-anthropic-labs) team.*
|
||||
|
||||
Over the past several months I’ve been working on two interconnected problems: getting Claude to produce high-quality frontend designs, and getting it to build complete applications without human intervention. This work originated with earlier efforts on our [frontend design skill](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md) and [long-running coding agent harness](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), where my colleagues and I were able to improve Claude’s performance well above baseline through prompt engineering and harness design—but both eventually hit ceilings.
|
||||
|
||||
To break through, I sought out novel AI engineering approaches that held across two quite different domains, one defined by subjective taste, the other by verifiable correctness and usability. Taking inspiration from [Generative Adversarial Networks](https://en.wikipedia.org/wiki/Generative_adversarial_network) (GANs), I designed a multi-agent structure with a **generator** and **evaluator** agent. Building an evaluator that graded outputs reliably—and with taste—meant first developing a set of criteria that could turn subjective judgments like “is this design good?” into concrete, gradable terms.
|
||||
|
||||
I then applied these techniques to long-running autonomous coding, carrying over two lessons from our earlier harness work: decomposing the build into tractable chunks, and using structured artifacts to hand off context between sessions. The final result was a three-agent architecture—planner, generator, and evaluator—that produced rich full-stack applications over multi-hour autonomous coding sessions.
|
||||
|
||||
## Why naive implementations fall short
|
||||
|
||||
We've previously shown that harness design has a substantial impact on the effectiveness of long running agentic coding. In an earlier [experiment](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), we used an initializer agent to decompose a product spec into a task list, and a coding agent that implemented the tasks one feature at a time before handing off artifacts to carry context across sessions. The broader developer community has converged on similar insights, with approaches like the " [Ralph Wiggum](https://ghuntley.com/ralph/) " method using hooks or scripts to keep agents in continuous iteration cycles.
|
||||
|
||||
But some problems remained persistent. For more complex tasks, the agent still tends to go off the rails over time. While decomposing this issue, we observed two common failure modes with agents executing these sorts of tasks.
|
||||
|
||||
First is that models tend to lose coherence on lengthy tasks as the context window fills (see our post on [context engineering](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)). Some models also exhibit "context anxiety," in which they begin wrapping up work prematurely as they approach what they believe is their context limit. Context resets—clearing the context window entirely and starting a fresh agent, combined with a structured handoff that carries the previous agent's state and the next steps—addresses both these issues.
|
||||
|
||||
This differs from compaction, where earlier parts of the conversation are summarized in place so the same agent can keep going on a shortened history. While compaction preserves continuity, it doesn't give the agent a clean slate, which means context anxiety can still persist. A reset provides a clean slate, at the cost of the handoff artifact having enough state for the next agent to pick up the work cleanly. In our earlier testing, we found Claude Sonnet 4.5 exhibited context anxiety strongly enough that compaction alone wasn't sufficient to enable strong long task performance, so context resets became essential to the harness design. This solves the core issue, but adds orchestration complexity, token overhead, and latency to each harness run.
|
||||
|
||||
A second issue, which we haven’t previously addressed, is self-evaluation. When asked to evaluate work they've produced, agents tend to respond by confidently praising the work—even when, to a human observer, the quality is obviously mediocre. This problem is particularly pronounced for subjective tasks like design, where there is no binary check equivalent to a verifiable software test. Whether a layout feels polished or generic is a judgment call, and agents reliably skew positive when grading their own work.
|
||||
|
||||
However, even on tasks that do have verifiable outcomes, agents still sometimes exhibit poor judgment that impedes their performance while completing the task. Separating the agent doing the work from the agent judging it proves to be a strong lever to address this issue. The separation doesn't immediately eliminate that leniency on its own; the evaluator is still an LLM that is inclined to be generous towards LLM-generated outputs. But tuning a standalone evaluator to be skeptical turns out to be far more tractable than making a generator critical of its own work, and once that external feedback exists, the generator has something concrete to iterate against.
|
||||
|
||||
## Frontend design: making subjective quality gradable
|
||||
|
||||
I started by experimenting on frontend design, where the self-evaluation issue was most visible. Absent any intervention, Claude normally gravitates toward safe, predictable layouts that are technically functional but visually unremarkable.
|
||||
|
||||
Two insights shaped the harness I built for frontend design. First, while aesthetics can’t be fully reduced to a score—and individual tastes will always vary—they can be improved with grading criteria that encode design principles and preferences. "Is this design beautiful?" is hard to answer consistently, but "does this follow our principles for good design?" gives Claude something concrete to grade against. Second, by separating frontend generation from frontend grading, we can create a feedback loop that drives the generator toward stronger outputs.
|
||||
|
||||
With this in mind, I wrote four grading criteria that I gave to both the generator and evaluator agents in their prompts:
|
||||
|
||||
- **Design quality:** Does the design feel like a coherent whole rather than a collection of parts? Strong work here means the colors, typography, layout, imagery, and other details combine to create a distinct mood and identity.
|
||||
- **Originality:** Is there evidence of custom decisions, or is this template layouts, library defaults, and AI-generated patterns? A human designer should recognize deliberate creative choices. Unmodified stock components—or telltale signs of AI generation like purple gradients over white cards—fail here.
|
||||
- **Craft:** Technical execution: typography hierarchy, spacing consistency, color harmony, contrast ratios. This is a competence check rather than a creativity check. Most reasonable implementations do fine here by default; failing means broken fundamentals.
|
||||
- **Functionality:** Usability independent of aesthetics. Can users understand what the interface does, find primary actions, and complete tasks without guessing?
|
||||
|
||||
I emphasized design quality and originality over craft and functionality. Claude already scored well on craft and functionality by default, as the required technical competence tended to come naturally to the model. But on design and originality, Claude often produced outputs that were bland at best. The criteria explicitly penalized highly generic “AI slop” patterns, and by weighting design and originality more heavily it pushed the model toward more aesthetic risk-taking.
|
||||
|
||||
I calibrated the evaluator using few-shot examples with detailed score breakdowns. This ensured the evaluator’s judgment aligned with my preferences, and reduced score drift across iterations.
|
||||
|
||||
I built the loop on the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), which kept the orchestration straightforward. A generator agent first created an HTML/CSS/JS frontend based on a user prompt. I gave the evaluator the Playwright MCP, which let it interact with the live page directly before scoring each criterion and writing a detailed critique. In practice, the evaluator would navigate the page on its own, screenshotting and carefully studying the implementation before producing its assessment. That feedback flowed back to the generator as input for the next iteration. I ran 5 to 15 iterations per generation, with each iteration typically pushing the generator in a more distinctive direction as it responded to the evaluator's critique. Because the evaluator was actively navigating the page rather than scoring a static screenshot, each cycle took real wall-clock time. Full runs stretched up to four hours. I also instructed the generator to make a strategic decision after each evaluation: refine the current direction if scores were trending well, or pivot to an entirely different aesthetic if the approach wasn't working.
|
||||
|
||||
Across runs, the evaluator's assessments improved over iterations before plateauing, with headroom still remaining. Some generations refined incrementally. Others took sharp aesthetic turns between iterations.
|
||||
|
||||
The wording of the criteria steered the generator in ways I didn't fully anticipate. Including phrases like "the best designs are museum quality" pushed designs toward a particular visual convergence, suggesting that the prompting associated with the criteria directly shaped the character of the output.
|
||||
|
||||
While scores generally improved over iterations, the pattern was not always cleanly linear. Later implementations tended to be better as a whole, but I regularly saw cases where I preferred a middle iteration over the last one. Implementation complexity also tended to increase across rounds, with the generator reaching for more ambitious solutions in response to the evaluator’s feedback. Even on the first iteration, outputs were noticeably better than a baseline with no prompting at all, suggesting the criteria and associated language themselves steered the model away from generic defaults before any evaluator feedback led to further refinement.
|
||||
|
||||
In one notable example, I prompted the model to create a website for a Dutch art museum. By the ninth iteration, it had produced a clean, dark-themed landing page for a fictional museum. The page was visually polished but largely in line with my expectations. Then, on the tenth cycle, it scrapped the approach entirely and reimagined the site as a spatial experience: a 3D room with a checkered floor rendered in CSS perspective, artwork hung on the walls in free-form positions, and doorway-based navigation between gallery rooms instead of scroll or click. It was the kind of creative leap that I hadn't seen before from a single-pass generation.
|
||||
|
||||
<video controls="" src="https://cdn.sanity.io/files/4zrzovbb/website/9877febd34432f7f582aecd0023b951223605c6a.mp4"></video>
|
||||
|
||||
## Scaling to full-stack coding
|
||||
|
||||
With these findings in hand, I applied this GAN-inspired pattern to full-stack development. The generator-evaluator loop maps naturally onto the software development lifecycle, where code review and QA serve the same structural role as the design evaluator.
|
||||
|
||||
### The architecture
|
||||
|
||||
In our earlier [long-running harness](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), we had solved for coherent multi-session coding with an initializer agent, a coding agent that worked one feature at a time, and context resets between sessions. Context resets were a key unlock: the harness used Sonnet 4.5, which exhibited the “context anxiety” tendency mentioned earlier. Creating a harness that worked well across context resets was key to keeping the model on task. Opus 4.5 largely removed that behavior on its own, so I was able to drop context resets from this harness entirely. The agents were run as one continuous session across the whole build, with the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) 's automatic compaction handling context growth along the way.
|
||||
|
||||
For this work I built on the foundation from the original harness with a three-agent system, with each agent addressing a specific gap I'd observed in prior runs. The system contained the following agent personas:
|
||||
|
||||
**Planner:** Our previous long-running harness required the user to provide a detailed spec upfront. I wanted to automate that step, so I created a planner agent that took a simple 1-4 sentence prompt and expanded it into a full product spec. I prompted it to be ambitious about scope and to stay focused on product context and high level technical design rather than detailed technical implementation. This emphasis was due to the concern that if the planner tried to specify granular technical details upfront and got something wrong, the errors in the spec would cascade into the downstream implementation. It seemed smarter to constrain the agents on the deliverables to be produced and let them figure out the path as they worked. I also asked the planner to find opportunities to weave AI features into the product specs. (See example in the Appendix at the bottom.)
|
||||
|
||||
**Generator:** The one-feature-at-a-time approach from the earlier harness worked well for scope management. I applied a similar model here, instructing the generator to work in sprints, picking up one feature at a time from the spec. Each sprint implemented the app with a React, Vite, FastAPI, and SQLite (later PostgreSQL) stack, and the generator was instructed to self-evaluate its work at the end of each sprint before handing off to QA. It also had git for version control.
|
||||
|
||||
**Evaluator:** Applications from earlier harnesses often looked impressive but still had real bugs when you actually tried to use them. To catch these, the evaluator used the Playwright MCP to click through the running application the way a user would, testing UI features, API endpoints, and database states. It then graded each sprint against both the bugs it had found and a set of criteria modeled on the frontend experiment, adapted here to cover product depth, functionality, visual design, and code quality. Each criterion had a hard threshold, and if any one fell below it, the sprint failed and the generator got detailed feedback on what went wrong.
|
||||
|
||||
Before each sprint, the generator and evaluator negotiated a sprint contract: agreeing on what "done" looked like for that chunk of work before any code was written. This existed because the product spec was intentionally high-level, and I wanted a step to bridge the gap between user stories and testable implementation. The generator proposed what it would build and how success would be verified, and the evaluator reviewed that proposal to make sure the generator was building the right thing. The two iterated until they agreed.
|
||||
|
||||
Communication was handled via files: one agent would write a file, another agent would read it and respond either within that file or with a new file that the previous agent would read in turn. The generator then built against the agreed-upon contract before handing the work off to QA. This kept the work faithful to the spec without over-specifying implementation too early.
|
||||
|
||||
### Running the harness
|
||||
|
||||
For the first version of this harness, I used Claude Opus 4.5, running user prompts against both the full harness and a single-agent system for comparison. I used Opus 4.5 since this was our best coding model when I began these experiments.
|
||||
|
||||
I wrote the following prompt to generate a retro video game maker:
|
||||
|
||||
> *Create a 2D retro game maker with features including a level editor, sprite editor, entity behaviors, and a playable test mode.*
|
||||
|
||||
The table below shows the harness type, length it ran for, and the total cost.
|
||||
|
||||
| **Harness** | **Duration** | **Cost** |
|
||||
| --- | --- | --- |
|
||||
| Solo | 20 min | $9 |
|
||||
| Full harness | 6 hr | $200 |
|
||||
|
||||
The harness was over 20x more expensive, but the difference in output quality was immediately apparent.
|
||||
|
||||
I was expecting an interface where I could construct a level and its component parts (sprites, entities, tile layout) then hit play to actually play the level. I started by opening the solo run’s output, and the initial application seemed in line with those expectations.
|
||||
|
||||
As I clicked through, however, issues started to emerge. The layout wasted space, with fixed-height panels leaving most of the viewport empty. The workflow was rigid. Trying to populate a level prompted me to create sprites and entities first, but nothing in the UI guided me toward that sequence. More to the point, the actual game was broken. My entities appeared on screen but nothing responded to input. Digging into the code revealed that the wiring between entity definitions and the game runtime was broken, with no surface indication of where.
|
||||
|
||||
![[23c98f1d7ae720bfb39190d50e0706c03b177ad8-1999x1320.webp]]
|
||||
|
||||
Initial screen when opening the app created by the solo harness.
|
||||
|
||||
|
||||
|
||||
After evaluating the solo run, I turned my attention to the harness run. This run started from the same one-sentence prompt, but the planner step expanded that prompt into a 16-feature spec spread across ten sprints. It went well beyond what the solo run attempted. In addition to the core editors and play mode, the spec called for a sprite animation system, behavior templates, sound effects and music, an AI-assisted sprite generator and level designer, and game export with shareable links. I gave the planner access to our [frontend design skill](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md), which it read and used to create a visual design language for the app as part of the spec. For each sprint, the generator and evaluator negotiated a contract defining the specific implementation details for the sprint, and the testable behaviors that would be tested to verify completion.
|
||||
|
||||
The app immediately showed more polish and smoothness than the solo run. The canvas used the full viewport, the panels were sized sensibly, and the interface had a consistent visual identity that tracked the design direction from the spec. Some of the clunkiness I'd seen in the solo run did remain—the workflow still didn't make it clear that you should build sprites and entities before trying to populate a level, and I had to figure that out by poking around. This read as a gap in the base model’s product intuition rather than something the harness was designed to address, though it did suggest a place where targeted iteration inside the harness could help to further improve output quality.
|
||||
|
||||
Working through the editors, the new run's advantages over solo became more apparent. The sprite editor was richer and more fully featured, with cleaner tool palettes, a better color picker, and more usable zoom controls.
|
||||
|
||||
Because I'd asked the planner to weave AI features into its specs, the app also came with a built-in Claude integration that let me generate different parts of the game through prompting. This significantly sped up the workflow.
|
||||
|
||||
![[a8bef95425966495629095a5cb38bde4a8b13558-1999x997.webp]]
|
||||
|
||||
Initial screen: Creating a new game, in the app built with the full harness
|
||||
|
||||
The biggest difference was in play mode. I was actually able to move my entity and play the game. The physics had some rough edges—my character jumped onto a platform but ended up overlapping with it, which felt intuitively wrong—but the core thing worked, which the solo run did not manage. After moving around a bit, I did hit some limitations with the AI’s game level construction. There was a large wall that I wasn’t able to jump past, so I was stuck. This suggested there were some common sense improvements and edge cases that the harness could handle to further refine the app.
|
||||
|
||||
Reading through the logs, it was clear that the evaluator kept the implementation in line with the spec. Each sprint, it walked through the sprint contract's test criteria and exercised the running application through Playwright, filing bugs against anything that diverged from expected behavior. The contracts were granular—Sprint 3 alone had 27 criteria covering the level editor—and the evaluator's findings were specific enough to act on without extra investigation. The table below shows several examples of issues our evaluator identified:
|
||||
|
||||
| **Contract criterion** | **Evaluator finding** |
|
||||
| --- | --- |
|
||||
| Rectangle fill tool allows click-drag to fill a rectangular area with selected tile | **FAIL** — Tool only places tiles at drag start/end points instead of filling the region. `fillRectangle` function exists but isn't triggered properly on mouseUp. |
|
||||
| User can select and delete placed entity spawn points | **FAIL** — Delete key handler at `LevelEditor.tsx:892` requires both `selection` and `selectedEntityId ` to be set, but clicking an entity only sets `selectedEntityId`. Condition should be `selection \|\| (selectedEntityId && activeLayer === 'entity')`. |
|
||||
| User can reorder animation frames via API | **FAIL** — `PUT /frames/reorder` route defined after `/{frame_id}` routes. FastAPI matches 'r `eorder` ' as a frame\_id integer and returns 422: "unable to parse string as an integer." |
|
||||
|
||||
Getting the evaluator to perform at this level took work. Out of the box, Claude is a poor QA agent. In early runs, I watched it identify legitimate issues, then talk itself into deciding they weren't a big deal and approve the work anyway. It also tended to test superficially, rather than probing edge cases, so more subtle bugs often slipped through. The tuning loop was to read the evaluator's logs, find examples where its judgment diverged from mine, and update the QAs prompt to solve for those issues. It took several rounds of this development loop before the evaluator was grading in a way that I found reasonable. Even then, the harness output showed the limits of the model’s QAing capabilities: small layout issues, interactions that felt unintuitive in places, and undiscovered bugs in more deeply nested features that the evaluator hadn't exercised thoroughly. There was clearly more verification headroom to capture with further tuning. But compared to the solo run, where the central feature of the application simply didn't work, the lift was obvious.
|
||||
|
||||
### Iterating on the harness
|
||||
|
||||
The first set of harness results was encouraging, but it was also bulky, slow, and expensive. The logical next step was to find ways to simplify the harness without degrading its performance. This was partly common sense and partly a function of a more general principle: every component in a harness encodes an assumption about what the model can't do on its own, and those assumptions are worth stress testing, both because they may be incorrect, and because they can quickly go stale as models improve. Our blog post [Building Effective Agents](https://www.anthropic.com/research/building-effective-agents) frames the underlying idea as "find the simplest solution possible, and only increase complexity when needed," and it's a pattern that shows up consistently for anyone maintaining an agent harness.
|
||||
|
||||
In my first attempt to simplify, I cut the harness back radically and tried a few creative new ideas, but I wasn't able to replicate the performance of the original. It also became difficult to tell which pieces of the harness design were actually load-bearing, and in what ways. Based on that experience, I moved to a more methodical approach, removing one component at a time and reviewing what impact it had on the final result.
|
||||
|
||||
As I was going through these iteration cycles, we also released Opus 4.6, which provided further motivation to reduce harness complexity. There was good reason to expect 4.6 would need less scaffolding than 4.5 did. From our [launch blog:](https://www.anthropic.com/news/claude-opus-4-6) "\[Opus 4.6\] plans more carefully, sustains agentic tasks for longer, can operate more reliably in larger codebases, and has better code review and debugging skills to catch its own mistakes." It also improved substantially on long-context retrieval. These were all capabilities the harness had been built to supplement.
|
||||
|
||||
### Removing the sprint construct
|
||||
|
||||
I started by removing the sprint construct entirely. The sprint structure had helped to decompose work into chunks for the model to work coherently. Given the improvements in Opus 4.6, there was good reason to believe that the model could natively handle the job without this sort of decomposition.
|
||||
|
||||
I kept both the planner and evaluator, as each continued to add obvious value. Without the planner, the generator under-scoped: given the raw prompt, it would start building without first speccing its work, and end up creating a less feature-rich application than the planner did.
|
||||
|
||||
With the sprint construct removed, I moved the evaluator to a single pass at the end of the run rather than grading per sprint. Since the model was much more capable, it changed how load-bearing the evaluator was for certain runs, with its usefulness depending on where the task sat relative to what the model could do reliably on its own. On 4.5, that boundary was close: our builds were at the edge of what the generator could do well solo, and the evaluator caught meaningful issues across the build. On 4.6, the model's raw capability increased, so the boundary moved outward. Tasks that used to need the evaluator's check to be implemented coherently were now often within what the generator handled well on its own, and for tasks within that boundary, the evaluator became unnecessary overhead. But for the parts of the build that were still at the edge of the generator’s capabilities, the evaluator continued to give real lift.
|
||||
|
||||
The practical implication is that the evaluator is not a fixed yes-or-no decision. It is worth the cost when the task sits beyond what the current model does reliably solo.
|
||||
|
||||
Alongside the structural simplification, I also added prompting to improve how the harness built AI features into each app, specifically getting the generator to build a proper agent that could drive the app's own functionality through tools. That took real iteration, since the relevant knowledge is recent enough that Claude's training data covers it thinly. But with enough tuning, the generator was building agents correctly.
|
||||
|
||||
### Results from the updated harness
|
||||
|
||||
To put the updated harness to the test, I used the following prompt to generate a Digital Audio Workstation (DAW), a music production program for composing, recording, and mixing songs:
|
||||
|
||||
> *Build a fully featured DAW in the browser using the Web Audio API.*
|
||||
|
||||
The run was still lengthy and expensive, at about 4 hours and $124 in token costs.
|
||||
|
||||
Most of the time went to the builder, which ran coherently for over two hours without the sprint decomposition that Opus 4.5 had needed.
|
||||
|
||||
| **Agent & Phase** | **Duration** | **Cost** |
|
||||
| --- | --- | --- |
|
||||
| Planner | 4.7 min | $0.46 |
|
||||
| Build (Round 1) | 2 hr 7 min | $71.08 |
|
||||
| QA (Round 1) | 8.8 min | $3.24 |
|
||||
| Build (Round 2) | 1 hr 2 min | $36.89 |
|
||||
| QA (Round 2) | 6.8 min | $3.09 |
|
||||
| Build (Round 3) | 10.9 min | $5.88 |
|
||||
| QA (Round 3) | 9.6 min | $4.06 |
|
||||
| **Total V2 Harness** | **3 hr 50 min** | **$124.70** |
|
||||
|
||||
As with the previous harness, the planner expanded the one-line prompt into a full spec. From the logs, I could see the generator model did a good job planning the app and the agent design, wiring the agent up, and testing it before handing off to QA.
|
||||
|
||||
That being said, the QA agent still caught real gaps. In its first-round feedback, it noted:
|
||||
|
||||
> This is a strong app with excellent design fidelity, solid AI agent, and good backend. The main failure point is Feature Completeness — while the app looks impressive and the AI integration works well, several core DAW features are display-only without interactive depth: clips can't be dragged/moved on the timeline, there are no instrument UI panels (synth knobs, drum pads), and no visual effect editors (EQ curves, compressor meters). These aren't edge cases — they're the core interactions that make a DAW usable, and the spec explicitly calls for them.
|
||||
|
||||
In its second round feedback, it again caught several functionality gaps:
|
||||
|
||||
> Remaining gaps:
|
||||
> \- Audio recording is still stub-only (button toggles but no mic capture)
|
||||
> \- Clip resize by edge drag and clip split not implemented
|
||||
> \- Effect visualizations are numeric sliders, not graphical (no EQ curve)
|
||||
|
||||
The generator was still liable to miss details or stub features when left to its own devices, and the QA still added value in catching those last mile issues for the generator to fix.
|
||||
|
||||
Based on the prompt, I was expecting a program where I could create melodies, harmonies, and drum patterns, arrange them into a song, and get help from an integrated agent along the way. The video below shows the result.
|
||||
|
||||
<video controls="" src="https://cdn.sanity.io/files/4zrzovbb/website/555910f9adb3938734940224e7a6f4c7cbbbd8f2.mp4"></video>
|
||||
|
||||
The app is far from a professional music production program, and the agent's song composition skills could clearly use a lot of work. Additionally, Claude can’t actually hear, which made the QA feedback loop less effective with respect to musical taste.
|
||||
|
||||
But the final app had all the core pieces of a functional music production program: a working arrangement view, mixer, and transport running in the browser. Beyond that, I was able to put together a short song snippet entirely through prompting: the agent set the tempo and key, laid down a melody, built a drum track, adjusted mixer levels, and added reverb. The core primitives for song composition were present, and the agent could drive them autonomously, using tools to create a simple production from end to end. You might say it’s not pitch-perfect yet—but it’s getting there.
|
||||
|
||||
## What comes next
|
||||
|
||||
As models continue to improve, we can roughly expect them to be capable of working for longer, and on more complex tasks. In some cases, that will mean the scaffold surrounding the model matters less over time, and developers can wait for the next model and see certain problems solve themselves. On the other hand, the better the models get, the more space there is to develop harnesses that can achieve complex tasks beyond what the model can do at baseline.
|
||||
|
||||
With this in mind, there are a few lessons from this work worth carrying forward. It is always good practice to experiment with the model you're building against, read its traces on realistic problems, and tune its performance to achieve your desired outcomes. When working on more complex tasks, there is sometimes headroom from decomposing the task and applying specialized agents to each aspect of the problem. And when a new model lands, it is generally good practice to re-examine a harness, stripping away pieces that are no longer load-bearing to performance and adding new pieces to achieve greater capability that may not have been possible before.
|
||||
|
||||
From this work, my conviction is that the space of interesting harness combinations doesn't shrink as models improve. Instead, it moves, and the interesting work for AI engineers is to keep finding the next novel combination.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Special thanks to Mike Krieger, Michael Agaby, Justin Young, Jeremy Hadfield, David Hershey, Julius Tarng, Xiaoyi Zhang, Barry Zhang, Orowa Sidker, Michael Tingley, Ibrahim Madha, Martina Long, and Canyon Robbins for their contributions to this work.
|
||||
|
||||
Thanks also to Jake Eaton, Alyssa Leonard, and Stef Sequeira for their help shaping the post.
|
||||
|
||||
## Appendix
|
||||
|
||||
Example plan generated by planner agent.
|
||||
|
||||
```
|
||||
RetroForge - 2D Retro Game Maker
|
||||
|
||||
Overview
|
||||
RetroForge is a web-based creative studio for designing and building 2D retro-style video games. It combines the nostalgic charm of classic 8-bit and 16-bit game aesthetics with modern, intuitive editing tools—enabling anyone from hobbyist creators to indie developers to bring their game ideas to life without writing traditional code.
|
||||
|
||||
The platform provides four integrated creative modules: a tile-based Level Editor for designing game worlds, a pixel-art Sprite Editor for crafting visual assets, a visual Entity Behavior system for defining game logic, and an instant Playable Test Mode for real-time gameplay testing. By weaving AI assistance throughout (powered by Claude), RetroForge accelerates the creative process—helping users generate sprites, design levels, and configure behaviors through natural language interaction.
|
||||
|
||||
RetroForge targets creators who love retro gaming aesthetics but want modern conveniences. Whether recreating the platformers, RPGs, or action games of their childhood, or inventing entirely new experiences within retro constraints, users can prototype rapidly, iterate visually, and share their creations with others.
|
||||
|
||||
Features
|
||||
1. Project Dashboard & Management
|
||||
The Project Dashboard is the home base for all creative work in RetroForge. Users need a clear, organized way to manage their game projects—creating new ones, returning to works-in-progress, and understanding what each project contains at a glance.
|
||||
|
||||
User Stories: As a user, I want to:
|
||||
|
||||
- Create a new game project with a name and description, so that I can begin designing my game
|
||||
- See all my existing projects displayed as visual cards showing the project name, last modified date, and a thumbnail preview, so that I can quickly find and continue my work
|
||||
- Open any project to enter the full game editor workspace, so that I can work on my game
|
||||
- Delete projects I no longer need, with a confirmation dialog to prevent accidents, so that I can keep my workspace organized
|
||||
- Duplicate an existing project as a starting point for a new game, so that I can reuse my previous work
|
||||
|
||||
Project Data Model: Each project contains:
|
||||
|
||||
Project metadata (name, description, created/modified timestamps)
|
||||
Canvas settings (resolution: e.g., 256x224, 320x240, or 160x144)
|
||||
Tile size configuration (8x8, 16x16, or 32x32 pixels)
|
||||
Color palette selection
|
||||
All associated sprites, tilesets, levels, and entity definitions
|
||||
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: "Harness engineering for coding agent users"
|
||||
source: "https://martinfowler.com/articles/harness-engineering.html"
|
||||
author:
|
||||
- "[[Birgitta Böckeler Birgitta is a Distinguished Engineer and AI-assisted delivery expert at Thoughtworks. She has over 20 years of experience as a software developer]]"
|
||||
- "[[architect and technical leader.]]"
|
||||
published: 2026-04-02
|
||||
created: 2026-04-11
|
||||
description: "A mental model for building trust in coding agents through feedforward guides, feedback sensors, and iterative harness engineering."
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
The term harness has emerged as a shorthand to mean everything in an AI agent except the model itself - [Agent = Model + Harness](https://blog.langchain.com/the-anatomy-of-an-agent-harness/). That is a very wide definition, and therefore worth narrowing down for common categories of agents. I want to take the liberty here of defining its meaning in the bounded context of using a coding agent. In coding agents, part of the harness is already built in (e.g. via the system prompt, or the chosen code retrieval mechanism, or even a [sophisticated orchestration system](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents)). But coding agents also provide us, their users, with many features to build an outer harness specifically for our use case and system.
|
||||
|
||||
![[harness-bounded-contexts.png|Three concentric circles, with the model in the core (the ultimate thing being harnessed), then the coding agent's builder harness next circle out, and the coding agent's user harness as the outermost ring]]
|
||||
|
||||
Figure 1: The term “harness” means different things depending on the bounded context.
|
||||
|
||||
A well-built outer harness serves two goals: it increases the probability that the agent gets it right in the first place, and it provides a feedback loop that self-corrects as many issues as possible before they even reach human eyes. Ultimately it should reduce the review toil and increase the system quality, all with the added benefit of fewer wasted tokens along the way.
|
||||
|
||||
![[harness-overview.png|Title "Harness engineering for coding agent users". Overview of guides (examples shown are [inferential] principles, CfRs, Rules, Ref Docs, How-tos; [computational] Language Servers, CLIs, scripts, codemods) that feedforward into a coding agent; and feedback sensors (examples shown are [inferential] review agents; [computational] static analysis, logs, browser). The feedback sensors point at the coding agent as well as input into its self-correcting loop. On the left side of it all we see a box with a human who steers both the guides and sensors.]]
|
||||
|
||||
## Computational vs Inferential
|
||||
|
||||
There are two execution types of guides and sensors:
|
||||
|
||||
- **Computational** - deterministic and fast, run by the CPU. Tests, linters, type checkers, structural analysis. Run in milliseconds to seconds; results are reliable.
|
||||
- **Inferential** - Semantic analysis, AI code review, “LLM as judge”. Typically run by a GPU or NPU. Slower and more expensive; results are more non-deterministic.
|
||||
|
||||
Computational guides increase the probability of good results with deterministic tooling. Computational sensors are cheap and fast enough to run on every change, alongside the agent. Inferential controls are of course more expensive and non-deterministic, but allow us to both provide rich guidance, and add additional semantic judgment. In spite of their non-determinism, inferential sensors can particularly increase our trust when used with a strong model, or rather a model that is suitable to the task at hand.
|
||||
|
||||
**Examples**
|
||||
|
||||
| | Direction | Computational / Inferential | Example implementations |
|
||||
| --- | --- | --- | --- |
|
||||
| Coding conventions | feedforward | Inferential | AGENTS.md, Skills |
|
||||
| Instructions how to bootstrap a new project | feedforward | Both | Skill with instructions and a bootstrap script |
|
||||
| Code mods | feedforward | Computational | A tool with access to OpenRewrite recipes |
|
||||
| Structural tests | feedback | Computational | A pre-commit (or coding agent) hook running ArchUnit tests that check for violations of module boundaries |
|
||||
| Instructions how to review | feedback | Inferential | Skills |
|
||||
|
||||
## The steering loop
|
||||
|
||||
The human's job in this is to **steer** the agent by iterating on the harness. Whenever an issue happens multiple times, the feedforward and feedback controls should be improved to make the issue less probable to occur in the future, or even prevent it.
|
||||
|
||||
In the steering loop, we can of course also use AI to improve the harness. Coding agents now make it much cheaper to build more custom controls and more custom static analysis. Agents can help write structural tests, generate draft rules from observed patterns, scaffold custom linters, or create how-to guides from codebase archaeology.
|
||||
|
||||
## Timing: Keep quality left
|
||||
|
||||
Teams who are [continuously integrating](https://martinfowler.com/articles/continuousIntegration.html) have always faced the challenge of spreading tests, checks and human reviews across the development timeline according to their cost, speed and criticality. When you aspire to [continuously deliver](https://martinfowler.com/bliki/ContinuousDelivery.html), you ideally even want every commit state to be deployable. You want to have checks as far left in the path to production as possible, since the earlier you find issues, the cheaper they are to fix. Feedback sensors, including the new inferential ones, need to be distributed across the lifecycle accordingly.
|
||||
|
||||
**Feedforward and feedback in the change lifecycle**
|
||||
|
||||
- What is reasonably fast and should be run even before integration, or even before a commit is even created? (e.g. linters, fast test suites, basic code review agent)
|
||||
- What is more expensive and should therefore only be run post-integration in the pipeline, in addition to a repetition of the fast controls? (e.g. mutation testing, a more broad code review that can take into account the bigger picture)
|
||||
![[harness-change-lifecycle-examples.png|Examples of feedforward and feedback in a change's lifecycle. Feedforward: LSP, architecture.md, /how-to-test skill, AGENTS.md, MCP server that can access a team's knowledge management tool, /xyz-api-docs skill; they feed into the agent's initial generation; feedback sensor examples for first self-correction loop are /code-review, npx eslint, semgrep, npm run coverage, npm run dep-cruiser; then human review is an additional feedback sensor; then integration happens; after integration, examples shown in the pipeline, which reruns all the previous sensors, and additional examples for more expensive sensors are /architecture-review skill, /detailed-review skill, mutation testing. An arrow shows that the feedback can then lead to new commits by agents or humans.]]
|
||||
|
||||
**Continuous drift and health sensors**
|
||||
|
||||
- What type of drift accumulates gradually and should be monitored by sensors running continuously against the codebase, outside the change lifecycle? (e.g. dead code detection, analysis of the quality of the test coverage, dependency scanners)
|
||||
- What runtime feedback could agents be monitoring? (e.g. having them look for degrading SLOs to make suggestions how to improve them, or AI judges continuously sampling response quality and flagging log anomalies)
|
||||
|
||||
## Regulation categories
|
||||
|
||||
The agent harness acts like a [cybernetic](https://en.wikipedia.org/wiki/Cybernetics) governor, combining feed-forward and feedback to regulate the codebase towards its desired state. It's useful to distinguish between multiple dimensions of that desired state, categorised by what the harness is supposed to regulate. Distinguishing between these categories helps because harnessability and complexity vary across them, and qualifying the word gives us more precise language for a term that is otherwise very generic.
|
||||
|
||||
The following are three categories that seem useful to me as of now:
|
||||
|
||||
### Maintainability harness
|
||||
|
||||
More or less all of the examples I am giving in this article are about regulating internal code quality and maintainability. This is at the moment the easiest type of harness, as we have a lot of pre-existing tooling that we can use for this.
|
||||
|
||||
To reflect on how much these aforementioned maintainability harness ideas increase my trust in agents, I mapped [common coding agent failure modes that I catalogued before](https://martinfowler.com/articles/exploring-gen-ai/13-role-of-developer-skills.html) against it.
|
||||
|
||||
Computational sensors catch the structural stuff reliably: duplicate code, cyclomatic complexity, missing test coverage, architectural drift, style violations. These are cheap, proven, and deterministic.
|
||||
|
||||
LLMs can partially address problems that require semantic judgment - semantically duplicate code, redundant tests, brute-force fixes, over-engineered solutions - but expensively and probabilistically. Not on every commit.
|
||||
|
||||
Neither catches reliably some of the higher-impact problems: Misdiagnosis of issues, overengineering and unnecessary features, misunderstood instructions. They'll sometimes catch them, but not reliably enough to reduce supervision. Correctness is outside any sensor's remit if the human didn't clearly specify what they wanted in the first place.
|
||||
|
||||
### Architecture fitness harness
|
||||
|
||||
This groups guides and sensors that define and check the architecture characteristics of the application. Basically: [Fitness Functions](https://www.thoughtworks.com/en-de/radar/techniques/architectural-fitness-function).
|
||||
|
||||
Examples:
|
||||
|
||||
- Skills that feed forward our performance requirements, and performance tests that feed back to the agent if it improved or degraded them.
|
||||
- Skills that describe coding conventions for better observability (like logging standards), and debugging instructions that ask the agent to reflect on the quality of the logs it had available.
|
||||
|
||||
### Behaviour harness
|
||||
|
||||
This is the elephant in the room - how do we guide and sense if the application functionally behaves the way we need it to? At the moment, I see most people who give high autonomy to their coding agents do this:
|
||||
|
||||
- Feed-forward: A functional specification (of varying levels of detail, from a short prompt to multi-file descriptions)
|
||||
- Feed-back: Check if the AI-generated test suite is green, has reasonably high coverage, some might even monitor its quality with mutation testing. Then combine that with manual testing.
|
||||
|
||||
This approach puts a lot of faith into the AI-generated tests, that's not good enough yet. Some of my colleagues are seeing good results with the [approved fixtures](https://lexler.github.io/augmented-coding-patterns/patterns/approved-fixtures/) pattern, but it's easier to apply in some areas than others. They use it selectively where it fits, it's not a wholesale answer to the test quality problem.
|
||||
|
||||
So overall, we still have a lot to do to figure out good harnesses for functional behaviour that increase our confidence enough to reduce supervision and manual testing.
|
||||
|
||||
![[harness-types.png|Simplified overview of a harness showing guides and sensors in horizontal, and then the regulation dimensions maintainability, architecture fitness, and behaviour, in vertical. Examples shown for the behaviour harness, spec as feedforward guide, test suite as feedback sensor that is a mix of inferential and computational, plus a human icon indicating human review and manual tests as main additional feedback sensor.]]
|
||||
|
||||
## Harnessability
|
||||
|
||||
Not every codebase is equally amenable to harnessing. A codebase written in a strongly typed language naturally has type-checking as a sensor; clearly definable module boundaries afford architectural constraint rules; frameworks like Spring abstract away details the agent doesn't even have to worry about and therefore implicitly increase the agent's chances of success. Without those properties, those controls aren't available to build.
|
||||
|
||||
This plays out differently for greenfield versus legacy. Greenfield teams can bake harnessability in from day one - technology decisions and architecture choices determine how governable the codebase will be. Legacy teams, especially with applications that have accrued a lot of technical debt, face the harder problem: the harness is most needed where it is hardest to build.
|
||||
|
||||
## Harness templates
|
||||
|
||||
Most enterprises have a few common topologies of services that cover 80% of what they need - business services that exposes data via APIs; event processing services; data dashboards. In many mature engineering organizations these topologies are already codified in service templates. These might evolve into harness templates in the future: a bundle of guides and sensors that leash a coding agent to the structure, conventions and tech stack of a topology. Teams may start picking tech stacks and structures partly based on what harnesses are already available for them.
|
||||
|
||||
![[harness-templates.png|A stack of examples of topologies (Data dashboard in Node, CRUD business service on JVM, event processor in Golang). The top one, data dashboard, is shown in detail, as a combination of structure definition and tech stack. The graphic indicates a "harness template" with guides and sensors for each topology, which can be instantiated.]]
|
||||
|
||||
We would of course face similar challenges as with service templates. As soon as teams instantiate them, they start fall out of sync with upstream improvements. Harness templates would face the same versioning and contribution problems, maybe even worse with non-deterministic guides and sensors that are harder to test.
|
||||
|
||||
## The role of the human
|
||||
|
||||
As human developers we bring our skills and experience as an implicit harness to every codebase. We absorbed conventions and good practices, we have felt the cognitive pain of complexity, and we know that our name is on the commit. We also carry organisational alignment - awareness of what the team is trying to achieve, which technical debt is tolerated for business reasons, and what “good” looks like in this specific context. We go in small steps and at our human pace, which creates the thinking space for that experience to get triggered and applied.
|
||||
|
||||
A coding agent has none of this: no social accountability, no aesthetic disgust at a 300-line function, no intuition that “we don't do it that way here,” and no organisational memory. It doesn't know which convention is load-bearing and which is just habit, or whether the technically correct solution fits what the team is trying to do.
|
||||
|
||||
Harnesses are an attempt to externalise and make explicit what human developer experience brings to the table, but it can only go so far. Building a coherent system of guides and sensors and self-correction loops is expensive, so we have to prioritise with a clear goal in mind: A good harness should not necessarily aim to fully eliminate human input, but to direct it to where our input is most important.
|
||||
|
||||
## A starting point - and open questions
|
||||
|
||||
The mental model I've laid out here describes techniques that are already happening in practice and helps frame discussions about what we still need to figure out. Its goal is to raise the conversation above the feature level - from skills and MCP servers to how we strategically design a system of controls that gives us genuine confidence in what agents produce.
|
||||
|
||||
Here are some harness-related examples from the current discourse:
|
||||
|
||||
- [An OpenAI team documented what their harness looks like](https://openai.com/index/harness-engineering/): layered architecture enforced by custom linters and structural tests, and recurring “garbage collection” that scans for drift and has agents suggest fixes. Their conclusion: “Our most difficult challenges now center on designing environments, feedback loops, and control systems.”
|
||||
- [Stripe's write-up about their minions](https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents) describes things like pre-push hooks that run relevant linters based on a heuristic, they highlight how important “shift feedback left” is to them, and their “blueprints” show how they're integrating feedback sensors into the agent workflows.
|
||||
- Mutation and structural testing are examples of computational feedback sensors that have been underused in the past, but are now having a resurgence.
|
||||
- There is increased chatter among developers about the integration of LSPs and code intelligence in coding agents, examples of computational feedforward guides.
|
||||
- I hear stories from teams at Thoughtworks about tackling architecture drift with both computational and inferential sensors, e.g. increasing API quality with a mix of agents and custom linters, or increasing code quality with a “janitor army”.
|
||||
|
||||
There's plenty still to figure out, not just the already mentioned behavioural harness. How do we keep a harness coherent as it grows, with guides and sensors in sync, not contradicting each other? How far can we trust agents to make sensible trade-offs when instructions and feedback signals point in different directions? If sensors never fire, is that a sign of high quality or inadequate detection mechanisms? We need a way to evaluate harness coverage and quality similar to what code coverage and mutation testing do for tests. Feedforward and feedback controls are currently scattered across delivery steps, there's real potential for tooling that helps configure, sync, and reason about them as a system. Building this outer harness is emerging as an ongoing engineering practice, not a one-time configuration.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Big thanks to the Doppler team for the engaging discussion at our last technology radar meeting, in particular Kief Morris for bringing up cybernetics. Thanks to Ned Letcher, Chris Ford and Ben O'Mahoney for the conversations about what a harness even is, and to Matteo Vaccari for his insights on the behaviour harness. And to everybody who took the time to read the draft and provide lots of valuable feedback: Christoph Burgmer, Jörn Dinkla, Michael Feathers, Karrtik Iyer, Swapnil Phulse, Paul Sobocinski, Zhenjia Zhou
|
||||
|
||||
GenAI (Claude and Claude Code) was used for research, pulling in relevant ideas from existing notes, and polishing the language.
|
||||
|
||||
## Earlier Memo
|
||||
|
||||
I wrote [a memo in early February](https://martinfowler.com/articles/exploring-gen-ai/harness-engineering-memo.html) containing my initial thoughts on Harness Engineering as the term first appeared. That post has attracted a lot of traffic. This article supersedes that memo, so we have redirected the original memo URL to this page, as we believe this page is the better resource for readers.
|
||||
|
||||
Significant Revisions
|
||||
|
||||
*02 April 2026:* published full article including introducing guides, sensors, computational and inferential elements, and harness templates
|
||||
|
||||
*17 February 2026:* published my [initial memo](https://martinfowler.com/articles/exploring-gen-ai/harness-engineering-memo.html) on Harness Engineering
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Improving Deep Agents with harness engineering"
|
||||
source: "https://blog.langchain.com/improving-deep-agents-with-harness-engineering/"
|
||||
author:
|
||||
- "[[LangChain Accounts]]"
|
||||
published: 2026-02-18
|
||||
created: 2026-04-11
|
||||
description: "TLDR: Our coding agent went from Top 30 to Top 5 on Terminal Bench 2.0. We only changed the harness. Here’s our approach to harness engineering (teaser: self-verification & tracing help a lot).The Goal of Harness EngineeringThe goal of a harness is to mold the inherently spiky"
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
TLDR: Our coding agent went from Top 30 to Top 5 on [Terminal Bench 2.0](https://www.tbench.ai/leaderboard/terminal-bench/2.0?ref=blog.langchain.com). We only changed the harness. Here’s our approach to harness engineering (teaser: self-verification & tracing help a lot).
|
||||
|
||||
## The Goal of Harness Engineering
|
||||
|
||||
The goal of a harness is to mold the inherently spiky intelligence of a model for tasks we care about. **Harness Engineering** is about systems, you’re building tooling around the model to optimize goals like task performance, token efficiency, latency, etc. Design decisions include the system prompt, tool choice, and execution flow.
|
||||
|
||||
But how should you change the harness to improve your agent?
|
||||
|
||||
At LangChain, we use [Traces](https://docs.langchain.com/langsmith/observability-quickstart?ref=blog.langchain.com) to understand agent failure modes at scale. Models today are largely black-boxes, their inner mechanisms are hard to interpret. But we can see their inputs and outputs in text space which we then use in our improvement loops.
|
||||
|
||||
We used a simple recipe to iteratively improve [deepagents-cli](https://github.com/langchain-ai/deepagents/tree/main/libs/cli?ref=blog.langchain.com) (our coding agent) `13.7 points` from `52.8` to `66.5` on Terminal Bench 2.0. We only tweaked the harness and kept the model fixed, `gpt-5.2-codex`.
|
||||
|
||||
![[Screenshot-2026-02-12-at-12.25.20---PM-1.png]]
|
||||
|
||||
Spending more reasoning compute on planning and verification
|
||||
|
||||
## Experiment Setup & The Knobs on a Harness
|
||||
|
||||
We used [Terminal Bench 2.0](https://www.tbench.ai/?ref=blog.langchain.com), a now standard benchmark to evaluate agentic coding. It has 89 tasks across domains like machine learning, debugging, and biology. We use [Harbor](https://harborframework.com/?ref=blog.langchain.com) to orchestrate the runs. It spins up sandboxes ([Daytona](https://www.daytona.io/?ref=blog.langchain.com)), interacts with our agent loop, and runs verification + scoring.
|
||||
|
||||
Every agent action is stored in [LangSmith](https://smith.langchain.com/?ref=blog.langchain.com). It also includes metrics like latency, token counts, and costs.
|
||||
|
||||
### The Knobs we can Turn
|
||||
|
||||
An agent harness has a lot of knobs: system prompts, tools, hooks/middleware, skills, sub-agent delegation, memory systems, and more. We deliberately compress the optimization space and focus on three: **System Prompt, Tools,** and [**Middleware**](https://docs.langchain.com/oss/python/langchain/middleware/overview?ref=blog.langchain.com#the-agent-loop) (our term for hooks around model and tool calls).
|
||||
|
||||
We start with a default prompt and standard tools+middleware. This scores 52.8% with GPT-5.2-Codex. A solid score, just outside the Top 30 of the leaderboard today, but room to grow.
|
||||
|
||||
![[Screenshot-2026-02-16-at-12.50.00---PM.png]]
|
||||
|
||||
Spending more reasoning compute on planning and verification
|
||||
|
||||
### The Trace Analyzer Skill
|
||||
|
||||
We wanted trace analysis to be repeatable so we made it into an Agent Skill. This serves as our recipe to **analyze errors across runs and make improvements to the harness**. The flow is:
|
||||
|
||||
1. Fetch experiment traces from LangSmith
|
||||
2. Spawn parallel error analysis agents → main agent synthesizes findings + suggestions
|
||||
3. Aggregate feedback and make targeted changes to the harness.
|
||||
|
||||
This works similarly to [boosting](https://en.wikipedia.org/wiki/Boosting_\(machine_learning\)?ref=blog.langchain.com) which focuses on mistakes from previous runs. A human can be pretty helpful in Step 3 (though not required) to verify and discuss proposed changes. Changes that overfit to a task are bad for generalization and can lead to regressions in other Tasks.
|
||||
|
||||
Automated trace analysis saves hours of time and made it easy to quickly try experiments. We’ll be publishing this skill soon, we’re currently testing it for prompt optimization generally.
|
||||
|
||||
![[langsmith_trace_analyzer_skill.png]]
|
||||
|
||||
Spending more reasoning compute on planning and verification
|
||||
|
||||
## What Actually Improved Agent Performance
|
||||
|
||||
Automated Trace analysis allowed us to [debug where agents were going wrong](https://www.langchain.com/conceptual-guides/agent-observability-powers-agent-evaluation?ref=blog.langchain.com). Issues included reasoning errors, not following task instructions, missing testing and verification, running out of time, etc. We go into these improvements in more details in the sections below.
|
||||
|
||||
### Build & Self-Verify
|
||||
|
||||
Today’s models are exceptional self-improvement machines.
|
||||
|
||||
**Self-verification allows agents to self-improve via feedback within a run**. However, they don’t have a natural tendency to enter this **build-verify loop.**
|
||||
|
||||
The most common failure pattern was that the agent wrote a solution, re-read its own code, confirmed it looks ok, and stopped. Testing is a key part of autonomous agentic coding. It helps test for overall correctness and simultaneously gives agents signal to hill-climb against.
|
||||
|
||||
We added guidance to the system prompt on how to approach problem solving.
|
||||
|
||||
1. **Planning & Discovery:** Read the task, scan the codebase, and build an initial plan based on the task specification and how to verify the solution.
|
||||
2. **Build:** Implement the plan with verification in mind. Build tests, if they don’t exist and test both happy paths and edge cases.
|
||||
3. **Verify:** Run tests, read the full output, compare against what was asked (not against your own code).
|
||||
4. **Fix:** Analyze any errors, revisit the original spec, and fix issues.
|
||||
|
||||
We really focus on testing because it powers the changes in every iteration. We found that alongside prompting, deterministic context injection helps agents verify their work. We use a `PreCompletionChecklistMiddleware` that intercepts the agent before it exits and reminds it to run a verification pass against the Task spec. This is similar to a [Ralph Wiggum Loop](https://ghuntley.com/loop/?ref=blog.langchain.com) where a hook forces the agent to continue executing on exit, we use this for verification.
|
||||
|
||||
![[self-verification-loop.png]]
|
||||
|
||||
Spending more reasoning compute on planning and verification
|
||||
|
||||
### Giving Agents Context about their Environment
|
||||
|
||||
Part of harness engineering is **building a good delivery mechanism for context engineering.** Terminal Bench tasks come with directory structures, built-in tooling, and strict timeouts.
|
||||
|
||||
1. **Directory Context & Tooling:** A `LocalContextMiddleware` runs on agent start to map the `cwd` and other parent+children directories. We run `bash` commands to find tools like `Python` installations. Context discovery and search are error prone, so injecting context reduces this error surface and helps **onboard the agent into its environment.**
|
||||
2. **Teaching Agents to Write Testable Code:** Agents don’t know how their code needs to be testable. We add prompting say their work will be measured against programatic tests, similar to when committing code. For example, Task specs that mention file paths should be followed exactly so the solutions works in an automated scoring step. Prompting that stresses edge-cases helps the agent avoid only checking “happy path” cases. Forcing models to conform to testing standards is a powerful strategy to avoid “slop buildup” over time.
|
||||
3. **Time Budgeting:** We inject time budget warnings to nudge the agent to finish work and shift to verification. Agents are famously bad at time estimation so this heuristic helps in this environment. Real world coding usually doesn’t have strict time limits, but without adding any knowledge of constraints, agents won’t work within time bounds.
|
||||
|
||||
The more that agents know about their environment, constraints, and evaluation criteria, the better they can autonomously self-direct their work.
|
||||
|
||||
**The purpose of the harness engineer: prepare and deliver context so agents can autonomously complete work.**
|
||||
|
||||
### Encouraging Agents to Step Back & Reconsider Plans
|
||||
|
||||
Agents can be myopic once they’ve decided on a plan which results in “doom loops” that make small variations to the same broken approach (10+ times in some traces).
|
||||
|
||||
We use a `LoopDetectionMiddleware` that tracks per-file edit counts via tool call hooks. It adds context like “…consider reconsidering your approach” after `N` edits to the same file. This can help agents recover from doom loops, though the model can continue down the same path if it thinks it’s correct.
|
||||
|
||||
Important note. This is a design heuristic that engineers around today’s perceived model issues. As models improve, these guardrails will likely be unnecessary, but today helps agents execute correctly and autonomously.
|
||||
|
||||
### Choosing How Much Compute to Spend on Reasoning
|
||||
|
||||
Reasoning models can run autonomously for hours so we have to decide how much compute to spend on every subtask. You can use the max reasoning budget on every task, but most work can benefit from optimizing reasoning compute spend.
|
||||
|
||||
Terminal Bench timeout limits create a tradeoff. More reasoning helps agents evaluate each step, but can burn over `2x` more tokens/time. `gpt-5.2-codex` has 4 reasoning modes, `low`, `medium`, `high`, and `xhigh`.
|
||||
|
||||
We found that reasoning helps with planning to fully understand the problem, some Terminal Bench tasks are very difficult. A good plan helps get to a working solution more quickly.
|
||||
|
||||
Later stage verification also benefits from more reasoning to catch mistakes and get a solution submitted. As a heuristic, we choose a xhigh-high-xhigh " **reasoning sandwich** " as a baseline.
|
||||
|
||||
![[the-reasoning-sandwich.png]]
|
||||
|
||||
Spending more reasoning compute on planning and verification
|
||||
|
||||
Running only at `xhigh` scored poorly at `53.9%` due to agent timeouts compared to `63.6%` at `high`. There weren’t large differences in trial runs across reasoning budget splits so we stuck with our approach which pushed the score to `66.5%`.
|
||||
|
||||
The natural approach for models is **Adaptive Reasoning,** seen with [Claude](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking?ref=blog.langchain.com) and [Gemini](https://ai.google.dev/gemini-api/docs/thinking?ref=blog.langchain.com) models where the model decides how much compute to spend on reasoning.
|
||||
|
||||
In a multi-model harness, balancing reasoning budgets could play out as using a large model for planning and [handing off](https://docs.langchain.com/oss/python/langchain/multi-agent/handoffs?ref=blog.langchain.com) to a smaller model for implementation.
|
||||
|
||||
## Practical Takeaways for Building Agent Harnesses
|
||||
|
||||
The design space of agents is big. Here are some general principles from our experiments and building deepagents overall.
|
||||
|
||||
1. **Context Engineering on Behalf of Agents.** Context assembly is still difficult for agents today, especially in unseen environments. Onboarding models with context like directory structures, available tools, coding best practices, and problem solving strategies helps reduce the error surface for poor search and avoidable errors in planning.
|
||||
2. **Help agents self-verify their work.** Models are biased towards their first plausible solution. Prompt them aggressively to verify their work by running tests and refining solutions. This is especially important in autonomous coding systems that don’t have humans in the loop.
|
||||
3. **Tracing as a feedback signal.** Traces allow agents to self-evaluate and debug themselves. It’s important to debug tooling and reasoning together (ex: models go down wrong paths because they lack a tool or instructions how to do something).
|
||||
4. **Detect and fix bad patterns in the short term.** Models today aren’t perfect. The job of the harness designer is to design around today’s shortcomings while planning for smarter models in the future. Blind retries and not verifying work are good examples. These guardrails will almost surely dissolve over time, but to build robust agent applications today, they’re useful tools to experiment with.
|
||||
5. **Tailor Harnesses to Models. T** he [Codex](https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide/?ref=blog.langchain.com) and [Claude](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices?ref=blog.langchain.com) prompting guides show that models require different prompting. A test run with Claude Opus 4.6 scored `59.6%` with an earlier harness version, competitive but worse than Codex because we didn’t run the same Improvement Loop with Claude. Many principles generalize like good context preparation and a focus on verification, but running a few rounds of harness iterations for your task helps maximize agent performance across tasks.
|
||||
|
||||
There’s more open research to do in harness design. Interesting avenues include multi-model systems (Codex, Gemini, and Claude together), memory primitives for continual learning so agents can autonomously improve on tasks, and measuring harness changes across models.
|
||||
|
||||
For the outer loop of improving agents, we’re looking at methods like [RLMs](https://alexzhang13.github.io/blog/2025/rlm/?ref=blog.langchain.com) to more efficiently mine traces. We’ll be continuing work to improve the harness and openly share our research.
|
||||
|
||||
We created [a dataset of our Traces](https://smith.langchain.com/public/29393299-8f31-48bb-a949-5a1f5968a744/d?tab=2&ref=blog.langchain.com) to share with the community.
|
||||
|
||||
Deep Agents is open source. [Python](https://github.com/langchain-ai/deepagents?ref=blog.langchain.com) and [Javascript](https://github.com/langchain-ai/deepagentsjs?ref=blog.langchain.com).
|
||||
|
||||
**To more hill climbing and open research.**
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "LLM Wiki:构建持久累积型个人知识库"
|
||||
source: "https://github.com/nashsu/llm_wiki/blob/main/llm-wiki.md"
|
||||
author: "nashsu"
|
||||
published:
|
||||
created: 2026-08-02
|
||||
description: "关于使用 LLM 增量构建、维护和查询个人 Wiki 的方法论;已去除项目宣传和产品推广内容。"
|
||||
tags:
|
||||
- "个人知识库"
|
||||
- "LLM Wiki"
|
||||
- "知识管理"
|
||||
---
|
||||
|
||||
# LLM Wiki:构建持久累积型个人知识库
|
||||
|
||||
## 核心问题
|
||||
|
||||
传统 RAG 通常在每次提问时从原始文档检索片段,再即时拼接答案。它适合一次性问答,但跨多篇文档的综合结论不会自动沉淀;下一次查询仍然需要重新检索、比较和组织材料。
|
||||
|
||||
LLM Wiki 的方法是让 LLM 增量维护一组持久、结构化、相互链接的 Markdown 页面。新来源进入后,系统不仅保存原文,也提炼关键信息、更新实体和概念页面、修订主题总结、记录来源之间的矛盾,并把有价值的查询答案归档回 Wiki。知识因此成为持续累积的人工制品,而不是一次性响应。
|
||||
|
||||
## 三层架构
|
||||
|
||||
### 原始来源层
|
||||
|
||||
`raw/` 保存用户筛选过的文章、论文、图像、数据和其他材料。原始来源应当被视为事实依据,保持可追溯和尽量不可变;LLM 读取它们,但不以 Wiki 重写原文。
|
||||
|
||||
### Wiki 层
|
||||
|
||||
`wiki/` 保存由 LLM 编译出的摘要、实体页、概念页、比较、综合分析和查询归档。LLM 负责创建页面、更新页面、维护交叉链接和处理来源之间的关系;人主要负责选择来源、提出问题和审阅重要判断。
|
||||
|
||||
### Schema 层
|
||||
|
||||
Schema 是指导 LLM 维护知识库的规则文档,例如项目级 `CLAUDE.md`、`AGENTS.md` 或专门的技能文件。它应说明目录结构、页面类型、元数据格式、命名和链接约定,以及摄取、问答和健康检查的工作流。
|
||||
|
||||
## 三个核心操作
|
||||
|
||||
### Ingest:摄取
|
||||
|
||||
处理一个新来源时,LLM 应先理解来源内容,再将其整合到已有 Wiki:
|
||||
|
||||
1. 读取并概括来源,提取实体、概念、主张和证据。
|
||||
2. 检索可能受影响的现有页面。
|
||||
3. 判断新增内容是支持、补充、修正还是挑战已有结论。
|
||||
4. 创建来源摘要,并更新相关实体、概念、索引和交叉链接。
|
||||
5. 记录来源、时间和变更,保留不确定性与待核查问题。
|
||||
|
||||
来源可以逐篇处理以获得更强的人类监督,也可以批量处理以提高吞吐量;选择取决于内容风险和用户对自动更新的信任程度。
|
||||
|
||||
### Query:查询
|
||||
|
||||
回答问题时,先阅读索引定位相关页面,再深入读取 Wiki 和必要的原始来源,最后给出带引用的综合答案。答案不应只停留在对话中:具有通用研究价值的比较、分析、连接或新结论,应归档到 `wiki/queries/`,并加入索引。
|
||||
|
||||
### Lint:健康检查
|
||||
|
||||
定期扫描 Wiki,寻找:页面之间的矛盾、已被新来源取代的陈旧主张、没有入链的孤岛页面、被频繁提及但尚未成页的重要概念、缺失的交叉链接,以及需要外部研究填补的知识空白。Lint 的目标不是追求页面数量,而是保持知识网络可导航、可解释和可更新。
|
||||
|
||||
## 索引与日志
|
||||
|
||||
`index.md` 是面向内容的导航目录:按类别列出页面、摘要和可选元数据。它应当是 LLM 开始查询时的入口。
|
||||
|
||||
`log.md` 是按时间追加的操作记录,记录摄取、查询和 Lint。索引回答“知识库里有什么”,日志回答“知识库如何演变”,两者不应混为一谈。
|
||||
|
||||
## 为什么这种模式有效
|
||||
|
||||
知识库维护中最费时的部分往往不是阅读,而是重复性的整理工作:更新摘要、维护反向链接、比较新旧来源、标注矛盾和保持命名一致。LLM 擅长执行这些跨文件的 bookkeeping 工作,因此可以降低维护成本,让知识库的长期价值不再随着页面数量线性崩溃。
|
||||
|
||||
人和 LLM 的职责应当分开:人负责来源选择、研究方向、重要判断和高风险审阅;LLM 负责提炼、链接、归档和一致性维护。该分工不是绝对规则,涉及事实争议、隐私或高风险决策时应提高人工介入程度。
|
||||
|
||||
## 边界与代价
|
||||
|
||||
LLM Wiki 不是所有场景的替代方案。来源很少、问题是一次性的,或答案必须完全基于原文时,直接检索可能更简单。Wiki 层也会引入编译成本、摘要失真、旧页面漂移和错误扩散风险,因此需要来源追踪、版本控制、定期 Lint 和必要的人工审阅。
|
||||
|
||||
该模式最适合持续积累、需要跨来源综合、且能够接受“知识在查询之间被重新组织”的个人研究或项目。
|
||||
|
||||
## 相关研究
|
||||
|
||||
- [[Externalization-in-LLM-Agents|LLM Agent 中的外部化]] - 将记忆、技能和协议理解为外部认知基础设施
|
||||
- [[Harness-Engineering|Harness 工程]] - 为知识编译提供上下文、约束、反馈和治理
|
||||
- [[Memory-Systems|记忆系统]] - 讨论跨会话状态的持久化与检索
|
||||
- [[Agent-Protocols|智能体协议]] - 讨论工具和服务的结构化交互
|
||||
- [[LLM-Wiki-Implementation-Patterns|LLM Wiki 的实现模式]] - 将方法论落实为摄取、检索和维护组件
|
||||
|
||||
## 原始来源
|
||||
|
||||
- [nashsu/llm_wiki:llm-wiki.md](https://github.com/nashsu/llm_wiki/blob/main/llm-wiki.md)
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: "My AI Adoption Journey"
|
||||
source: "https://mitchellh.com/writing/my-ai-adoption-journey"
|
||||
author:
|
||||
published: 2026-02-05
|
||||
created: 2026-04-11
|
||||
description:
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
## Mitchell Hashimoto
|
||||
|
||||
## My AI Adoption Journey
|
||||
|
||||
Table of Contents
|
||||
|
||||
My experience adopting any meaningful tool is that I've necessarily gone through three phases: (1) a period of inefficiency (2) a period of adequacy, then finally (3) a period of workflow and life-altering discovery.
|
||||
|
||||
In most cases, I have to force myself through phase 1 and 2 because I usually have a workflow I'm already happy and comfortable with. Adopting a tool feels like work, and I *do not* want to put in the effort, but I usually do in an effort to be a well-rounded person of my craft.
|
||||
|
||||
This is my journey of how I found value in AI tooling and what I'm trying next with it. In an ocean of overly dramatic, hyped takes, I hope this represents a more nuanced, measured approach to my views on AI and how they've changed over time.
|
||||
|
||||
This blog post was fully written by hand, in my own words. I hate that I have to say that but especially given the subject matter, I want to be explicit about it.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Drop the Chatbot
|
||||
|
||||
Immediately cease trying to perform meaningful work via a chatbot (e.g. ChatGPT, Gemini on the web, etc.). Chatbots have real value and are a daily part of my AI workflow, but their utility in coding is highly limited because you're mostly hoping they come up with the right results based on their prior training, and correcting them involves a human (you) to tell them they're wrong repeatedly. It is inefficient.
|
||||
|
||||
I think everyone's first experience with AI is a chat interface. And I think everyone's first experience trying to code with AI has been asking a chat interface to write code.
|
||||
|
||||
While I was still a heavy AI skeptic, my first "oh wow" moment was pasting a screenshot of Zed's command palette into Gemini, asking it to reproduce it with SwiftUI, and being truly flabbergasted that it did it *very well*. The command palette that ships for macOS in Ghostty today is only very lightly modified from what Gemini produced for me in seconds.
|
||||
|
||||
But when I tried to reproduce that behavior for other tasks, I was left disappointed. In the context of brownfield projects, I found the chat interface produced poor results very often, and I found myself very frustrated copying and pasting code and command output to and from the interface. It was very obviously far less efficient than me doing the work myself.
|
||||
|
||||
To find value, you *must* use an **agent**. An agent is the industry-adopted term for an LLM that can chat and invoke external behavior in a loop [^1] At a bare minimum, the agent must have the ability to: read files, execute programs, and make HTTP requests.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Reproduce Your Own Work
|
||||
|
||||
The next phase on my journey I tried [Claude Code](https://github.com/anthropics/claude-code). I'll cut to the chase: I initially wasn't impressed. I just wasn't getting good results out of my sessions. I felt I had to touch up everything it produced and this process was taking more time than if I had just done it myself. I read blog posts, watched videos, but just wasn't that impressed.
|
||||
|
||||
Instead of giving up, I **forced myself to reproduce all my manual commits with agentic ones.** I literally did the work twice. I'd do the work manually, and then I'd fight an agent to produce identical results in terms of quality and function (without it being able to see my manual solution, of course).
|
||||
|
||||
This was *excruciating*, because it got in the way of simply getting things done. But I've been around the block with non-AI tools enough to know that friction is natural, and I can't come to a firm, defensible conclusion without exhausting my efforts.
|
||||
|
||||
But, expertise formed. I quickly discovered for myself from first principles what others were already saying, but discovering it myself resulted in a stronger fundamental understanding.
|
||||
|
||||
1. Break down sessions into separate clear, actionable tasks. Don't try to "draw the owl" in one mega session.
|
||||
2. For vague requests, split the work into separate planning vs. execution sessions.
|
||||
3. If you give an agent a way to verify its work, it more often than not fixes its own mistakes and prevents regressions.
|
||||
|
||||
More generally, I also found the edges of what agents -- at the time -- were good at, what they weren't good at, and for the tasks they were good at how to achieve the results I wanted.
|
||||
|
||||
All of this led to significant efficiency gains, to the point where I was starting to naturally use agents in a way that I felt was no slower than doing it myself (but I still didn't feel it was any faster, since I was mostly babysitting an agent).
|
||||
|
||||
The negative space here is worth reiterating: part of the efficiency gains here were understanding when *not* to reach for an agent. Using an agent for something it'll likely fail at is obviously a big waste of time and having the knowledge to avoid that completely leads to time savings [^2].
|
||||
|
||||
At this stage, I was finding adequate value with agents that I was happy to use them in my workflow, but still didn't feel like I was seeing any net efficiency gains. I didn't care though, I was content at this point with AI as a tool.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: End-of-Day Agents
|
||||
|
||||
To try to find some efficiency, I next started up a new pattern: **block out the last 30 minutes of every day to kick off one or more agents.** My hypothesis was that *perhaps* I could gain some efficiency if the agent can make some *positive progress* in the times I can't work anyways. Basically: instead of trying to do more in the time I have, try to do more in the time I don't have.
|
||||
|
||||
Similar to the previous task, I at first found this both unsuccessful and annoying. But, I once again quickly found different categories of work that were really helpful:
|
||||
|
||||
- **Deep research sessions** where I'd ask agents to survey some field, such as finding all libraries in a specific language with a specific license type and producing multi-page summaries for each on their pros, cons, development activity, social sentiment, etc.
|
||||
- **Parallel agents attempting different vague ideas I had but didn't have time to get started on.** I didn't expect them to produce something I'd ever ship here, but perhaps could illuminate some unknown unknowns when I got to the task the next day.
|
||||
- **Issue and PR triage/review.** Agents are good at using `gh` (GitHub CLI), so I manually scripted a quick way to spin up a bunch in parallel to triage issues. I would NOT allow agents to respond, I just wanted reports the next day to try to guide me towards high value or low effort tasks.
|
||||
|
||||
To be clear, I did not go as far as others went to have agents running in loops all night. In most cases, agents completed their tasks in less than half an hour. But, the latter part of the working day, I'm usually tired and coming out of flow and find myself too personally inefficient, so shifting my effort to spinning up these agents I found gave me a "warm start" the next morning that got me working more quickly than I would've otherwise.
|
||||
|
||||
I was happy, and I was starting to feel like I was doing more than I was doing prior to AI, if only slightly.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Outsource the Slam Dunks
|
||||
|
||||
By this point, I was getting very confident about what tasks my AI was and wasn't great at. I had really high confidence with certain tasks that the AI would achieve a mostly-correct solution. So the next step on my journey was: **let agents do all of that work while I worked on other tasks.**
|
||||
|
||||
More specifically, I would start each day by taking the results of my prior night's triage agents, filter them manually to find the issues that an agent will almost certainly solve well, and then keep them going in the background (one at a time, not in parallel).
|
||||
|
||||
Meanwhile, **I'd work on something else.** I wasn't going to social media (any more than usual without AI), I wasn't watching videos, etc. I was in my own, normal, pre-AI deep thinking mode working on something I wanted to work on or had to work on.
|
||||
|
||||
**Very important at this stage: turn off agent desktop notifications.** Context switching is very expensive. In order to remain efficient, I found that it was my job as a human to be in control of when I interrupt the agent, not the other way around. Don't let the agent notify you. During natural breaks in your work, tab over and check on it, then carry on.
|
||||
|
||||
Importantly, I think the "work on something else" helps counteract the highly publicized [Anthropic skill formation paper](https://www.anthropic.com/research/AI-assistance-coding-skills). Well, you're trading off: not forming skills for the tasks you're delegating to the agent while continuing to form skills naturally in the tasks you continue to work on manually.
|
||||
|
||||
At this point I was firmly in the "no way I can go back" territory. I felt more efficient, but even if I wasn't, the thing I liked the most was that I could now focus my coding and thinking on tasks I really loved while still adequately completing the tasks I didn't.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Engineer the Harness
|
||||
|
||||
At risk of stating the obvious: agents are much more efficient when they produce the right result the first time, or at worst produce a result that requires minimal touch-ups. The most sure-fire way to achieve this is to give the agent fast, high quality tools to automatically tell it when it is wrong.
|
||||
|
||||
I don't know if there is a broad industry-accepted term for this yet, but I've grown to calling this "harness engineering." It is the idea that anytime you find an agent makes a mistake, you take the time to engineer a solution such that the agent never makes that mistake again. I don't need to invent any new terms here; if another one exists, I'll jump on the bandwagon.
|
||||
|
||||
This comes in two forms:
|
||||
|
||||
1. **Better implicit prompting (AGENTS.md).** For simple things, like the agent repeatedly running the wrong commands or finding the wrong APIs, update the `AGENTS.md` (or equivalent). Here is [an example from Ghostty](https://github.com/ghostty-org/ghostty/blob/ca07f8c3f775fe437d46722db80a755c2b6e6399/src/inspector/AGENTS.md). Each line in that file is based on a bad agent behavior, and it almost completely resolved them all.
|
||||
2. **Actual, programmed tools.** For example, scripts to take screenshots, run filtered tests, etc etc. This is usually paired with an AGENTS.md change to let it know about this existing.
|
||||
|
||||
**This is where I'm at today.** I'm making an earnest effort whenever I see an agent do a Bad Thing to prevent it from ever doing that bad thing again. Or, conversely, I'm making an earnest effort for agents to be able to verify they're doing a Good Thing.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Always Have an Agent Running
|
||||
|
||||
Simultaneous to step 5, I'm also operating under the goal of **having an agent running at all times.** If an agent isn't running, I ask myself "is there something an agent could be doing for me right now?"
|
||||
|
||||
I particularly like to combine this with slower, more thoughtful models like Amp's [deep mode](https://ampcode.com/news/deep-mode) (which is basically just GPT-5.2-Codex) which can take upwards of 30+ minutes to make small changes. The flip side of that is that it does tend to produce very good results.
|
||||
|
||||
**I'm not \[yet?\] running multiple agents, and currently don't really want to.** I find having the one agent running is a good balance for me right now between being able to do deep, manual work I find enjoyable, and babysitting my kind of stupid and yet mysteriously productive robot friend.
|
||||
|
||||
The "have an agent running at all times" goal is still just a goal. I'd say right now I'm maybe effective at having a background agent running 10 to 20% of a normal working day. But, I'm actively working to improve that.
|
||||
|
||||
**I don't want to run agents for the sake of running agents.** I only want to run them when there is a task I think would be truly helpful to me. Part of the challenge of this goal is improving my own workflows and tools so that I can have a constant stream of high quality work to do that I can delegate. Which, even without AI, is important!
|
||||
|
||||
---
|
||||
|
||||
## Today
|
||||
|
||||
And that's where I'm at today.
|
||||
|
||||
Through this journey, I've personally reached a point where I'm having success with modern AI tooling and I believe I'm approaching it with the proper measured view that is grounded in reality. I really don't care one way or the other if AI is here to stay [^3], I'm a software craftsman that just wants to build stuff for the love of the game.
|
||||
|
||||
The whole landscape is moving so rapidly that I'm sure I'll look back at this post very quickly and laugh at my naivete. But, as they say, if you can't be embarassed about your past self, you're probably not growing. I just hope I'll grow in the right direction!
|
||||
|
||||
I have no skin in the game here [^4], and there are of course other reasons behind utility to avoid using AI. I fully respect anyone's individual decisions regarding it. I'm not here to convince you! For those interested, I just wanted to share my personal approach to navigating these new tools and give a glimpse about how I approach new tools *in general*, regardless of AI.
|
||||
|
||||
February 5, 2026
|
||||
|
||||
[^1]: Modern coding models like Opus and Codex are specifically trained to bias towards using tools compared to conversational models.
|
||||
|
||||
[^2]: Due to the rapid pace of innovation in models, I have to constantly revisit my priors on this one.
|
||||
|
||||
[^3]: The skill formation issues particularly in juniors without a strong grasp of fundamentals deeply worries me, however.
|
||||
|
||||
[^4]: I don't work for, invest in, or advise any AI companies.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Scaling Managed Agents: Decoupling the brain from the hands"
|
||||
source: "https://www.anthropic.com/engineering/managed-agents"
|
||||
author:
|
||||
published:
|
||||
created: 2026-04-11
|
||||
description: "Anthropic is an AI safety and research company that's working to build reliable, interpretable, and steerable AI systems."
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
*Get started with Claude Managed Agents by following our [docs](https://platform.claude.com/docs/en/managed-agents/overview).*
|
||||
|
||||
A running topic on the Engineering Blog is how to [build effective agents](https://www.anthropic.com/engineering/building-effective-agents) and [design harnesses](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) for [long-running work](https://www.anthropic.com/engineering/harness-design-long-running-apps). A common thread across this work is that harnesses encode assumptions about what Claude can’t do on its own. However, those assumptions need to be frequently questioned because they can [go stale](http://www.incompleteideas.net/IncIdeas/BitterLesson.html) as models improve.
|
||||
|
||||
As just one example, in prior work [we found](https://www.anthropic.com/engineering/harness-design-long-running-apps) that Claude Sonnet 4.5 would wrap up tasks prematurely as it sensed its context limit approaching—a behavior sometimes called “context anxiety.” We addressed this by adding context resets to the harness. But when we used the same harness on Claude Opus 4.5, we found that the behavior was gone. The resets had become dead weight.
|
||||
|
||||
We expect harnesses to continue evolving. So we built Managed Agents: a hosted service in the Claude Platform that runs long-horizon agents on your behalf through a small set of interfaces meant to outlast any particular implementation—including the ones we run today.
|
||||
|
||||
Building Managed Agents meant solving an old problem in computing: how to design a system for “ [programs as yet unthought of](http://www.catb.org/esr/writings/taoup/html/ch03s01.html).” Decades ago, operating systems solved this problem by virtualizing hardware into abstractions— *process, file* —general enough for programs that didn't exist yet. The abstractions outlasted the hardware. The `read()` command is agnostic as to whether it’s accessing a disk pack from the 1970s or a modern SSD. The abstractions on top stayed stable while the implementations underneath changed freely.
|
||||
|
||||
Managed Agents follow the same pattern. We virtualized the components of an agent: a session (the append-only log of everything that happened), a harness (the loop that calls Claude and routes Claude’s tool calls to the relevant infrastructure), and a sandbox (an execution environment where Claude can run code and edit files). This allows the implementation of each to be swapped without disturbing the others. We're opinionated about the shape of these interfaces, not about what runs behind them.
|
||||
|
||||
![[903b624ada206b10753a24c6a1367e74a869165d-1080x1080.webp]]
|
||||
|
||||
## Don’t adopt a pet
|
||||
|
||||
We started by placing all agent components into a single container, which meant the session, agent harness, and sandbox all shared an environment. There were benefits to this approach, including that file edits are direct syscalls, and there were no service boundaries to design.
|
||||
|
||||
But by coupling everything into one container, we ran into an old infrastructure problem: we’d adopted a [*pet*](https://cloudscaling.com/blog/cloud-computing/the-history-of-pets-vs-cattle/). In the pets-vs-cattle analogy, a pet is a named, hand-tended individual you can’t afford to lose, while cattle are interchangeable. In our case, the server became that pet; if a container failed, the session was lost. If a container was unresponsive, we had to nurse it back to health.
|
||||
|
||||
Nursing containers meant debugging unresponsive stuck sessions. Our only window in was the WebSocket event stream, but that couldn’t tell us *where* failures arose, which meant that a bug in the harness, a packet drop in the event stream, or a container going offline all presented the same. To figure out what went wrong, an engineer had to open a shell inside the container, but because that container often also held user data, that approach essentially meant we lacked the ability to debug.
|
||||
|
||||
A second issue was that the harness assumed that whatever Claude worked on lived in the container with it. When customers asked us to connect Claude to their virtual private cloud, they had to either peer their network with ours, or run our harness in their own environment. An assumption baked into the harness became a problem when we wanted to connect it to different infrastructure.
|
||||
|
||||
## Decouple the brain from the hands
|
||||
|
||||
The solution we arrived at was to decouple what we thought of as the “brain” (Claude and its harness) from both the “hands” (sandboxes and tools that perform actions) and the “session” (the log of session events). Each became an interface that made few assumptions about the others, and each could fail or be replaced independently.
|
||||
|
||||
**The harness leaves the container.** Decoupling the brain from the hands meant the harness no longer lived inside the container. It called the container the way it called any other tool: `execute(name, input) → string`. The container became cattle. If the container died, the harness caught the failure as a tool-call error and passed it back to Claude. If Claude decided to retry, a new container could be reinitialized with a standard recipe: `provision({resources})`. We no longer had to nurse failed containers back to health.
|
||||
|
||||
**Recovering from harness failure.** The harness also became cattle. Because the session log sits outside the harness, nothing in the harness needs to survive a crash. When one fails, a new one can be rebooted with `wake(sessionId)`, use `getSession(id)` to get back the event log, and resume from the last event. During the agent loop, the harness writes to the session with `emitEvent(id, event)` in order to keep a durable record of events.
|
||||
|
||||
![[73e900af5b9d6ed8c64db0a8e74d4465963556b7-1640x1596.webp]]
|
||||
|
||||
**The security boundary.** In the coupled design, any untrusted code that Claude generated was run in the same container as credentials—so a prompt injection only had to convince Claude to read its own environment. Once an attacker has those tokens, they can spawn fresh, unrestricted sessions and delegate work to them. Narrow scoping is an obvious mitigation, but this encodes an assumption about what Claude can't do with a limited token—and Claude is getting increasingly smart. The structural fix was to make sure the tokens are never reachable from the sandbox where Claude’s generated code runs.
|
||||
|
||||
We used two patterns to ensure this. Auth can be bundled with a resource or held in a vault outside the sandbox. For Git, we use each repository’s access token to clone the repo during sandbox initialization and wire it into the local git remote. Git `push` and `pull` work from inside the sandbox without the agent ever handling the token itself. For custom tools, we support MCP and store OAuth tokens in a secure vault. Claude calls MCP tools via a dedicated proxy; this proxy takes in a token associated with the session. The proxy can then fetch the corresponding credentials from the vault and make the call to the external service. The harness is never made aware of any credentials.
|
||||
|
||||
## The session is not Claude’s context window
|
||||
|
||||
Long-horizon tasks often exceed the length of Claude’s context window, and the standard ways to address this all involve irreversible decisions about what to keep. We’ve explored these techniques in [prior work](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) on context engineering. For example, compaction lets Claude save a summary of its context window and the memory tool lets Claude write context to files, enabling learning across sessions. This can be paired with context trimming, which selectively removes tokens such as old tool results or thinking blocks.
|
||||
|
||||
But irreversible decisions to selectively retain or discard context can lead to failures. It is difficult to know which tokens the future turns will need. If messages are transformed by a compaction step, the harness removes compacted messages from Claude’s context window, and these are recoverable only if they are stored. Prior work [has explored](https://arxiv.org/pdf/2512.24601) ways to address this by storing context as an object that lives *outside* the context window. For example, context can be an object in a REPL that the LLM programmatically accesses by writing code to filter or slice it.
|
||||
|
||||
![[cf0719d7832b1f577b7393c84a7c53eecc725ca4-760x200.webp]]
|
||||
|
||||
In Managed Agents, the session provides this same benefit, serving as a context object that lives outside Claude’s context window. But rather than be stored within the sandbox or REPL, context is durably stored in the session log. The interface, `getEvents(),` allows the brain to interrogate context by selecting positional slices of the event stream. The interface can be used flexibly, allowing the brain to pick up from wherever it last stopped reading, rewinding a few events before a specific moment to see the lead up, or rereading context before a specific action.
|
||||
|
||||
Any fetched events can also be transformed in the harness before being passed to Claude’s context window. These transformations can be whatever the harness encodes, including context organization to achieve a high prompt cache hit rate and context engineering. We separated the concerns of recoverable context storage in the session and arbitrary context management in the harness because we can’t predict what specific context engineering will be required in future models. The interfaces push that context management into the harness, and only guarantee that the session is durable and available for interrogation.
|
||||
|
||||
## Many brains, many hands
|
||||
|
||||
**Many brains.** Decoupling the brain from the hands solved one of our earliest customer complaints. When teams wanted Claude to work against resources in their own VPC, the only path was to peer their network with ours, because the container holding the harness assumed every resource sat next to it. Once the harness was no longer in the container, that assumption went away. The same change had a performance payoff. When we initially put the brain in a container, it meant that many brains required as many containers. For each brain, no inference could happen until that container was provisioned; every session paid the full container setup cost up front. Every session, even ones that would never touch the sandbox, had to clone the repo, boot the process, fetch pending events from our servers.
|
||||
|
||||
That dead time is expressed in time-to-first-token (TTFT), which measures how long a session waits between accepting work and producing its first response token. TTFT is the latency the user most acutely *feels*.
|
||||
|
||||
Decoupling the brain from the hands means that containers are provisioned by the brain via a tool call `(execute(name, input) → string)` only if they are needed. So a session that didn't need a container right away didn't wait for one. Inference could start as soon as the orchestration layer pulled pending events from the session log. Using this architecture, our p50 TTFT dropped roughly 60% and p95 dropped over 90%. Scaling to many brains just meant starting many stateless harnesses, and connecting them to hands only if needed.
|
||||
|
||||
**Many hands.** We also wanted the ability to connect each brain to many hands. In practice, this means Claude must reason about many execution environments and decide where to send work—a harder cognitive task than operating in a single shell. We started with the brain in a single container because earlier models weren't capable of this. As intelligence scaled, the single container became the limitation instead: when that container failed, we lost state for every hand that the brain was reaching into.
|
||||
|
||||
Decoupling the brain from the hands makes each hand a tool, `execute(name, input) → string`: a name and input go in, and a string is returned. That interface supports any custom tool, any MCP server, and our own tools. The harness doesn’t know whether the sandbox is a container, a phone, or a Pokémon emulator. And because no hand is coupled to any brain, brains can pass hands to one another.
|
||||
|
||||
![[4f67b1c10566552aec514a716ea43544ab330e0b-668x243.webp]]
|
||||
|
||||
## Conclusion
|
||||
|
||||
The challenge we faced is an old one: how to design a system for “programs as yet unthought of.” Operating systems have lasted decades by virtualizing the hardware into abstractions general enough for programs that didn't exist yet. With Managed Agents, we aimed to design a system that accommodates future harnesses, sandboxes, or other components around Claude.
|
||||
|
||||
Managed Agents is a meta-harness in the same spirit, unopinionated about the *specific* harness that Claude will need in the future. Rather, it is a system with general interfaces that allow many different harnesses. For example, Claude Code is an excellent harness that we use widely across tasks. We’ve also shown that task-specific agent harnesses excel in narrow domains. Managed Agents can accommodate any of these, matching Claude’s intelligence over time.
|
||||
|
||||
Meta-harness design means being opinionated about the interfaces around Claude: we expect that Claude will need the ability to manipulate state (the session) and perform computation (the sandbox). We also expect that Claude will require the ability to scale to many brains and many hands. We designed the interfaces so that these can be run reliably and securely over long time horizons. But we make no assumptions about the number or location of brains or hands that Claude will need.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Written by Lance Martin, Gabe Cemaj, and Michael Cohen. Thanks to Nodir Turakulov and Jeremy Fox for helpful conversations on these topics. Special thanks to the Agents API team and Jake Eaton for their contributions.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "物产中大外部文件清单与哈希"
|
||||
source_type: "迁移元数据"
|
||||
ingested: 2026-08-02
|
||||
confidence: high
|
||||
selection: "只保存原始文件清单、大小和 SHA-256,不复制 PDF、Excel、图片和临时文件本体。"
|
||||
---
|
||||
|
||||
# 物产中大外部文件清单与哈希
|
||||
|
||||
外部目录在本次盘点时共有 124 个文件,约 80.9 MB。以下记录重点 PDF 和 Excel 的原始大小与 SHA-256,便于将来重新获得文件后判断是否为同一版本。
|
||||
|
||||
## PDF
|
||||
|
||||
| 相对路径 | 大小(字节) | SHA-256 |
|
||||
|---|---:|---|
|
||||
| 玻璃/assets/PDF/公司年报/信义/信义2025年中期报告.pdf | 2150419 | a3d37e6f46069eec70befbddb4c52e1ddaea3b93032e2776ba9e244cd212d0fd |
|
||||
| 玻璃/assets/PDF/品种手册/玻璃期货投教材料.pdf | 30501069 | 2ef00108b040145c4ca821cf2f5b2c7eaab983f2b34da29ab420dfba8c37304c |
|
||||
| 玻璃/assets/PDF/相关研报/专题/20240920-中信期货-黑色建材专题报告:玻璃纯碱行业发展趋势及展望.pdf | 1589609 | 8511acca7f27ff8dacd1818a411d2372f54ceffae43a709df2b5b37f6d2ae5cb |
|
||||
| 玻璃/assets/PDF/相关研报/周报/隆众资讯浮法玻璃周报(20251128-1204).pdf | 1668351 | 1e635c6b58fd625283f7e142f6e97ccd7a903c03376499bb85724ae6d4a88b70 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2015/2015年瑞达期货年报.pdf | 1127905 | 5238f1ecee8209b3628147a2318dd81d580b21ef8aaa807728eac93616306d25 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2024/2024年中国玻璃.pdf | 1872276 | 5e0c53c598cad15eec6ea2351ea181dfcefddaeca86fb6ffdcdd1e78a4db8022 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2025/【中信期货黑色建材(玻璃纯碱)】需求临近底部,供应面临出清——2026年度策略报告.pdf | 2589506 | c7708f77bf38d4b7ef2334b5e96b15da7b7eabf7d80669a1a9a160d967852705 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2025/20251208-财通证券-建材行业2026年年度策略报告:成本构筑护城河,新场景新业务打开空间.pdf | 1115998 | 3a54f5b4564f45b25a2b60b7ec143c177d7c846b9086ca9e201513b0708b9777 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2025/20251211-兴业证券-建材行业2026年度策略:反内卷优化竞争格局,十五五首年有望开启新上行周期.pdf | 8018388 | 69ce1a34e8b51d1d114f41951249ac6b1c37fd5a4c2c21c5e97e3209b9e10218 |
|
||||
| 玻璃/assets/PDF/相关研报/年报/2025/长江证券-玻璃.pdf | 1273285 | 4c1e612a50d07b964c30dfbc5e9fbd9dfcae57fe3495a81491ff5fad2a9ec00e |
|
||||
| 玻璃/assets/PDF/相关研报/月报/隆众资讯浮法玻璃月报(2025年11月)%0A%0A#化工 #玻璃 #能源化工.pdf | 1227464 | 1882161d78f9814247229c2fd25a661b73e9e295595b3ce73935950b5156fc00 |
|
||||
| 玻璃/assets/PDF/相关研报/行业/2025 年玻璃产业链白皮书.pdf | 2942471 | d9ffc3166e3e41278b1145d20eb7c249053359a9003e48079c17627eea63b1c6 |
|
||||
| 玻璃/assets/PDF/相关研报/行业/中国平板玻璃行业市场规模测算逻辑模型.pdf | 1040879 | 8235197211bf5050a086f3057166fb5b391071072d42fa3f4098b4cdf98b4f4d |
|
||||
| 玻璃/assets/PDF/行业标准/建筑玻璃应用技术规程.pdf | 7165359 | e65e94b80084087b68ecfe52463898790b77281fea21c3a9438b8f10de495834 |
|
||||
| 玻璃/assets/PDF/行业标准/K17_浮法工艺流程.pdf | 2779040 | 93b706ef5ae010379e40a31e9977de3cdc81ab66e70cad9b6ac94ee82b444943 |
|
||||
|
||||
## Excel
|
||||
|
||||
| 相对路径 | 大小(字节) | SHA-256 |
|
||||
|---|---:|---|
|
||||
| 企业自由现金流/企业自由现金流.xlsx | 462275 | 6981da8ccd3f4e2f0207ebcd560e7147d96a7d09b3160da12fa98527527b7f2b |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/玻璃纯碱供需数据(年度).xlsx | 7743 | 11dbf78987293ca2d8824d98e41749fbb8d9224ac956f342a6432e5a8cdc09b1 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/玻璃纯碱基础数据(日度).xlsx | 189984 | 69f9417ee6e6e0f9e4b3e56204e25b6677b6e139307ab06c95a08b17ff59b33e |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/玻璃纯碱基础数据(月度).xlsx | 15089 | 0428245be908114f5d88a13d988ff5950be59529c98c1ee5448e502b81415255 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/玻璃纯碱基础数据(周度).xlsx | 30403 | 9f34cfb599a1471e130b979452a47fabdb78f61c7b9298ef0e4fd6ddff035b11 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/重质纯碱市场价(沙河;送到价).xlsx | 122159 | c7f1271df4abb20a0fd603c2c2338423c6764512cb2a9fcfa7e36d13b002a3b5 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/low-e开工率(周).xlsx | 9486 | 1b95ad605efc7a2bd8a96722c22dff52359e2ebe477474f8d20c53317e71c319 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/产能产量产能利用率/产能利用率VS产线.xlsx | 60135 | 232003d4a262cba74bdc96d4b329a98c563d5b3ae2ad1eb7f38432a9a22441b6 |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/库存专题/库存可用天数.xlsx | 46574 | 6871288f94fd7bdeb6ab243cb01f6333a2ddde43bd3e14f8b434dec37054bd5e |
|
||||
| 玻璃/数据库/玻璃纯碱供需数据/需求专区/下游深加工.xlsx | 26927 | b8ada6855b123a2028604bda7f126a1982ad6fa8b4570e5f9bebc19cb28d47e0 |
|
||||
| 玻璃/玻璃框架/供给/政策限制.xlsx | 10843 | 5467ae33841b3c5ff6673f8dab4c0ffbe0c99f6a3ba372c7d70323e628cb3f5b |
|
||||
|
||||
## 说明
|
||||
|
||||
PDF 文本提取质量受中文字体和版式影响,行业标准与投教材料未被未经核验地改写为长期事实。Excel 长时序数据未复制,Wiki 只保留字段、口径、公式、质量风险和可复用研究方法。
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 966 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,60 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..")
|
||||
$ExePath = Join-Path $RepoRoot "src-tauri/target/release/llm-wiki.exe"
|
||||
$PdfiumPath = Join-Path $RepoRoot "src-tauri/pdfium/pdfium.dll"
|
||||
$McpRoot = Join-Path $RepoRoot "mcp-server"
|
||||
$DistRoot = Join-Path $RepoRoot "dist-portable"
|
||||
$PortableRoot = Join-Path $DistRoot "LLM-Wiki-$Version-windows-x64-portable"
|
||||
$ZipPath = Join-Path $DistRoot "LLM-Wiki-$Version-windows-x64-portable.zip"
|
||||
|
||||
if (!(Test-Path $ExePath)) {
|
||||
throw "Tauri executable was not found at $ExePath"
|
||||
}
|
||||
if (!(Test-Path $PdfiumPath)) {
|
||||
throw "PDFium DLL was not found at $PdfiumPath"
|
||||
}
|
||||
foreach ($Path in @(
|
||||
(Join-Path $McpRoot "package.json"),
|
||||
(Join-Path $McpRoot "dist"),
|
||||
(Join-Path $McpRoot "node_modules")
|
||||
)) {
|
||||
if (!(Test-Path $Path)) {
|
||||
throw "Required MCP resource was not found at $Path. Run npm --prefix mcp-server ci and npm run mcp:build first."
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $PortableRoot) {
|
||||
Remove-Item -Recurse -Force $PortableRoot
|
||||
}
|
||||
if (Test-Path $ZipPath) {
|
||||
Remove-Item -Force $ZipPath
|
||||
}
|
||||
New-Item -ItemType Directory -Force $PortableRoot | Out-Null
|
||||
|
||||
Copy-Item $ExePath (Join-Path $PortableRoot "LLM Wiki.exe")
|
||||
|
||||
New-Item -ItemType Directory -Force (Join-Path $PortableRoot "pdfium") | Out-Null
|
||||
Copy-Item $PdfiumPath (Join-Path $PortableRoot "pdfium/pdfium.dll")
|
||||
|
||||
$PortableMcpRoot = Join-Path $PortableRoot "mcp-server"
|
||||
New-Item -ItemType Directory -Force $PortableMcpRoot | Out-Null
|
||||
Copy-Item (Join-Path $McpRoot "package.json") (Join-Path $PortableMcpRoot "package.json")
|
||||
Copy-Item -Recurse (Join-Path $McpRoot "dist") (Join-Path $PortableMcpRoot "dist")
|
||||
Copy-Item -Recurse (Join-Path $McpRoot "node_modules") (Join-Path $PortableMcpRoot "node_modules")
|
||||
|
||||
@"
|
||||
LLM Wiki Windows Portable
|
||||
|
||||
Run "LLM Wiki.exe" from this folder. Keep the pdfium/ and mcp-server/ folders next to the executable.
|
||||
|
||||
This portable package does not install start-menu shortcuts or auto-update hooks. It still stores app data in the normal LLM Wiki application data directory.
|
||||
"@ | Set-Content -Encoding UTF8 (Join-Path $PortableRoot "README-portable.txt")
|
||||
|
||||
Compress-Archive -Path (Join-Path $PortableRoot "*") -DestinationPath $ZipPath -CompressionLevel Optimal
|
||||
Write-Host "Created $ZipPath"
|
||||
@@ -0,0 +1,204 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
args: '--target aarch64-apple-darwin'
|
||||
rust_target: aarch64-apple-darwin
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
rust_target: ''
|
||||
# GitHub-hosted ARM Linux runner (free for public repos
|
||||
# since 2024). Native build — no cross-compile of webkit2gtk.
|
||||
- platform: ubuntu-22.04-arm
|
||||
args: ''
|
||||
rust_target: ''
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
rust_target: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
# GitHub-hosted runners occasionally hit crates.io HTTP/2 stream
|
||||
# resets while downloading large dependency graphs. Cargo retries plus
|
||||
# HTTP/1.1 transport make release builds less flaky across the matrix.
|
||||
CARGO_HTTP_MULTIPLEXING: "false"
|
||||
CARGO_NET_RETRY: "5"
|
||||
CARGO_HTTP_TIMEOUT: "60"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install protoc (macOS)
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
run: brew install protobuf
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# xdg-utils provides /usr/bin/xdg-open, which Tauri's
|
||||
# AppImage bundler embeds into the produced AppImage.
|
||||
# Pre-installed on the x86_64 runner image but NOT on
|
||||
# the ARM64 image — list it explicitly so both arches
|
||||
# bundle cleanly regardless of future image drift.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf protobuf-compiler xdg-utils
|
||||
|
||||
# The repo ships pre-downloaded PDFium binaries under
|
||||
# src-tauri/pdfium/ for every supported architecture
|
||||
# (libpdfium.so = Linux x86_64, libpdfium-arm64.so = Linux aarch64,
|
||||
# libpdfium.dylib = macOS arm64, pdfium.dll = Windows).
|
||||
# For architectures whose bundled filename must
|
||||
# stay `libpdfium.*`, swap the matching binary into place before cargo
|
||||
# runs. We do NOT pull from bblanchon/pdfium-binaries during CI — that
|
||||
# download has historically failed often enough that committing the
|
||||
# binaries is the maintenance-friendly path.
|
||||
- name: Verify PDFium binary checksums
|
||||
if: matrix.platform != 'windows-latest'
|
||||
run: shasum -a 256 -c src-tauri/pdfium/SHA256SUMS
|
||||
|
||||
# rust_target is empty for both Ubuntu rows, so platform is the
|
||||
# only stable discriminator for the Linux ARM swap.
|
||||
- name: Use ARM64 pdfium binary (Ubuntu ARM only)
|
||||
if: matrix.platform == 'ubuntu-22.04-arm'
|
||||
run: |
|
||||
cp src-tauri/pdfium/libpdfium-arm64.so src-tauri/pdfium/libpdfium.so
|
||||
file src-tauri/pdfium/libpdfium.so
|
||||
|
||||
- name: Install protoc (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Prepare MCP server resources
|
||||
run: |
|
||||
npm --prefix mcp-server ci
|
||||
npm run mcp:build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
# Tag-triggered runs publish a real GitHub Release.
|
||||
# Manual (workflow_dispatch) runs leave tagName/releaseName
|
||||
# empty so tauri-action skips the release-upload step and
|
||||
# just produces bundle artifacts — useful for testing a
|
||||
# branch build without polluting the Releases page.
|
||||
tagName: ${{ github.event_name == 'push' && github.ref_name || '' }}
|
||||
releaseName: ${{ github.event_name == 'push' && format('LLM Wiki {0}', github.ref_name) || '' }}
|
||||
releaseBody: 'See the assets below for download links.'
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Package Windows portable zip
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = node -p "require('./package.json').version"
|
||||
.github/scripts/package-windows-portable.ps1 -Version $version
|
||||
|
||||
- name: Attach Windows portable zip to release
|
||||
if: github.event_name == 'push' && matrix.platform == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist-portable/*.zip --clobber
|
||||
|
||||
# On workflow_dispatch, no release is created, so the bundles
|
||||
# would otherwise be discarded with the runner. Upload them as
|
||||
# workflow artifacts so the maintainer can `gh run download`
|
||||
# the .msi / .exe / .dmg / .deb to test locally. Skipped on
|
||||
# tag pushes since the release page already has them.
|
||||
- name: Upload bundles as workflow artifacts (manual runs only)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundle-${{ matrix.platform }}
|
||||
# Glob covers both targeted (e.g.
|
||||
# target/aarch64-apple-darwin/release/...) and default
|
||||
# (target/release/...) build paths.
|
||||
path: |
|
||||
src-tauri/target/**/release/bundle/msi/*.msi
|
||||
src-tauri/target/**/release/bundle/nsis/*.exe
|
||||
src-tauri/target/**/release/bundle/dmg/*.dmg
|
||||
src-tauri/target/**/release/bundle/deb/*.deb
|
||||
src-tauri/target/**/release/bundle/appimage/*.AppImage
|
||||
dist-portable/*.zip
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
package-extension:
|
||||
name: Package browser extension
|
||||
needs: build
|
||||
# Browser extension is only published as part of an actual
|
||||
# tagged release; manual builds don't need it.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync extension manifest version and zip
|
||||
run: |
|
||||
# Pull version from package.json so we have a single source of
|
||||
# truth. Chrome's manifest requires numeric-only version
|
||||
# (e.g. 0.3.5), which matches the repo's semver convention.
|
||||
APP_VERSION=$(node -p "require('./package.json').version")
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'extension/manifest.json';
|
||||
const m = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||
m.version = '${APP_VERSION}';
|
||||
fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\n');
|
||||
"
|
||||
mkdir -p dist-extension
|
||||
(cd extension && zip -r "../dist-extension/llm-wiki-extension-${APP_VERSION}.zip" . -x "*.DS_Store")
|
||||
ls -la dist-extension
|
||||
|
||||
- name: Attach extension zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist-extension/*.zip --clobber
|
||||
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install protoc (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
run: brew install protobuf
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf protobuf-compiler
|
||||
|
||||
- name: Install protoc (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: choco install protoc -y
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Check frontend build
|
||||
run: npx vite build
|
||||
|
||||
- name: Prepare MCP server resources
|
||||
run: |
|
||||
npm --prefix mcp-server ci
|
||||
npm run mcp:build
|
||||
|
||||
- name: Check Rust build
|
||||
working-directory: src-tauri
|
||||
run: cargo build
|
||||
@@ -0,0 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
# RC artifacts pulled from CI for local Windows / Intel Mac verification.
|
||||
# These are pre-release binaries downloaded via `gh run download`, never
|
||||
# meant for the repo.
|
||||
dist-rc/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
|
||||
# Tauri generated files
|
||||
src-tauri/gen/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Internal docs (not shipped)
|
||||
docs/
|
||||
AGENTS.md
|
||||
|
||||
# Benchmark and test data (local only)
|
||||
tests/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Brainstorm assets (not tracked in source control)
|
||||
.superpowers/
|
||||
.claude/
|
||||
@@ -0,0 +1,676 @@
|
||||
LLM Wiki — Copyright (C) 2024-2026 Yong Su
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,503 @@
|
||||
# LLM Wiki
|
||||
|
||||
<p align="center">
|
||||
<img src="logo.jpg" width="128" height="128" style="border-radius: 22%;" alt="LLM Wiki Logo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A personal knowledge base that builds itself.</strong><br>
|
||||
LLM reads your documents, builds a structured wiki, and keeps it current.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-is-this">What is this?</a> •
|
||||
<a href="#what-we-changed--added">Features</a> •
|
||||
<a href="#tech-stack">Tech Stack</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#credits">Credits</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README_CN.md">中文</a> | <a href="README_JA.md">日本語</a> | <a href="README_KO.md">한국어</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/overview.jpg" width="100%" alt="Overview">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Two-Step Chain-of-Thought Ingest** — LLM analyzes first, then generates wiki pages with source traceability and incremental cache
|
||||
- **Multimodal Image Ingestion** — extract embedded images from PDFs, generate factual captions with a vision LLM, surface them in image-aware search results with lightbox preview and jump-to-source
|
||||
- **Multi-format Document Parsing** — ingest PDF, Office documents, EPUB/MOBI, Org mode, images, media, web clips, and batches of URLs, with built-in, cloud, or local MinerU PDF processing
|
||||
- **Flexible Model Configuration** — configure models per project, route Chat and Ingest independently, and manage custom providers, headers, and streaming output
|
||||
- **Source-grounded Retrieval** — use Read Sources Only mode to answer exclusively from original imported material
|
||||
- **Project Management & Migration** — export and import complete project archives across devices, and rebuild the Wiki index from existing pages
|
||||
- **4-Signal Knowledge Graph** — relevance model with direct links, source overlap, Adamic-Adar, and type affinity
|
||||
- **Louvain Community Detection** — automatic knowledge cluster discovery with cohesion scoring
|
||||
- **Graph Insights** — surprising connections and knowledge gaps with one-click Deep Research
|
||||
- **Vector Semantic Search** — optional embedding-based retrieval via LanceDB, supports any OpenAI-compatible endpoint
|
||||
- **Persistent Ingest Queue** — serial processing with crash recovery, cancel, retry, and progress visualization
|
||||
- **Folder Import** — recursive folder import preserving directory structure, folder context as LLM classification hint
|
||||
- **Source Folder Auto-Watch** — detects external changes in `raw/sources/` and keeps ingest/delete cleanup in sync
|
||||
- **Deep Research** — LLM-optimized search topics, multi-query web search via Tavily, SerpApi, or SearXNG, auto-ingest results into wiki
|
||||
- **Rust Backend Chat Agent** — tool-using chat runtime with wiki/source/graph/web retrieval, workspace file generation, shell approval, cancellation, and streaming tool events
|
||||
- **Agent Skills** — scan and enable local `SKILL.md` folders, select skills with `/skill`, and let the Agent read skill instructions on demand
|
||||
- **Generated Outputs Preview** — Agent-created Markdown, HTML, images, and other workspace files appear as outputs with preview and quick folder access
|
||||
- **Mermaid Diagram Rendering** — render Mermaid code blocks directly in chat and preview, with compact syntax-error cards instead of raw parser output
|
||||
- **Async Review System** — LLM flags items for human judgment, predefined actions, pre-generated search queries
|
||||
- **Chrome Web Clipper** — one-click web page capture with auto-ingest into knowledge base
|
||||
- **Local HTTP API + MCP Server + AI Agent Skill** — built-in `127.0.0.1:19828` JSON API and bundled MCP server for hybrid search, file read, graph traversal, and source rescan; ready-made [agent skill](https://github.com/nashsu/llm_wiki_skill) installs into Claude Code / Codex with one command (`npx skills add …`)
|
||||
|
||||
## What is this?
|
||||
|
||||
LLM Wiki is a cross-platform desktop application that turns your documents into an organized, interlinked knowledge base — automatically. Instead of traditional RAG (retrieve-and-answer from scratch every time), the LLM **incrementally builds and maintains a persistent wiki** from your sources. Knowledge is compiled once and kept current, not re-derived on every query.
|
||||
|
||||
This project is based on [Karpathy's LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) — a methodology for building personal knowledge bases using LLMs. llm_wiki is created and maintained by [nash_su](https://x.com/nash_su), who implemented the core ideas as a full desktop application with significant enhancements.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/llm_wiki_arch.jpg" width="100%" alt="LLM Wiki Architecture">
|
||||
</p>
|
||||
|
||||
## Credits
|
||||
|
||||
The foundational methodology comes from **Andrej Karpathy**'s [llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f), which describes the pattern of using LLMs to incrementally build and maintain a personal wiki. The original document is an abstract design pattern; this project is a concrete implementation with substantial extensions.
|
||||
|
||||
## What We Kept from the Original
|
||||
|
||||
The core architecture follows Karpathy's design faithfully:
|
||||
|
||||
- **Three-layer architecture**: Raw Sources (immutable) → Wiki (LLM-generated) → Schema (rules & config)
|
||||
- **Three core operations**: Ingest, Query, Lint
|
||||
- **index.md** as the content catalog and LLM navigation entry point
|
||||
- **log.md** as the chronological operation record with parseable format
|
||||
- **[[wikilink]]** syntax for cross-references
|
||||
- **YAML frontmatter** on every wiki page
|
||||
- **Obsidian compatibility** — the wiki directory works as an Obsidian vault
|
||||
- **Human curates, LLM maintains** — the fundamental role division
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/5-obsidian_compatibility.jpg" width="100%" alt="Obsidian Compatibility">
|
||||
</p>
|
||||
|
||||
## What We Changed & Added
|
||||
|
||||
### 1. From CLI to Desktop Application
|
||||
|
||||
The original is an abstract pattern document designed to be copy-pasted to an LLM agent. We built it into a **full cross-platform desktop application** with:
|
||||
- **Three-column layout**: Knowledge Tree / File Tree (left) + Chat (center) + Preview (right)
|
||||
- **Icon sidebar** for switching between Wiki, Sources, Search, Graph, Lint, Review, Deep Research, Settings
|
||||
- **Custom resizable panels** — drag-to-resize left and right panels with min/max constraints
|
||||
- **Activity panel** — real-time processing status showing file-by-file ingest progress
|
||||
- **All state persisted** — conversations, settings, review items, project config survive restarts
|
||||
- **Scenario templates** — Research, Reading, Personal Growth, Business, General — each pre-configures purpose.md and schema.md
|
||||
|
||||
### 2. Purpose.md — The Wiki's Soul
|
||||
|
||||
The original has Schema (how the wiki works) but no formal place for **why** the wiki exists. We added `purpose.md`:
|
||||
- Defines goals, key questions, research scope, evolving thesis
|
||||
- LLM reads it during every ingest and query for context
|
||||
- LLM can suggest updates based on usage patterns
|
||||
- Different from schema — schema is structural rules, purpose is directional intent
|
||||
|
||||
### 3. Two-Step Chain-of-Thought Ingest
|
||||
|
||||
The original describes a single-step ingest where the LLM reads and writes simultaneously. We split it into **two sequential LLM calls** for significantly better quality:
|
||||
|
||||
```
|
||||
Step 1 (Analysis): LLM reads source → structured analysis
|
||||
- Key entities, concepts, arguments
|
||||
- Connections to existing wiki content
|
||||
- Contradictions & tensions with existing knowledge
|
||||
- Recommendations for wiki structure
|
||||
|
||||
Step 2 (Generation): LLM takes analysis → generates wiki files
|
||||
- Source summary with frontmatter (type, title, sources[])
|
||||
- Entity pages, concept pages with cross-references
|
||||
- Updated index.md, log.md, overview.md
|
||||
- Review items for human judgment
|
||||
- Search queries for Deep Research
|
||||
```
|
||||
|
||||
Additional ingest enhancements beyond the original:
|
||||
- **SHA256 incremental cache** — source file content is hashed before ingest; unchanged files are skipped automatically, saving LLM tokens and time
|
||||
- **Persistent ingest queue** — serial processing prevents concurrent LLM calls; queue persisted to disk, survives app restart; failed tasks auto-retry up to 3 times
|
||||
- **Folder import** — recursive folder import preserving directory structure; folder path passed to LLM as classification context (e.g., "papers > energy" helps categorize content)
|
||||
- **Source folder auto-watch** — files added, edited, or deleted in `raw/sources/` outside the app are picked up automatically and reuse the same ingest/delete lifecycle as in-app actions
|
||||
- **Queue visualization** — Activity Panel shows progress bar, pending/processing/failed tasks with cancel and retry buttons
|
||||
- **Auto-embedding** — when vector search is enabled, new pages are automatically embedded after ingest
|
||||
- **Source traceability** — every generated wiki page includes a `sources: []` field in YAML frontmatter, linking back to the raw source files that contributed to it
|
||||
- **overview.md auto-update** — global summary page regenerated on every ingest to reflect the latest state of the wiki
|
||||
- **Guaranteed source summary** — fallback ensures a source summary page is always created, even if the LLM omits it
|
||||
- **Language-aware generation** — LLM responds in the user's configured language (English or Chinese)
|
||||
- **Progressive Sources view** — large source folders render progressively while scrolling, keeping big source collections responsive
|
||||
|
||||
### 4. Knowledge Graph with Relevance Model
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/3-knowledge_graph.jpg" width="100%" alt="Knowledge Graph">
|
||||
</p>
|
||||
|
||||
The original mentions `[[wikilinks]]` for cross-references but has no graph analysis. We built a **full knowledge graph visualization and relevance engine**:
|
||||
|
||||
**4-Signal Relevance Model:**
|
||||
| Signal | Weight | Description |
|
||||
|--------|--------|-------------|
|
||||
| Direct link | ×3.0 | Pages linked via `[[wikilinks]]` |
|
||||
| Source overlap | ×4.0 | Pages sharing the same raw source (via frontmatter `sources[]`) |
|
||||
| Adamic-Adar | ×1.5 | Pages sharing common neighbors (weighted by neighbor degree) |
|
||||
| Type affinity | ×1.0 | Bonus for same page type (entity↔entity, concept↔concept) |
|
||||
|
||||
**Graph Visualization (sigma.js + graphology + ForceAtlas2):**
|
||||
- Node colors by page type or community, sizes scaled by link count (√ scaling)
|
||||
- Edge thickness and color by relevance weight (green=strong, gray=weak)
|
||||
- Hover interaction: neighbors stay visible, non-neighbors dim, edges highlight with relevance score label
|
||||
- Zoom controls (ZoomIn, ZoomOut, Fit-to-screen)
|
||||
- Position caching prevents layout jumps when data updates
|
||||
- Legend switches between type counts and community info based on coloring mode
|
||||
|
||||
### 5. Louvain Community Detection
|
||||
|
||||
Not in the original. Automatic discovery of knowledge clusters using the **Louvain algorithm** (graphology-communities-louvain):
|
||||
|
||||
- **Auto-clustering** — discovers which pages naturally group together based on link topology, independent of predefined page types
|
||||
- **Type / Community toggle** — switch between coloring nodes by page type (entity, concept, source...) or by discovered knowledge cluster
|
||||
- **Cohesion scoring** — each community scored by intra-edge density (actual edges / possible edges); low-cohesion clusters (< 0.15) flagged with warning
|
||||
- **12-color palette** — distinct visual separation between clusters
|
||||
- **Community legend** — shows top node label, member count, and cohesion per cluster
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/kg_community.jpg" width="100%" alt="Louvain Community Detection">
|
||||
</p>
|
||||
|
||||
### 6. Graph Insights — Surprising Connections & Knowledge Gaps
|
||||
|
||||
Not in the original. The system **automatically analyzes graph structure** to surface actionable insights:
|
||||
|
||||
**Surprising Connections:**
|
||||
- Detects unexpected relationships: cross-community edges, cross-type links, peripheral↔hub couplings
|
||||
- Composite surprise score ranks the most noteworthy connections
|
||||
- Dismissable — mark connections as reviewed so they don't reappear
|
||||
|
||||
**Knowledge Gaps:**
|
||||
- **Isolated pages** (degree ≤ 1) — pages with few or no connections to the rest of the wiki
|
||||
- **Sparse communities** (cohesion < 0.15, ≥ 3 pages) — knowledge areas with weak internal cross-references
|
||||
- **Bridge nodes** (connecting 3+ clusters) — critical junction pages that hold multiple knowledge areas together
|
||||
|
||||
**Interactive:**
|
||||
- Click any insight card to **highlight** corresponding nodes and edges in the graph; click again to deselect
|
||||
- Knowledge gaps and bridge nodes have a **Deep Research button** — triggers LLM-optimized research with domain-aware topics (reads overview.md + purpose.md for context)
|
||||
- Research topic shown in **editable confirmation dialog** before starting — user can refine topic and search queries
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/kg_insights.jpg" width="100%" alt="Graph Insights">
|
||||
</p>
|
||||
|
||||
### 7. Optimized Query Retrieval Pipeline
|
||||
|
||||
The original describes a simple query where the LLM reads relevant pages. We built a **multi-phase retrieval pipeline** with optional vector search and budget control:
|
||||
|
||||
```
|
||||
Phase 1: Tokenized Search
|
||||
- English: word splitting + stop word removal
|
||||
- Chinese: CJK bigram tokenization (每个 → [每个, 个…])
|
||||
- Title match bonus (+10 score)
|
||||
- Searches both wiki/ and raw/sources/
|
||||
|
||||
Phase 1.5: Vector Semantic Search (optional)
|
||||
- Embedding via any OpenAI-compatible /v1/embeddings endpoint
|
||||
- Stored in LanceDB (Rust backend) for fast ANN retrieval
|
||||
- Cosine similarity finds semantically related pages even without keyword overlap
|
||||
- Results merged into search: boosts existing matches + adds new discoveries
|
||||
|
||||
Phase 2: Graph Expansion
|
||||
- Top search results used as seed nodes
|
||||
- 4-signal relevance model finds related pages
|
||||
- 2-hop traversal with decay for deeper connections
|
||||
|
||||
Phase 3: Budget Control
|
||||
- Configurable context window: 4K → 1M tokens
|
||||
- Proportional allocation: 60% wiki pages, 20% chat history, 5% index, 15% system
|
||||
- Pages prioritized by combined search + graph relevance score
|
||||
|
||||
Phase 4: Context Assembly
|
||||
- Numbered pages with full content (not just summaries)
|
||||
- System prompt includes: purpose.md, language rules, citation format, index.md
|
||||
- LLM instructed to cite pages by number: [1], [2], etc.
|
||||
```
|
||||
|
||||
**Vector Search** is fully optional — disabled by default, enabled in Settings with independent endpoint, API key, and model configuration. When disabled, the pipeline falls back to tokenized search + graph expansion. Benchmark: overall recall improved from 58.2% to 71.4% with vector search enabled.
|
||||
|
||||
### 8. Multi-Conversation Chat with Persistence
|
||||
|
||||
The original has a single query interface. We built **full multi-conversation support**:
|
||||
|
||||
- **Independent chat sessions** — create, rename, delete conversations
|
||||
- **Conversation sidebar** — quick switching between topics
|
||||
- **Per-conversation persistence** — each conversation saved to `.llm-wiki/chats/{id}.json`
|
||||
- **Configurable history depth** — limit how many messages are sent as context (default: 10)
|
||||
- **Cited references panel** — collapsible section on each response showing which wiki pages were used, grouped by type with icons
|
||||
- **Reference persistence** — cited pages stored directly in message data, stable across restarts
|
||||
- **Regenerate** — re-generate the last response with one click (removes last assistant + user message pair, re-sends)
|
||||
- **Save to Wiki** — archive valuable answers to `wiki/queries/`, then auto-ingest to extract entities/concepts into the knowledge network
|
||||
|
||||
### 9. Rust Backend Chat Agent & Skills
|
||||
|
||||
Not in the original. Chat now runs through a Rust backend Agent runtime rather than a browser-only TypeScript loop:
|
||||
|
||||
- **Tool-using Agent** — can choose wiki search, source search, graph search, web search, AnyTXT, workspace file tools, approved shell commands, and skill file reads
|
||||
- **Skill management** — scan project and user skill folders, enable or disable skills, and pick a skill per conversation with `/skill` completion
|
||||
- **Generated workspace outputs** — files produced by Agent tools are kept under `agent-workspace/`, shown as generated outputs, and can be previewed or opened from the chat
|
||||
- **User interaction forms** — skills can ask for structured user input such as single choice, multiple choice, or free text without hardcoding skill-specific UI
|
||||
- **Safer execution model** — project workspace commands can continue smoothly, while external shell commands still require explicit approval
|
||||
|
||||
### 10. Thinking / Reasoning Display
|
||||
|
||||
Not in the original. For LLMs that emit `<think>` blocks (DeepSeek, QwQ, etc.):
|
||||
|
||||
- **Streaming thinking** — rolling 5-line display with opacity fade during generation
|
||||
- **Collapsed by default** — thinking blocks hidden after completion, click to expand
|
||||
- **Visual separation** — thinking content shown in distinct style, separate from the main response
|
||||
|
||||
### 11. Markdown Rendering: KaTeX Math & Mermaid Diagrams
|
||||
|
||||
Not in the original. Rich Markdown rendering across chat and preview:
|
||||
|
||||
- **KaTeX rendering** — inline `$...$` and block `$$...$$` formulas rendered via remark-math + rehype-katex
|
||||
- **Milkdown math plugin** — preview editor renders math natively via @milkdown/plugin-math
|
||||
- **Auto-detection** — bare `\begin{aligned}` and other LaTeX environments automatically wrapped with `$$` delimiters
|
||||
- **Unicode fallback** — 100+ symbol mappings (α, ∑, →, ≤, etc.) for simple inline notation outside math blocks
|
||||
- **Mermaid code blocks** — fenced `mermaid` diagrams render directly as flowcharts, sequence diagrams, and other Mermaid-supported visuals
|
||||
- **Compact Mermaid errors** — syntax failures are captured inside a small error card instead of spilling raw parser output into the chat
|
||||
|
||||
### 12. Review System (Async Human-in-the-Loop)
|
||||
|
||||
The original suggests staying involved during ingest. We added an **asynchronous review queue**:
|
||||
|
||||
- LLM flags items needing human judgment during ingest
|
||||
- **Predefined action types**: Create Page, Deep Research, Skip — constrained to prevent LLM hallucination of arbitrary actions
|
||||
- **Search queries generated at ingest time** — LLM pre-generates optimized web search queries for each review item
|
||||
- User handles reviews at their convenience — doesn't block ingest
|
||||
|
||||
### 13. Deep Research
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/1-deepresearch.jpg" width="100%" alt="Deep Research">
|
||||
</p>
|
||||
|
||||
Not in the original. When the LLM identifies knowledge gaps:
|
||||
|
||||
- **Web search** via Tavily, SerpApi, or SearXNG finds relevant sources with full content extraction (no truncation)
|
||||
- **Provider-specific configuration** — Tavily and SerpApi use independent API keys; SerpApi supports selectable engines, while SearXNG uses a configured instance URL and search categories
|
||||
- **Multiple search queries** per topic — LLM-generated at ingest time, optimized for search engines
|
||||
- **LLM-optimized research topics** — when triggered from Graph Insights, LLM reads overview.md + purpose.md to generate domain-specific topics and queries (not generic keywords)
|
||||
- **User confirmation dialog** — editable topic and search queries shown for review before research starts
|
||||
- **LLM synthesizes** findings into a wiki research page with cross-references to existing wiki
|
||||
- **Thinking display** — `<think>` blocks shown as collapsible sections during synthesis, auto-scroll to latest content
|
||||
- **Auto-ingest** — research results automatically processed to extract entities/concepts into the wiki
|
||||
- **Task queue** with 3 concurrent tasks
|
||||
- **Research Panel** — dedicated sidebar panel with dynamic height, real-time streaming progress
|
||||
|
||||
### 14. Browser Extension (Web Clipper)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/4-chrome_extension_webclipper.jpg" width="100%" alt="Chrome Extension Web Clipper">
|
||||
</p>
|
||||
|
||||
The original mentions Obsidian Web Clipper. We built a **dedicated Chrome Extension** (Manifest V3):
|
||||
|
||||
- **Mozilla Readability.js** for accurate article extraction (strips ads, nav, sidebars)
|
||||
- **Turndown.js** for HTML → Markdown conversion with table support
|
||||
- **Project picker** — choose which wiki to clip into (supports multi-project)
|
||||
- **Local HTTP API** (port 19827, tiny_http) — Extension ↔ App communication
|
||||
- **Auto-ingest** — clipped content automatically triggers the two-step ingest pipeline
|
||||
- **Clip watcher** — polls every 3 seconds for new clips, processes automatically
|
||||
- **Offline preview** — shows extracted content even when app is not running
|
||||
|
||||
### 15. Multi-format Document Support
|
||||
|
||||
The original focuses on text/markdown. We support structured extraction preserving document semantics:
|
||||
|
||||
| Format | Method |
|
||||
|--------|--------|
|
||||
| PDF | Built-in pdf-extract (Rust) with file caching; optional MinerU Cloud, Local API, or Pipeline parsing for complex layouts |
|
||||
| DOCX | docx-rs — headings, bold/italic, lists, tables → structured Markdown |
|
||||
| PPTX | ZIP + XML — slide-by-slide extraction with heading/list structure |
|
||||
| XLSX/XLS/ODS | calamine — proper cell types, multi-sheet support, Markdown tables |
|
||||
| EPUB/MOBI | Electronic book metadata, chapters, and body text → ingest-ready content |
|
||||
| Images | Native preview (png, jpg, gif, webp, svg, etc.) |
|
||||
| Video/Audio | Built-in player |
|
||||
| Web clips | Readability.js + Turndown.js → clean Markdown |
|
||||
|
||||
> MinerU is optional. Use MinerU Cloud, an official Local API endpoint, or Local Pipeline mode for complex PDFs. Local modes keep processing on your machine, and extracted images are stored in the project-managed `wiki/media` directory. If MinerU fails, LLM Wiki falls back to the built-in parser.
|
||||
|
||||
### 16. File Deletion with Cascade Cleanup
|
||||
|
||||
The original has no deletion mechanism. We added **intelligent cascade deletion**:
|
||||
|
||||
- Deleting a source file removes its wiki summary page
|
||||
- **3-method matching** finds related wiki pages: frontmatter `sources[]` field, source summary page name, frontmatter section references
|
||||
- **Shared entity preservation** — entity/concept pages linked to multiple sources only have the deleted source removed from their `sources[]` array, not deleted entirely
|
||||
- **Index cleanup** — removed pages are purged from index.md
|
||||
- **Wikilink cleanup** — dead `[[wikilinks]]` to deleted pages are removed from remaining wiki pages
|
||||
|
||||
### 17. Configurable Context Window
|
||||
|
||||
Not in the original. Users can configure how much context the LLM receives:
|
||||
|
||||
- **Slider from 4K to 1M tokens** — adapts to different LLM capabilities
|
||||
- **Proportional budget allocation** — larger windows get proportionally more wiki content
|
||||
- **60/20/5/15 split** — wiki pages / chat history / index / system prompt
|
||||
|
||||
### 18. Cross-Platform Compatibility
|
||||
|
||||
The original is platform-agnostic (abstract pattern). We handle concrete cross-platform concerns:
|
||||
|
||||
- **Path normalization** — unified `normalizePath()` used across 22+ files, backslash → forward slash
|
||||
- **Unicode-safe string handling** — char-based slicing instead of byte-based (prevents crashes on CJK filenames)
|
||||
- **macOS close-to-hide** — close button hides window (app stays running in background), click dock icon to restore, Cmd+Q to quit
|
||||
- **Windows/Linux close confirmation** — confirmation dialog before quitting to prevent accidental data loss
|
||||
- **Tauri v2** — native desktop on macOS, Windows, Linux
|
||||
- **GitHub Actions CI/CD** — automated builds for macOS (ARM + Intel), Windows (.msi), Linux (.deb / .AppImage)
|
||||
|
||||
### 19. Other Additions
|
||||
|
||||
- **i18n** — English + Chinese interface (react-i18next)
|
||||
- **Settings persistence** — LLM provider, API key, model, context size, language saved via Tauri Store
|
||||
- **Obsidian config** — auto-generated `.obsidian/` directory with recommended settings
|
||||
- **Markdown rendering** — GFM tables with borders, proper code blocks, wikilink processing in chat and preview
|
||||
- **Multi-provider LLM support** — OpenAI, Anthropic, Google, Ollama, Custom — each with provider-specific streaming and headers
|
||||
- **Configurable LLM timeout** — adjust request timeouts for slow local models and long-running operations
|
||||
- **Configurable Firecrawl** — optional API key and custom Base URL for hosted or self-hosted services
|
||||
- **Collapsible file sidebar** — collapse Knowledge/Files navigation while preserving its state
|
||||
- **Project maintenance** — ZIP export/import for migration and deterministic `wiki/index.md` rebuilding
|
||||
- **dataVersion signaling** — graph and UI automatically refresh when wiki content changes
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Desktop | Tauri v2 (Rust backend) |
|
||||
| Frontend | React 19 + TypeScript + Vite |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| Editor | Milkdown (ProseMirror-based WYSIWYG) |
|
||||
| Graph | sigma.js + graphology + ForceAtlas2 |
|
||||
| Search | Tokenized search + graph relevance + optional vector (LanceDB) |
|
||||
| Vector DB | LanceDB (Rust, embedded, optional) |
|
||||
| Documents | pdf-extract + MinerU Cloud/Local + docx-rs + calamine + EPUB/MOBI extraction |
|
||||
| i18n | react-i18next |
|
||||
| State | Zustand |
|
||||
| LLM | Streaming fetch (OpenAI, Anthropic, Google, Ollama, Custom) |
|
||||
| Web Search | Tavily, SerpApi, SearXNG JSON API |
|
||||
|
||||
## Installation
|
||||
|
||||
### Pre-built Binaries
|
||||
|
||||
Download from [Releases](https://github.com/nashsu/llm_wiki/releases):
|
||||
- **macOS**: `.dmg` (Apple Silicon + Intel)
|
||||
- **Windows**: `.msi`
|
||||
- **Linux**: `.deb` / `.AppImage`
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node.js 20+, Rust 1.70+
|
||||
git clone https://github.com/nashsu/llm_wiki.git
|
||||
cd llm_wiki
|
||||
npm install
|
||||
npm run tauri dev # Development
|
||||
npm run tauri build # Production build
|
||||
```
|
||||
|
||||
### Chrome Extension
|
||||
|
||||
1. Open `chrome://extensions`
|
||||
2. Enable "Developer mode"
|
||||
3. Click "Load unpacked"
|
||||
4. Select the `extension/` directory
|
||||
5. Clip the current page with `Alt+Shift+L` (`Command+Shift+L` on macOS). Customize it at `chrome://extensions/shortcuts`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Launch the app → Create a new project (choose a template)
|
||||
2. Go to **Settings** → Configure your LLM provider (API key + model)
|
||||
3. Optional: configure **Web Search** providers and source folder auto-watch in Settings
|
||||
4. Go to **Sources** → Import documents (PDF, DOCX, MD, etc.)
|
||||
5. Watch the **Activity Panel** — LLM automatically builds wiki pages
|
||||
6. Use **Chat** to query your knowledge base
|
||||
7. Browse the **Knowledge Graph** to see connections
|
||||
8. Check **Review** for items needing your attention
|
||||
9. Run **Lint** periodically to maintain wiki health
|
||||
|
||||
## Local HTTP API + MCP Server + AI Agent Skill
|
||||
|
||||
LLM Wiki ships a built-in local HTTP API at `http://127.0.0.1:19828` (token-protected, `127.0.0.1`-only) so external tools — including AI agents like **Claude Code**, **Codex**, or any HTTP-capable script — can query your wiki:
|
||||
|
||||
- `GET /api/v1/health` — server status (no auth)
|
||||
- `GET /api/v1/projects` — list projects
|
||||
- `GET /api/v1/projects/{id}/files` / `files/content` — read files and content
|
||||
- `GET /api/v1/projects/{id}/reviews?status=unresolved` — export Review tab items for wiki maintenance (`status`: `unresolved`, `resolved`, or `all`; optional `type` and `limit`)
|
||||
- `PATCH /api/v1/projects/{id}/reviews/{reviewId}` — update one Review item (JSON body `{ "resolved": true, "action": "label" }`; `resolved` defaults to true, pass false to reopen)
|
||||
- `POST /api/v1/projects/{id}/reviews/resolve` — bulk-resolve Review items (JSON body `{ "ids": [...], "action": "label" }`), returns `{ resolved, notFound, count }`; the Review tab's Refresh button re-reads the result from disk
|
||||
- `POST /api/v1/projects/{id}/search` — **hybrid** retrieval (keyword + vector) returning `mode`, `tokenHits`, `vectorHits`, per-result `vectorScore`
|
||||
- `POST /api/v1/projects/{id}/chat` — non-streaming backend Agent chat endpoint returning an assistant message, references, usage, and tool events for wiki/source/web/AnyTXT retrieval; `mode: "deep"` broadens evidence collection, while the full Deep Research workspace remains available in the desktop UI
|
||||
- `GET /api/v1/projects/{id}/graph` — wikilinks graph
|
||||
- `POST /api/v1/projects/{id}/sources/rescan` — trigger a backend rescan
|
||||
|
||||
Enable the API, generate a token, and choose whether local unauthenticated access is allowed in **Settings → API + MCP**.
|
||||
|
||||
For MCP-compatible clients, LLM Wiki also includes a local MCP server in `mcp-server/`. After building it with `npm run mcp:build`, **Settings → API + MCP** shows a copyable MCP client configuration with the correct local path for your machine. The MCP tools call the same API surface, so agent clients can list projects, read files, export unresolved Review items, run hybrid search, inspect the graph, trigger source rescans, and call the same Rust backend Agent chat endpoint without custom HTTP glue code.
|
||||
|
||||
### Plug your AI agent in with one command
|
||||
|
||||
A ready-made **agent skill** for LLM Wiki lives in its own repo. Install it into Claude Code / Codex / any skills-compatible runtime:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/nashsu/llm_wiki_skill.git --skill llm-wiki
|
||||
```
|
||||
|
||||
After install, the agent can answer prompts like "what does my LLM Wiki say about X", "search my 知识库 for Y", "show the neighborhood of node Z in my wiki graph", and "rescan my wiki sources" by talking to your locally-running app — read-only by default, citing wiki page paths so you can verify in-app.
|
||||
|
||||
- **Skill repo**: <https://github.com/nashsu/llm_wiki_skill>
|
||||
- **Trigger discipline**: it intentionally does **not** trigger on generic "search my notes" / "check my Obsidian / Notion / Logseq" — only when you explicitly name LLM Wiki / `my wiki` / `知识库`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my-wiki/
|
||||
├── purpose.md # Goals, key questions, research scope
|
||||
├── schema.md # Wiki structure rules, page types
|
||||
├── raw/
|
||||
│ ├── sources/ # Uploaded documents (immutable)
|
||||
│ └── assets/ # Local images
|
||||
├── wiki/
|
||||
│ ├── index.md # Content catalog
|
||||
│ ├── log.md # Operation history
|
||||
│ ├── overview.md # Global summary (auto-updated)
|
||||
│ ├── entities/ # People, organizations, products
|
||||
│ ├── concepts/ # Theories, methods, techniques
|
||||
│ ├── sources/ # Source summaries
|
||||
│ ├── queries/ # Saved chat answers + research
|
||||
│ ├── synthesis/ # Cross-source analysis
|
||||
│ └── comparisons/ # Side-by-side comparisons
|
||||
├── .obsidian/ # Obsidian vault config (auto-generated)
|
||||
└── .llm-wiki/ # App config, chat history, review items
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=nashsu%2Fllm_wiki&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0** — see [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,500 @@
|
||||
# LLM Wiki
|
||||
|
||||
<p align="center">
|
||||
<img src="logo.jpg" width="128" height="128" style="border-radius: 22%;" alt="LLM Wiki Logo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>一个能自我构建的个人知识库。</strong><br>
|
||||
LLM 阅读你的文档,构建结构化 Wiki,并持续保持更新。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#这是什么">这是什么?</a> •
|
||||
<a href="#我们的修改与新增">功能特性</a> •
|
||||
<a href="#技术栈">技术栈</a> •
|
||||
<a href="#安装">安装</a> •
|
||||
<a href="#致谢">致谢</a> •
|
||||
<a href="#许可证">许可证</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 中文 | <a href="README_JA.md">日本語</a> | <a href="README_KO.md">한국어</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/overview.jpg" width="100%" alt="概览">
|
||||
</p>
|
||||
|
||||
## 功能亮点
|
||||
|
||||
- **两步思维链摄入** — LLM 先分析再生成 Wiki 页面,来源可追溯,支持增量缓存
|
||||
- **多模态图片摄入** — 自动提取 PDF 内嵌图片,调用视觉模型生成事实性描述,搜索结果按图文分区,支持 lightbox 预览与跳转到原始文档对应位置
|
||||
- **多格式文档解析** — 支持 PDF、Office 文档、EPUB/MOBI、Org mode、图片、音视频、网页剪藏和批量 URL 导入,并提供内置、云端或本地 MinerU PDF 处理
|
||||
- **灵活的模型配置** — 支持项目级模型配置、Chat/Ingest 独立模型路由,以及自定义 Provider、请求头和流式输出
|
||||
- **原始资料检索** — 可使用“只读原文”模式,仅依据导入的原始资料回答
|
||||
- **项目管理与迁移** — 支持完整项目归档的跨设备导入导出,并可根据现有 Wiki 页面重建索引
|
||||
- **四信号知识图谱** — 直接链接、来源重叠、Adamic-Adar、类型亲和四维关联度模型
|
||||
- **Louvain 社区检测** — 自动发现知识聚类,内聚度评分
|
||||
- **图谱洞察** — 惊奇连接与知识空白检测,一键触发 Deep Research
|
||||
- **向量语义搜索** — 可选的 embedding 检索,基于 LanceDB,支持任意 OpenAI 兼容端点
|
||||
- **持久化摄入队列** — 串行处理,崩溃恢复,取消/重试,进度可视化
|
||||
- **文件夹导入** — 递归导入保留目录结构,文件夹路径作为 LLM 分类上下文
|
||||
- **Source 文件夹自动监听** — 检测 `raw/sources/` 的外部变更,并同步触发摄入或删除清理
|
||||
- **深度研究** — LLM 智能生成搜索主题,通过 Tavily、SerpApi 或 SearXNG 进行多查询网络搜索,研究结果自动摄入 Wiki
|
||||
- **Rust 后端 Chat Agent** — 支持工具调用的聊天运行时,可进行 Wiki/Source/Graph/Web 检索、workspace 文件生成、shell 审批、取消和流式工具事件展示
|
||||
- **Agent Skills** — 扫描并启用本地 `SKILL.md` 目录,在聊天中用 `/skill` 选择,让 Agent 按需读取 Skill 指令
|
||||
- **生成物预览** — Agent 生成的 Markdown、HTML、图片等 workspace 文件会作为生成物展示,支持预览和快速打开目录
|
||||
- **Mermaid 流程图渲染** — 聊天和预览中可直接渲染 Mermaid 代码块,语法错误会显示为紧凑错误卡片
|
||||
- **异步审核系统** — LLM 在摄入时标记需人工判断的项,预定义操作,预生成搜索查询
|
||||
- **Chrome 网页剪藏** — 一键捕获网页内容,自动摄入知识库
|
||||
- **本地 HTTP API + MCP Server + AI Agent Skill** — 内置 `127.0.0.1:19828` JSON API 和随包提供的 MCP Server,支持 Hybrid 检索、文件读取、知识图谱遍历、源资料重新扫描;配套 [agent skill](https://github.com/nashsu/llm_wiki_skill) 一行命令接入 Claude Code / Codex(`npx skills add …`)
|
||||
|
||||
## 这是什么?
|
||||
|
||||
LLM Wiki 是一个跨平台桌面应用,能将你的文档自动转化为有组织、相互关联的知识库。与传统 RAG(每次查询都从头检索和回答)不同,LLM 会从你的资料中**增量构建并维护一个持久化的 Wiki**。知识只编译一次并持续更新,而非每次查询都重新推导。
|
||||
|
||||
本项目基于 [Karpathy 的 LLM Wiki 方法论](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) —— 一套使用 LLM 构建个人知识库的方法论。llm_wiki 由 [nash_su](https://x.com/nash_su) 创建和维护,并在保留核心理念的基础上,将其实现为完整桌面应用,加入了大量增强。
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/llm_wiki_arch.jpg" width="100%" alt="LLM Wiki 架构图">
|
||||
</p>
|
||||
|
||||
## 致谢
|
||||
|
||||
基础方法论来自 **Andrej Karpathy** 的 [llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f),描述了使用 LLM 增量构建和维护个人 Wiki 的设计模式。原始文档是一个抽象的设计范式;本项目是一个具体的实现,并有大量扩展。
|
||||
|
||||
## 保留的原始设计
|
||||
|
||||
核心架构忠实遵循 Karpathy 的方法论:
|
||||
|
||||
- **三层架构**:原始资料(不可变)→ Wiki(LLM 生成)→ Schema(规则和配置)
|
||||
- **三个核心操作**:Ingest(摄入)、Query(查询)、Lint(检查)
|
||||
- **index.md** 作为内容目录和 LLM 导航入口
|
||||
- **log.md** 作为可解析格式的时序操作记录
|
||||
- **[[wikilink]]** 语法用于交叉引用
|
||||
- **YAML frontmatter** 存在于每个 Wiki 页面
|
||||
- **Obsidian 兼容** —— Wiki 目录可直接作为 Obsidian 仓库使用
|
||||
- **人类策展,LLM 维护** —— 基本角色分工
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/5-obsidian_compatibility.jpg" width="100%" alt="Obsidian 兼容">
|
||||
</p>
|
||||
|
||||
## 我们的修改与新增
|
||||
|
||||
### 1. 从命令行到桌面应用
|
||||
|
||||
原始设计是一个抽象的模式文档,设计上是复制粘贴给 LLM agent 使用的。我们将其构建为**完整的跨平台桌面应用**:
|
||||
- **三栏布局**:知识树 / 文件树(左)+ 聊天(中)+ 预览(右)
|
||||
- **图标侧边栏** —— 在 Wiki、资料源、搜索、图谱、Lint、审核、深度研究、设置之间快速切换
|
||||
- **自定义可调面板** —— 左右面板支持拖拽调整大小,带最小/最大约束
|
||||
- **活动面板** —— 实时处理状态,逐文件显示摄入进度
|
||||
- **全状态持久化** —— 对话、设置、审核项、项目配置在重启后保持
|
||||
- **场景模板** —— 研究、阅读、个人成长、商业、通用 —— 每个模板预配置 purpose.md 和 schema.md
|
||||
|
||||
### 2. Purpose.md —— Wiki 的灵魂
|
||||
|
||||
原始设计有 Schema(Wiki 如何运作),但没有正式定义 **为什么** 这个 Wiki 存在。我们新增了 `purpose.md`:
|
||||
- 定义目标、关键问题、研究范围、演进中的论点
|
||||
- LLM 在每次摄入和查询时都会读取它以获取上下文
|
||||
- LLM 可以根据使用模式建议更新
|
||||
- 与 schema 不同 —— schema 是结构规则,purpose 是方向意图
|
||||
|
||||
### 3. 两步思维链摄入
|
||||
|
||||
原始设计描述的是 LLM 同时阅读和写入的单步摄入。我们将其拆分为**两次顺序 LLM 调用**,显著提升质量:
|
||||
|
||||
```
|
||||
第一步(分析):LLM 阅读资料 → 结构化分析
|
||||
- 关键实体、概念、论点
|
||||
- 与现有 Wiki 内容的关联
|
||||
- 与现有知识的矛盾和张力
|
||||
- Wiki 结构建议
|
||||
|
||||
第二步(生成):LLM 基于分析 → 生成 Wiki 文件
|
||||
- 带 frontmatter 的资料摘要(type, title, sources[])
|
||||
- 实体页面、概念页面及交叉引用
|
||||
- 更新 index.md、log.md、overview.md
|
||||
- 需要人工判断的审核项
|
||||
- 深度研究的搜索查询
|
||||
```
|
||||
|
||||
超越原始设计的摄入增强:
|
||||
- **SHA256 增量缓存** —— 摄入前检查源文件内容哈希,未变更则自动跳过,节省 LLM token 和时间
|
||||
- **持久化摄入队列** —— 串行处理防止并发 LLM 调用;队列持久化到磁盘,应用重启后自动恢复;失败任务自动重试最多 3 次
|
||||
- **文件夹导入** —— 递归导入保留目录结构;文件夹路径作为分类上下文传给 LLM(如 "papers > energy" 帮助分类)
|
||||
- **Source 文件夹自动监听** —— 在应用外新增、修改或删除 `raw/sources/` 文件时会被自动检测,并复用应用内相同的摄入/删除生命周期
|
||||
- **队列可视化** —— 活动面板显示进度条、排队/处理中/失败任务,支持取消和重试
|
||||
- **自动 Embedding** —— 开启向量搜索时,新页面摄入后自动生成 embedding
|
||||
- **来源可追溯** —— 每个生成的 Wiki 页面在 YAML frontmatter 中包含 `sources: []` 字段,链接回贡献的原始资料文件
|
||||
- **overview.md 自动更新** —— 全局概要页面在每次摄入后重新生成,反映 Wiki 最新状态
|
||||
- **保证资料摘要生成** —— 兜底机制确保资料摘要页面始终被创建,即使 LLM 遗漏
|
||||
- **语言感知生成** —— LLM 按用户配置的语言(中文或英文)响应
|
||||
- **资料源渐进渲染** —— 大型资料目录会随滚动分批渲染,保持 Sources 页面流畅
|
||||
|
||||
### 4. 知识图谱与关联度模型
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/3-knowledge_graph.jpg" width="100%" alt="知识图谱">
|
||||
</p>
|
||||
|
||||
原始设计提到了 `[[wikilinks]]` 用于交叉引用,但没有图分析。我们构建了**完整的知识图谱可视化和关联度引擎**:
|
||||
|
||||
**四信号关联度模型:**
|
||||
| 信号 | 权重 | 描述 |
|
||||
|------|------|------|
|
||||
| 直接链接 | ×3.0 | 通过 `[[wikilinks]]` 链接的页面 |
|
||||
| 来源重叠 | ×4.0 | 共享同一原始资料的页面(通过 frontmatter `sources[]`) |
|
||||
| Adamic-Adar | ×1.5 | 共享共同邻居的页面(按邻居度数加权) |
|
||||
| 类型亲和 | ×1.0 | 相同页面类型的加分(实体↔实体,概念↔概念) |
|
||||
|
||||
**图谱可视化(sigma.js + graphology + ForceAtlas2):**
|
||||
- 按页面类型或社区着色节点,按链接数缩放节点大小(√ 缩放)
|
||||
- 边的粗细和颜色按关联权重变化(绿色=强,灰色=弱)
|
||||
- 悬停交互:邻居节点保持可见,非邻居变暗,边高亮并显示关联度分数
|
||||
- 缩放控件(放大、缩小、适应屏幕)
|
||||
- 位置缓存防止数据更新时布局跳动
|
||||
- 图例根据着色模式自动切换类型计数或社区信息
|
||||
|
||||
### 5. Louvain 社区检测
|
||||
|
||||
原始设计中没有。基于 **Louvain 算法**(graphology-communities-louvain)自动发现知识聚类:
|
||||
|
||||
- **自动聚类** —— 根据链接拓扑发现哪些页面自然归为一组,独立于预定义的页面类型
|
||||
- **类型 / 社区 一键切换** —— 按页面类型(实体、概念、资料...)或按发现的知识集群着色
|
||||
- **内聚度评分** —— 每个社区按内部边密度(实际边数 / 可能边数)评分;低内聚社区(< 0.15)标警告
|
||||
- **12 色调色板** —— 集群之间视觉区分清晰
|
||||
- **社区图例** —— 显示核心节点标签、成员数和内聚度
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/kg_community.jpg" width="100%" alt="Louvain 社区检测">
|
||||
</p>
|
||||
|
||||
### 6. 图谱洞察 —— 惊奇连接与知识空白
|
||||
|
||||
原始设计中没有。系统**自动分析图谱结构**,呈现可操作的洞察:
|
||||
|
||||
**惊奇连接:**
|
||||
- 检测意外关联:跨社区边、跨类型链接、边缘↔核心耦合
|
||||
- 复合惊奇度评分排序最值得关注的连接
|
||||
- 可消除 —— 标记为已查看后不再重复出现
|
||||
|
||||
**知识空白:**
|
||||
- **孤立页面**(度 ≤ 1)—— 与 Wiki 其余部分缺少连接的页面
|
||||
- **稀疏社区**(cohesion < 0.15,≥ 3 页)—— 内部交叉引用薄弱的知识领域
|
||||
- **桥接节点**(连接 3+ 个集群)—— 维系多个知识领域的关键枢纽页面
|
||||
|
||||
**交互:**
|
||||
- 点击洞察卡片**高亮**图谱中对应节点和边;再次点击取消
|
||||
- 知识空白和桥接节点附带 **Deep Research 按钮** —— 触发 LLM 智能主题生成(读取 overview.md + purpose.md 获取领域上下文)
|
||||
- 研究主题在**可编辑确认对话框**中展示 —— 用户可修改主题和搜索查询后再启动
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/kg_insights.jpg" width="100%" alt="图谱洞察">
|
||||
</p>
|
||||
|
||||
### 7. 优化的查询检索管线
|
||||
|
||||
原始设计描述了 LLM 读取相关页面的简单查询。我们构建了支持可选向量搜索的**多阶段检索管线**:
|
||||
|
||||
```
|
||||
阶段 1:分词搜索
|
||||
- 英文:分词 + 停用词过滤
|
||||
- 中文:CJK 二元组分词(每个 → [每个, 个…])
|
||||
- 标题匹配加分(+10 分)
|
||||
- 同时搜索 wiki/ 和 raw/sources/
|
||||
|
||||
阶段 1.5:向量语义搜索(可选)
|
||||
- 通过任意 OpenAI 兼容的 /v1/embeddings 端点生成 embedding
|
||||
- 存储在 LanceDB(Rust 后端)中进行快速 ANN 检索
|
||||
- 余弦相似度发现即使没有关键词重叠也语义相关的页面
|
||||
- 结果合并:增强已有匹配 + 添加新发现
|
||||
|
||||
阶段 2:图谱扩展
|
||||
- 搜索结果作为种子节点
|
||||
- 四信号关联度模型发现相关页面
|
||||
- 2 跳遍历带衰减,发现更深层关联
|
||||
|
||||
阶段 3:预算控制
|
||||
- 可配置上下文窗口:4K → 1M tokens
|
||||
- 比例分配:60% Wiki 页面,20% 聊天历史,5% 索引,15% 系统提示
|
||||
- 页面按搜索 + 图谱关联度综合分数排序
|
||||
|
||||
阶段 4:上下文组装
|
||||
- 编号页面附完整内容(非仅摘要)
|
||||
- 系统提示包含:purpose.md、语言规则、引用格式、index.md
|
||||
- LLM 被指示按编号引用页面:[1]、[2] 等
|
||||
```
|
||||
|
||||
**向量搜索**完全可选 —— 默认关闭,在设置中开启,有独立的端点、API Key 和模型配置。关闭时管线 fallback 到分词搜索 + 图谱扩展。基准测试:开启向量搜索后整体召回率从 58.2% 提升至 71.4%。
|
||||
|
||||
### 8. 多对话聊天与持久化
|
||||
|
||||
原始设计只有单一查询接口。我们构建了**完整的多对话支持**:
|
||||
|
||||
- **独立聊天会话** —— 创建、重命名、删除对话
|
||||
- **对话侧边栏** —— 快速切换不同主题
|
||||
- **逐对话持久化** —— 每个对话保存到 `.llm-wiki/chats/{id}.json`
|
||||
- **可配置历史深度** —— 限制作为上下文发送的消息数量(默认:10)
|
||||
- **引用参考面板** —— 每条回复上可折叠的区域,显示使用了哪些 Wiki 页面,按类型分组并附图标
|
||||
- **引用持久化** —— 引用的页面直接存储在消息数据中,重启后稳定不变
|
||||
- **重新生成** —— 一键重新生成最后一条回复(移除最后的助手+用户消息对,重新发送)
|
||||
- **保存到 Wiki** —— 将有价值的回答归档到 `wiki/queries/`,然后自动摄入提取实体/概念到知识网络
|
||||
|
||||
### 9. Rust 后端 Chat Agent 与 Skills
|
||||
|
||||
原始设计中没有。聊天现在由 Rust 后端 Agent runtime 驱动,而不是只在浏览器端运行 TypeScript 循环:
|
||||
|
||||
- **工具型 Agent** —— 可自主选择 Wiki 检索、Source 检索、图谱检索、网页搜索、AnyTXT、workspace 文件工具、已批准的 shell 命令和 Skill 文件读取
|
||||
- **Skill 管理** —— 扫描项目级和用户级 Skill 目录,启用或禁用 Skill,并在每个会话中通过 `/skill` 补全选择 Skill
|
||||
- **生成物管理** —— Agent 工具生成的文件统一放在 `agent-workspace/` 下,并作为生成物在聊天中展示、预览或打开目录
|
||||
- **用户交互表单** —— Skill 可以请求单选、多选或自由文本等结构化用户输入,不需要为每个 Skill 硬编码专用界面
|
||||
- **更安全的执行模型** —— 项目 workspace 内的命令可以顺畅继续执行,外部 shell 命令仍需要明确批准
|
||||
|
||||
### 10. 思维链 / 推理过程展示
|
||||
|
||||
原始设计中没有。针对会输出 `<think>` 块的 LLM(DeepSeek、QwQ 等):
|
||||
|
||||
- **流式思维展示** —— 生成中滚动显示 5 行,带透明度渐变
|
||||
- **默认折叠** —— 生成完成后思维块隐藏,点击展开
|
||||
- **视觉分离** —— 思维内容以独特样式显示,与主回复分开
|
||||
|
||||
### 11. Markdown 渲染:KaTeX 数学公式与 Mermaid 图表
|
||||
|
||||
原始设计中没有。聊天和预览支持更丰富的 Markdown 渲染:
|
||||
|
||||
- **KaTeX 渲染** —— 行内 `$...$` 和块级 `$$...$$` 公式通过 remark-math + rehype-katex 渲染
|
||||
- **Milkdown 数学插件** —— 预览编辑器通过 @milkdown/plugin-math 原生渲染数学公式
|
||||
- **自动检测** —— 裸 `\begin{aligned}` 等 LaTeX 环境自动补上 `$$` 定界符
|
||||
- **Unicode 降级** —— 100+ 符号映射(α, ∑, →, ≤ 等)用于数学块外的简单行内符号
|
||||
- **Mermaid 代码块** —— fenced `mermaid` 代码块可直接渲染为流程图、时序图等 Mermaid 支持的图表
|
||||
- **紧凑 Mermaid 错误** —— 语法错误会被收敛到小型错误卡片中,不会把原始解析器输出铺满聊天界面
|
||||
|
||||
### 12. 审核系统(异步人机协作)
|
||||
|
||||
原始设计建议在摄入时全程参与。我们新增了**异步审核队列**:
|
||||
|
||||
- LLM 在摄入过程中标记需要人工判断的项目
|
||||
- **预定义操作类型**:创建页面、深度研究、跳过 —— 约束操作防止 LLM 凭空生成任意操作
|
||||
- **摄入时生成搜索查询** —— LLM 预先为每个审核项生成优化的网络搜索查询
|
||||
- 用户可在方便时处理审核 —— 不阻塞摄入流程
|
||||
|
||||
### 13. 深度研究
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/1-deepresearch.jpg" width="100%" alt="深度研究">
|
||||
</p>
|
||||
|
||||
原始设计中没有。当 LLM 识别出知识空白时:
|
||||
|
||||
- **网络搜索** 支持 Tavily、SerpApi 或 SearXNG,查找相关资料并返回完整内容(非截断摘要)
|
||||
- **Provider 独立配置** —— Tavily 和 SerpApi 使用各自 API Key;SerpApi 支持选择搜索引擎,SearXNG 使用实例 URL 和搜索分类
|
||||
- **多条搜索查询** —— 摄入时由 LLM 生成,针对搜索引擎优化
|
||||
- **LLM 智能主题生成** —— 从图谱洞察触发时,LLM 读取 overview.md + purpose.md 生成领域精准的研究主题和查询(非泛泛关键词)
|
||||
- **用户确认对话框** —— 研究主题和搜索查询可编辑,确认后才开始研究
|
||||
- **LLM 综合** 搜索结果生成 Wiki 研究页面,并交叉引用现有 Wiki
|
||||
- **思维链展示** —— 综合过程中 `<think>` 块显示为可折叠区域,自动滚动到最新内容
|
||||
- **自动摄入** —— 研究结果自动进入两步摄入流程,提取实体/概念到 Wiki
|
||||
- **任务队列** —— 最多 3 个并发任务
|
||||
- **研究面板** —— 专用侧边面板,动态高度,实时流式进度
|
||||
|
||||
### 14. 浏览器扩展(网页剪藏)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/4-chrome_extension_webclipper.jpg" width="100%" alt="Chrome 扩展网页剪藏">
|
||||
</p>
|
||||
|
||||
原始设计提到了 Obsidian Web Clipper。我们构建了**专用 Chrome 扩展**(Manifest V3):
|
||||
|
||||
- **Mozilla Readability.js** 精确提取文章内容(去除广告、导航、侧边栏)
|
||||
- **Turndown.js** 将 HTML 转换为 Markdown,支持表格
|
||||
- **项目选择器** —— 选择剪藏到哪个 Wiki(支持多项目)
|
||||
- **本地 HTTP API**(端口 19827,tiny_http)—— 扩展 ↔ 应用通信
|
||||
- **自动摄入** —— 剪藏内容自动触发两步摄入流程
|
||||
- **剪藏监听** —— 每 3 秒轮询新剪藏,自动处理
|
||||
- **离线预览** —— 即使应用未运行也能显示提取的内容
|
||||
|
||||
### 15. 多格式文档支持
|
||||
|
||||
原始设计聚焦于纯文本/Markdown。我们支持保留文档语义的结构化提取:
|
||||
|
||||
| 格式 | 方法 |
|
||||
|------|------|
|
||||
| PDF | 内置 pdf-extract(Rust)+ 文件缓存;可选 MinerU 云端、Local API 或 Pipeline 模式解析复杂排版 |
|
||||
| DOCX | docx-rs —— 标题、加粗/斜体、列表、表格 → 结构化 Markdown |
|
||||
| PPTX | ZIP + XML —— 逐页提取,保留标题/列表结构 |
|
||||
| XLSX/XLS/ODS | calamine —— 正确的单元格类型、多工作表支持、Markdown 表格 |
|
||||
| EPUB/MOBI | 提取电子书元数据、章节和正文,转换为可摄取内容 |
|
||||
| 图片 | 原生预览(png, jpg, gif, webp, svg 等) |
|
||||
| 视频/音频 | 内置播放器 |
|
||||
| 网页剪藏 | Readability.js + Turndown.js → 干净的 Markdown |
|
||||
|
||||
> MinerU 是可选功能。复杂 PDF 可使用 MinerU 云端、官方 Local API 或本地 Pipeline 模式;本地模式无需上传文件,提取的图片会保存到项目管理的 `wiki/media` 目录。若 MinerU 失败,LLM Wiki 会回退到内置解析器。
|
||||
|
||||
### 16. 文件删除级联清理
|
||||
|
||||
原始设计没有删除机制。我们新增了**智能级联删除**:
|
||||
|
||||
- 删除资料文件时同时移除其 Wiki 摘要页面
|
||||
- **三重匹配** 查找相关 Wiki 页面:frontmatter `sources[]` 字段、资料摘要页面名称、frontmatter 章节引用
|
||||
- **共享实体保护** —— 链接到多个资料的实体/概念页面仅从其 `sources[]` 数组中移除被删除的资料,而非删除整个页面
|
||||
- **索引清理** —— 被移除的页面从 index.md 中清除
|
||||
- **Wiki 链接清理** —— 指向已删除页面的失效 `[[wikilinks]]` 从其余 Wiki 页面中移除
|
||||
|
||||
### 17. 可配置上下文窗口
|
||||
|
||||
原始设计中没有。用户可配置 LLM 接收多少上下文:
|
||||
|
||||
- **4K 到 1M tokens 滑块** —— 适配不同 LLM 的能力
|
||||
- **比例预算分配** —— 更大的窗口按比例获得更多 Wiki 内容
|
||||
- **60/20/5/15 分配** —— Wiki 页面 / 聊天历史 / 索引 / 系统提示
|
||||
|
||||
### 18. 跨平台兼容
|
||||
|
||||
原始设计与平台无关(抽象模式)。我们处理了具体的跨平台问题:
|
||||
|
||||
- **路径规范化** —— 统一的 `normalizePath()` 在 22+ 个文件中使用,反斜杠 → 正斜杠
|
||||
- **Unicode 安全字符串处理** —— 基于字符而非字节的切片(防止中文文件名导致崩溃)
|
||||
- **macOS 关闭隐藏** —— 关闭按钮隐藏窗口(程序后台运行),点击 Dock 图标恢复,Cmd+Q 退出
|
||||
- **Windows/Linux 关闭确认** —— 关闭时弹出确认对话框,防止误操作导致数据丢失
|
||||
- **Tauri v2** —— macOS、Windows、Linux 原生桌面
|
||||
- **GitHub Actions CI/CD** —— 自动构建 macOS(ARM + Intel)、Windows(.msi)、Linux(.deb / .AppImage)
|
||||
|
||||
### 19. 其他新增
|
||||
|
||||
- **国际化** —— 中英文界面(react-i18next)
|
||||
- **设置持久化** —— LLM 提供商、API 密钥、模型、上下文大小、语言通过 Tauri Store 保存
|
||||
- **Obsidian 配置** —— 自动生成 `.obsidian/` 目录及推荐设置
|
||||
- **Markdown 渲染** —— 带边框的 GFM 表格、代码块、聊天和预览中的 wikilink 处理
|
||||
- **多 LLM 提供商** —— OpenAI、Anthropic、Google、Ollama、自定义 —— 各有特定的流式传输和请求头
|
||||
- **可配置 LLM 超时** —— 可针对较慢的本地模型和长任务调整请求超时
|
||||
- **可配置 Firecrawl** —— 支持可选 API Key 和自定义 Base URL,可连接托管或自部署服务
|
||||
- **可折叠文件侧栏** —— 可收起 Knowledge/Files 导航并保存折叠状态
|
||||
- **项目维护** —— 支持 ZIP 导入导出迁移和确定性重建 `wiki/index.md`
|
||||
- **dataVersion 信号** —— 图谱和 UI 在 Wiki 内容变更时自动刷新
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 桌面 | Tauri v2(Rust 后端) |
|
||||
| 前端 | React 19 + TypeScript + Vite |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| 编辑器 | Milkdown(基于 ProseMirror 的所见即所得) |
|
||||
| 图谱 | sigma.js + graphology + ForceAtlas2 |
|
||||
| 搜索 | 分词搜索 + 图谱关联度 + 可选向量(LanceDB) |
|
||||
| 向量数据库 | LanceDB(Rust,嵌入式,可选) |
|
||||
| 文档解析 | pdf-extract + MinerU 云端/本地 + docx-rs + calamine + EPUB/MOBI 提取 |
|
||||
| 国际化 | react-i18next |
|
||||
| 状态管理 | Zustand |
|
||||
| LLM | 流式 fetch(OpenAI、Anthropic、Google、Ollama、自定义) |
|
||||
| 网络搜索 | Tavily、SerpApi、SearXNG JSON API |
|
||||
|
||||
## 安装
|
||||
|
||||
### 预编译二进制文件
|
||||
|
||||
从 [Releases](https://github.com/nashsu/llm_wiki/releases) 下载:
|
||||
- **macOS**:`.dmg`(Apple Silicon + Intel)
|
||||
- **Windows**:`.msi`
|
||||
- **Linux**:`.deb` / `.AppImage`
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
# 前置条件:Node.js 20+, Rust 1.70+
|
||||
git clone https://github.com/nashsu/llm_wiki.git
|
||||
cd llm_wiki
|
||||
npm install
|
||||
npm run tauri dev # 开发模式
|
||||
npm run tauri build # 生产构建
|
||||
```
|
||||
|
||||
### Chrome 扩展
|
||||
|
||||
1. 打开 `chrome://extensions`
|
||||
2. 启用「开发者模式」
|
||||
3. 点击「加载已解压的扩展程序」
|
||||
4. 选择 `extension/` 目录
|
||||
5. 使用 `Alt+Shift+L`(macOS 为 `Command+Shift+L`)直接剪藏当前页面;可在 `chrome://extensions/shortcuts` 中自定义快捷键
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 启动应用 → 创建新项目(选择模板)
|
||||
2. 进入 **设置** → 配置 LLM 提供商(API 密钥 + 模型)
|
||||
3. 可选:在 **设置** 中配置网络搜索 Provider 和 source 文件夹自动监听
|
||||
4. 进入 **资料源** → 导入文档(PDF、DOCX、MD 等)
|
||||
5. 观察 **活动面板** —— LLM 自动构建 Wiki 页面
|
||||
6. 使用 **聊天** 查询你的知识库
|
||||
7. 浏览 **知识图谱** 查看关联
|
||||
8. 查看 **审核** 处理需要你关注的项目
|
||||
9. 定期运行 **Lint** 维护 Wiki 健康度
|
||||
|
||||
## 本地 HTTP API + MCP Server + AI Agent Skill
|
||||
|
||||
LLM Wiki 内置一个本地 HTTP API(监听 `http://127.0.0.1:19828`,Token 鉴权,仅本机可达),任何外部工具——包括 **Claude Code**、**Codex** 这类 AI Agent,或者任意能发 HTTP 请求的脚本——都可以直接查询你的知识库:
|
||||
|
||||
- `GET /api/v1/health` —— 服务状态(无需鉴权)
|
||||
- `GET /api/v1/projects` —— 项目列表
|
||||
- `GET /api/v1/projects/{id}/files` / `files/content` —— 读取文件树与内容
|
||||
- `POST /api/v1/projects/{id}/search` —— **Hybrid 混合检索**(关键词 + 向量),返回 `mode`、`tokenHits`、`vectorHits`,每条结果带 `vectorScore`
|
||||
- `POST /api/v1/projects/{id}/chat` —— 非流式 Rust 后端 Agent 聊天接口,返回助手消息、引用、用量和工具事件;支持 Wiki/Source/Web/AnyTXT 检索,`mode: "deep"` 会扩展证据收集范围
|
||||
- `GET /api/v1/projects/{id}/graph` —— Wikilinks 知识图谱
|
||||
- `POST /api/v1/projects/{id}/sources/rescan` —— 触发后端重新扫描
|
||||
|
||||
在 **设置 → API + MCP** 中开启 API、生成 Token,并按需选择是否允许本机无鉴权访问。
|
||||
|
||||
对于兼容 MCP 的客户端,LLM Wiki 还内置了 `mcp-server/`。执行 `npm run mcp:build` 构建后,**设置 → API + MCP** 会展示一份可复制的 MCP 客户端配置,并自动填入当前机器上的真实入口路径。MCP 工具复用同一套 API 能力,因此 Agent 可以直接列出项目、读取文件、执行 Hybrid 检索、查看图谱、触发资料源重新扫描,并调用同一套 Rust 后端 Agent 聊天接口,不需要再手写 HTTP 调用。
|
||||
|
||||
### 一条命令把 AI Agent 接进你的知识库
|
||||
|
||||
LLM Wiki 配套的 **agent skill** 单独维护在另一个仓库。把它装进 Claude Code / Codex / 任意兼容 skills 的 runtime:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/nashsu/llm_wiki_skill.git --skill llm-wiki
|
||||
```
|
||||
|
||||
安装完成后,Agent 就能响应 "我的 LLM Wiki 里关于 X 是怎么说的"、"在我的知识库里搜 Y"、"展示我 wiki 图谱里 Z 的邻居"、"重新索引我的资料源" 等请求——直接调用本机运行的 App,默认只读,引用 wiki 页面路径方便你在 App 内核对。
|
||||
|
||||
- **Skill 仓库**:<https://github.com/nashsu/llm_wiki_skill>
|
||||
- **触发约束**:刻意**不会**响应"搜我的笔记"/"看我的 Obsidian / Notion / Logseq"这类泛指的请求——只有你明确说 LLM Wiki / `我的 wiki` / `我的知识库` 时才会被调用。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
my-wiki/
|
||||
├── purpose.md # 目标、关键问题、研究范围
|
||||
├── schema.md # Wiki 结构规则、页面类型
|
||||
├── raw/
|
||||
│ ├── sources/ # 上传的文档(不可变)
|
||||
│ └── assets/ # 本地图片
|
||||
├── wiki/
|
||||
│ ├── index.md # 内容目录
|
||||
│ ├── log.md # 操作历史
|
||||
│ ├── overview.md # 全局概要(自动更新)
|
||||
│ ├── entities/ # 人物、组织、产品
|
||||
│ ├── concepts/ # 理论、方法、技术
|
||||
│ ├── sources/ # 资料摘要
|
||||
│ ├── queries/ # 保存的聊天回答 + 研究
|
||||
│ ├── synthesis/ # 跨资料分析
|
||||
│ └── comparisons/ # 并列对比
|
||||
├── .obsidian/ # Obsidian 仓库配置(自动生成)
|
||||
└── .llm-wiki/ # 应用配置、聊天历史、审核项
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=nashsu%2Fllm_wiki&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=nashsu/llm_wiki&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **GNU 通用公共许可证 v3.0** 授权 —— 详见 [LICENSE](LICENSE)。
|
||||
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 608 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
||||
typeof define === 'function' && define.amd ? define(factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.TurndownService = factory());
|
||||
})(this, (function () { 'use strict';
|
||||
|
||||
function extend(destination) {
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
var source = arguments[i];
|
||||
for (var key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) destination[key] = source[key];
|
||||
}
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
function repeat(character, count) {
|
||||
return Array(count + 1).join(character);
|
||||
}
|
||||
function trimLeadingNewlines(string) {
|
||||
return string.replace(/^\n*/, '');
|
||||
}
|
||||
function trimTrailingNewlines(string) {
|
||||
// avoid match-at-end regexp bottleneck, see #370
|
||||
var indexEnd = string.length;
|
||||
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
|
||||
return string.substring(0, indexEnd);
|
||||
}
|
||||
function trimNewlines(string) {
|
||||
return trimTrailingNewlines(trimLeadingNewlines(string));
|
||||
}
|
||||
var blockElements = ['ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'];
|
||||
function isBlock(node) {
|
||||
return is(node, blockElements);
|
||||
}
|
||||
var voidElements = ['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'];
|
||||
function isVoid(node) {
|
||||
return is(node, voidElements);
|
||||
}
|
||||
function hasVoid(node) {
|
||||
return has(node, voidElements);
|
||||
}
|
||||
var meaningfulWhenBlankElements = ['A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', 'AUDIO', 'VIDEO'];
|
||||
function isMeaningfulWhenBlank(node) {
|
||||
return is(node, meaningfulWhenBlankElements);
|
||||
}
|
||||
function hasMeaningfulWhenBlank(node) {
|
||||
return has(node, meaningfulWhenBlankElements);
|
||||
}
|
||||
function is(node, tagNames) {
|
||||
return tagNames.indexOf(node.nodeName) >= 0;
|
||||
}
|
||||
function has(node, tagNames) {
|
||||
return node.getElementsByTagName && tagNames.some(function (tagName) {
|
||||
return node.getElementsByTagName(tagName).length;
|
||||
});
|
||||
}
|
||||
var markdownEscapes = [[/\\/g, '\\\\'], [/\*/g, '\\*'], [/^-/g, '\\-'], [/^\+ /g, '\\+ '], [/^(=+)/g, '\\$1'], [/^(#{1,6}) /g, '\\$1 '], [/`/g, '\\`'], [/^~~~/g, '\\~~~'], [/\[/g, '\\['], [/\]/g, '\\]'], [/^>/g, '\\>'], [/_/g, '\\_'], [/^(\d+)\. /g, '$1\\. ']];
|
||||
function escapeMarkdown(string) {
|
||||
return markdownEscapes.reduce(function (accumulator, escape) {
|
||||
return accumulator.replace(escape[0], escape[1]);
|
||||
}, string);
|
||||
}
|
||||
|
||||
var rules = {};
|
||||
rules.paragraph = {
|
||||
filter: 'p',
|
||||
replacement: function (content) {
|
||||
return '\n\n' + content + '\n\n';
|
||||
}
|
||||
};
|
||||
rules.lineBreak = {
|
||||
filter: 'br',
|
||||
replacement: function (content, node, options) {
|
||||
return options.br + '\n';
|
||||
}
|
||||
};
|
||||
rules.heading = {
|
||||
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
replacement: function (content, node, options) {
|
||||
var hLevel = Number(node.nodeName.charAt(1));
|
||||
if (options.headingStyle === 'setext' && hLevel < 3) {
|
||||
var underline = repeat(hLevel === 1 ? '=' : '-', content.length);
|
||||
return '\n\n' + content + '\n' + underline + '\n\n';
|
||||
} else {
|
||||
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n';
|
||||
}
|
||||
}
|
||||
};
|
||||
rules.blockquote = {
|
||||
filter: 'blockquote',
|
||||
replacement: function (content) {
|
||||
content = trimNewlines(content).replace(/^/gm, '> ');
|
||||
return '\n\n' + content + '\n\n';
|
||||
}
|
||||
};
|
||||
rules.list = {
|
||||
filter: ['ul', 'ol'],
|
||||
replacement: function (content, node) {
|
||||
var parent = node.parentNode;
|
||||
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
|
||||
return '\n' + content;
|
||||
} else {
|
||||
return '\n\n' + content + '\n\n';
|
||||
}
|
||||
}
|
||||
};
|
||||
rules.listItem = {
|
||||
filter: 'li',
|
||||
replacement: function (content, node, options) {
|
||||
var prefix = options.bulletListMarker + ' ';
|
||||
var parent = node.parentNode;
|
||||
if (parent.nodeName === 'OL') {
|
||||
var start = parent.getAttribute('start');
|
||||
var index = Array.prototype.indexOf.call(parent.children, node);
|
||||
prefix = (start ? Number(start) + index : index + 1) + '. ';
|
||||
}
|
||||
var isParagraph = /\n$/.test(content);
|
||||
content = trimNewlines(content) + (isParagraph ? '\n' : '');
|
||||
content = content.replace(/\n/gm, '\n' + ' '.repeat(prefix.length)); // indent
|
||||
return prefix + content + (node.nextSibling ? '\n' : '');
|
||||
}
|
||||
};
|
||||
rules.indentedCodeBlock = {
|
||||
filter: function (node, options) {
|
||||
return options.codeBlockStyle === 'indented' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
|
||||
},
|
||||
replacement: function (content, node, options) {
|
||||
return '\n\n ' + node.firstChild.textContent.replace(/\n/g, '\n ') + '\n\n';
|
||||
}
|
||||
};
|
||||
rules.fencedCodeBlock = {
|
||||
filter: function (node, options) {
|
||||
return options.codeBlockStyle === 'fenced' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
|
||||
},
|
||||
replacement: function (content, node, options) {
|
||||
var className = node.firstChild.getAttribute('class') || '';
|
||||
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
|
||||
var code = node.firstChild.textContent;
|
||||
var fenceChar = options.fence.charAt(0);
|
||||
var fenceSize = 3;
|
||||
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
|
||||
var match;
|
||||
while (match = fenceInCodeRegex.exec(code)) {
|
||||
if (match[0].length >= fenceSize) {
|
||||
fenceSize = match[0].length + 1;
|
||||
}
|
||||
}
|
||||
var fence = repeat(fenceChar, fenceSize);
|
||||
return '\n\n' + fence + language + '\n' + code.replace(/\n$/, '') + '\n' + fence + '\n\n';
|
||||
}
|
||||
};
|
||||
rules.horizontalRule = {
|
||||
filter: 'hr',
|
||||
replacement: function (content, node, options) {
|
||||
return '\n\n' + options.hr + '\n\n';
|
||||
}
|
||||
};
|
||||
rules.inlineLink = {
|
||||
filter: function (node, options) {
|
||||
return options.linkStyle === 'inlined' && node.nodeName === 'A' && node.getAttribute('href');
|
||||
},
|
||||
replacement: function (content, node) {
|
||||
var href = escapeLinkDestination(node.getAttribute('href'));
|
||||
var title = escapeLinkTitle(cleanAttribute(node.getAttribute('title')));
|
||||
var titlePart = title ? ' "' + title + '"' : '';
|
||||
return '[' + content + '](' + href + titlePart + ')';
|
||||
}
|
||||
};
|
||||
rules.referenceLink = {
|
||||
filter: function (node, options) {
|
||||
return options.linkStyle === 'referenced' && node.nodeName === 'A' && node.getAttribute('href');
|
||||
},
|
||||
replacement: function (content, node, options) {
|
||||
var href = escapeLinkDestination(node.getAttribute('href'));
|
||||
var title = cleanAttribute(node.getAttribute('title'));
|
||||
if (title) title = ' "' + escapeLinkTitle(title) + '"';
|
||||
var replacement;
|
||||
var reference;
|
||||
switch (options.linkReferenceStyle) {
|
||||
case 'collapsed':
|
||||
replacement = '[' + content + '][]';
|
||||
reference = '[' + content + ']: ' + href + title;
|
||||
break;
|
||||
case 'shortcut':
|
||||
replacement = '[' + content + ']';
|
||||
reference = '[' + content + ']: ' + href + title;
|
||||
break;
|
||||
default:
|
||||
var id = this.references.length + 1;
|
||||
replacement = '[' + content + '][' + id + ']';
|
||||
reference = '[' + id + ']: ' + href + title;
|
||||
}
|
||||
this.references.push(reference);
|
||||
return replacement;
|
||||
},
|
||||
references: [],
|
||||
append: function (options) {
|
||||
var references = '';
|
||||
if (this.references.length) {
|
||||
references = '\n\n' + this.references.join('\n') + '\n\n';
|
||||
this.references = []; // Reset references
|
||||
}
|
||||
return references;
|
||||
}
|
||||
};
|
||||
rules.emphasis = {
|
||||
filter: ['em', 'i'],
|
||||
replacement: function (content, node, options) {
|
||||
if (!content.trim()) return '';
|
||||
return options.emDelimiter + content + options.emDelimiter;
|
||||
}
|
||||
};
|
||||
rules.strong = {
|
||||
filter: ['strong', 'b'],
|
||||
replacement: function (content, node, options) {
|
||||
if (!content.trim()) return '';
|
||||
return options.strongDelimiter + content + options.strongDelimiter;
|
||||
}
|
||||
};
|
||||
rules.code = {
|
||||
filter: function (node) {
|
||||
var hasSiblings = node.previousSibling || node.nextSibling;
|
||||
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
|
||||
return node.nodeName === 'CODE' && !isCodeBlock;
|
||||
},
|
||||
replacement: function (content) {
|
||||
if (!content) return '';
|
||||
content = content.replace(/\r?\n|\r/g, ' ');
|
||||
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
|
||||
var delimiter = '`';
|
||||
var matches = content.match(/`+/gm) || [];
|
||||
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
|
||||
return delimiter + extraSpace + content + extraSpace + delimiter;
|
||||
}
|
||||
};
|
||||
rules.image = {
|
||||
filter: 'img',
|
||||
replacement: function (content, node) {
|
||||
var alt = escapeMarkdown(cleanAttribute(node.getAttribute('alt')));
|
||||
var src = escapeLinkDestination(node.getAttribute('src') || '');
|
||||
var title = cleanAttribute(node.getAttribute('title'));
|
||||
var titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : '';
|
||||
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '';
|
||||
}
|
||||
};
|
||||
function cleanAttribute(attribute) {
|
||||
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
|
||||
}
|
||||
function escapeLinkDestination(destination) {
|
||||
var escaped = destination.replace(/([<>()])/g, '\\$1');
|
||||
return escaped.indexOf(' ') >= 0 ? '<' + escaped + '>' : escaped;
|
||||
}
|
||||
function escapeLinkTitle(title) {
|
||||
return title.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a collection of rules used to convert HTML to Markdown
|
||||
*/
|
||||
|
||||
function Rules(options) {
|
||||
this.options = options;
|
||||
this._keep = [];
|
||||
this._remove = [];
|
||||
this.blankRule = {
|
||||
replacement: options.blankReplacement
|
||||
};
|
||||
this.keepReplacement = options.keepReplacement;
|
||||
this.defaultRule = {
|
||||
replacement: options.defaultReplacement
|
||||
};
|
||||
this.array = [];
|
||||
for (var key in options.rules) this.array.push(options.rules[key]);
|
||||
}
|
||||
Rules.prototype = {
|
||||
add: function (key, rule) {
|
||||
this.array.unshift(rule);
|
||||
},
|
||||
keep: function (filter) {
|
||||
this._keep.unshift({
|
||||
filter: filter,
|
||||
replacement: this.keepReplacement
|
||||
});
|
||||
},
|
||||
remove: function (filter) {
|
||||
this._remove.unshift({
|
||||
filter: filter,
|
||||
replacement: function () {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
},
|
||||
forNode: function (node) {
|
||||
if (node.isBlank) return this.blankRule;
|
||||
var rule;
|
||||
if (rule = findRule(this.array, node, this.options)) return rule;
|
||||
if (rule = findRule(this._keep, node, this.options)) return rule;
|
||||
if (rule = findRule(this._remove, node, this.options)) return rule;
|
||||
return this.defaultRule;
|
||||
},
|
||||
forEach: function (fn) {
|
||||
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
|
||||
}
|
||||
};
|
||||
function findRule(rules, node, options) {
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
var rule = rules[i];
|
||||
if (filterValue(rule, node, options)) return rule;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function filterValue(rule, node, options) {
|
||||
var filter = rule.filter;
|
||||
if (typeof filter === 'string') {
|
||||
if (filter === node.nodeName.toLowerCase()) return true;
|
||||
} else if (Array.isArray(filter)) {
|
||||
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true;
|
||||
} else if (typeof filter === 'function') {
|
||||
if (filter.call(rule, node, options)) return true;
|
||||
} else {
|
||||
throw new TypeError('`filter` needs to be a string, array, or function');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapseWhitespace function is adapted from collapse-whitespace
|
||||
* by Luc Thevenard.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* collapseWhitespace(options) removes extraneous whitespace from an the given element.
|
||||
*
|
||||
* @param {Object} options
|
||||
*/
|
||||
function collapseWhitespace(options) {
|
||||
var element = options.element;
|
||||
var isBlock = options.isBlock;
|
||||
var isVoid = options.isVoid;
|
||||
var isPre = options.isPre || function (node) {
|
||||
return node.nodeName === 'PRE';
|
||||
};
|
||||
if (!element.firstChild || isPre(element)) return;
|
||||
var prevText = null;
|
||||
var keepLeadingWs = false;
|
||||
var prev = null;
|
||||
var node = next(prev, element, isPre);
|
||||
while (node !== element) {
|
||||
if (node.nodeType === 3 || node.nodeType === 4) {
|
||||
// Node.TEXT_NODE or Node.CDATA_SECTION_NODE
|
||||
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
|
||||
if ((!prevText || / $/.test(prevText.data)) && !keepLeadingWs && text[0] === ' ') {
|
||||
text = text.substr(1);
|
||||
}
|
||||
|
||||
// `text` might be empty at this point.
|
||||
if (!text) {
|
||||
node = remove(node);
|
||||
continue;
|
||||
}
|
||||
node.data = text;
|
||||
prevText = node;
|
||||
} else if (node.nodeType === 1) {
|
||||
// Node.ELEMENT_NODE
|
||||
if (isBlock(node) || node.nodeName === 'BR') {
|
||||
if (prevText) {
|
||||
prevText.data = prevText.data.replace(/ $/, '');
|
||||
}
|
||||
prevText = null;
|
||||
keepLeadingWs = false;
|
||||
} else if (isVoid(node) || isPre(node)) {
|
||||
// Avoid trimming space around non-block, non-BR void elements and inline PRE.
|
||||
prevText = null;
|
||||
keepLeadingWs = true;
|
||||
} else if (prevText) {
|
||||
// Drop protection if set previously.
|
||||
keepLeadingWs = false;
|
||||
}
|
||||
} else {
|
||||
node = remove(node);
|
||||
continue;
|
||||
}
|
||||
var nextNode = next(prev, node, isPre);
|
||||
prev = node;
|
||||
node = nextNode;
|
||||
}
|
||||
if (prevText) {
|
||||
prevText.data = prevText.data.replace(/ $/, '');
|
||||
if (!prevText.data) {
|
||||
remove(prevText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* remove(node) removes the given node from the DOM and returns the
|
||||
* next node in the sequence.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Node} node
|
||||
*/
|
||||
function remove(node) {
|
||||
var next = node.nextSibling || node.parentNode;
|
||||
node.parentNode.removeChild(node);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* next(prev, current, isPre) returns the next node in the sequence, given the
|
||||
* current and previous nodes.
|
||||
*
|
||||
* @param {Node} prev
|
||||
* @param {Node} current
|
||||
* @param {Function} isPre
|
||||
* @return {Node}
|
||||
*/
|
||||
function next(prev, current, isPre) {
|
||||
if (prev && prev.parentNode === current || isPre(current)) {
|
||||
return current.nextSibling || current.parentNode;
|
||||
}
|
||||
return current.firstChild || current.nextSibling || current.parentNode;
|
||||
}
|
||||
|
||||
/*
|
||||
* Set up window for Node.js
|
||||
*/
|
||||
|
||||
var root = typeof window !== 'undefined' ? window : {};
|
||||
|
||||
/*
|
||||
* Parsing HTML strings
|
||||
*/
|
||||
|
||||
function canParseHTMLNatively() {
|
||||
var Parser = root.DOMParser;
|
||||
var canParse = false;
|
||||
|
||||
// Adapted from https://gist.github.com/1129031
|
||||
// Firefox/Opera/IE throw errors on unsupported types
|
||||
try {
|
||||
// WebKit returns null on unsupported types
|
||||
if (new Parser().parseFromString('', 'text/html')) {
|
||||
canParse = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return canParse;
|
||||
}
|
||||
function createHTMLParser() {
|
||||
var Parser = function () {};
|
||||
{
|
||||
if (shouldUseActiveX()) {
|
||||
Parser.prototype.parseFromString = function (string) {
|
||||
var doc = new window.ActiveXObject('htmlfile');
|
||||
doc.designMode = 'on'; // disable on-page scripts
|
||||
doc.open();
|
||||
doc.write(string);
|
||||
doc.close();
|
||||
return doc;
|
||||
};
|
||||
} else {
|
||||
Parser.prototype.parseFromString = function (string) {
|
||||
var doc = document.implementation.createHTMLDocument('');
|
||||
doc.open();
|
||||
doc.write(string);
|
||||
doc.close();
|
||||
return doc;
|
||||
};
|
||||
}
|
||||
}
|
||||
return Parser;
|
||||
}
|
||||
function shouldUseActiveX() {
|
||||
var useActiveX = false;
|
||||
try {
|
||||
document.implementation.createHTMLDocument('').open();
|
||||
} catch (e) {
|
||||
if (root.ActiveXObject) useActiveX = true;
|
||||
}
|
||||
return useActiveX;
|
||||
}
|
||||
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
|
||||
|
||||
function RootNode(input, options) {
|
||||
var root;
|
||||
if (typeof input === 'string') {
|
||||
var doc = htmlParser().parseFromString(
|
||||
// DOM parsers arrange elements in the <head> and <body>.
|
||||
// Wrapping in a custom element ensures elements are reliably arranged in
|
||||
// a single element.
|
||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>', 'text/html');
|
||||
root = doc.getElementById('turndown-root');
|
||||
} else {
|
||||
root = input.cloneNode(true);
|
||||
}
|
||||
collapseWhitespace({
|
||||
element: root,
|
||||
isBlock: isBlock,
|
||||
isVoid: isVoid,
|
||||
isPre: options.preformattedCode ? isPreOrCode : null
|
||||
});
|
||||
return root;
|
||||
}
|
||||
var _htmlParser;
|
||||
function htmlParser() {
|
||||
_htmlParser = _htmlParser || new HTMLParser();
|
||||
return _htmlParser;
|
||||
}
|
||||
function isPreOrCode(node) {
|
||||
return node.nodeName === 'PRE' || node.nodeName === 'CODE';
|
||||
}
|
||||
|
||||
function Node(node, options) {
|
||||
node.isBlock = isBlock(node);
|
||||
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
|
||||
node.isBlank = isBlank(node);
|
||||
node.flankingWhitespace = flankingWhitespace(node, options);
|
||||
return node;
|
||||
}
|
||||
function isBlank(node) {
|
||||
return !isVoid(node) && !isMeaningfulWhenBlank(node) && /^\s*$/i.test(node.textContent) && !hasVoid(node) && !hasMeaningfulWhenBlank(node);
|
||||
}
|
||||
function flankingWhitespace(node, options) {
|
||||
if (node.isBlock || options.preformattedCode && node.isCode) {
|
||||
return {
|
||||
leading: '',
|
||||
trailing: ''
|
||||
};
|
||||
}
|
||||
var edges = edgeWhitespace(node.textContent);
|
||||
|
||||
// abandon leading ASCII WS if left-flanked by ASCII WS
|
||||
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
|
||||
edges.leading = edges.leadingNonAscii;
|
||||
}
|
||||
|
||||
// abandon trailing ASCII WS if right-flanked by ASCII WS
|
||||
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
|
||||
edges.trailing = edges.trailingNonAscii;
|
||||
}
|
||||
return {
|
||||
leading: edges.leading,
|
||||
trailing: edges.trailing
|
||||
};
|
||||
}
|
||||
function edgeWhitespace(string) {
|
||||
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
|
||||
return {
|
||||
leading: m[1],
|
||||
// whole string for whitespace-only strings
|
||||
leadingAscii: m[2],
|
||||
leadingNonAscii: m[3],
|
||||
trailing: m[4],
|
||||
// empty for whitespace-only strings
|
||||
trailingNonAscii: m[5],
|
||||
trailingAscii: m[6]
|
||||
};
|
||||
}
|
||||
function isFlankedByWhitespace(side, node, options) {
|
||||
var sibling;
|
||||
var regExp;
|
||||
var isFlanked;
|
||||
if (side === 'left') {
|
||||
sibling = node.previousSibling;
|
||||
regExp = / $/;
|
||||
} else {
|
||||
sibling = node.nextSibling;
|
||||
regExp = /^ /;
|
||||
}
|
||||
if (sibling) {
|
||||
if (sibling.nodeType === 3) {
|
||||
isFlanked = regExp.test(sibling.nodeValue);
|
||||
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
|
||||
isFlanked = false;
|
||||
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
|
||||
isFlanked = regExp.test(sibling.textContent);
|
||||
}
|
||||
}
|
||||
return isFlanked;
|
||||
}
|
||||
|
||||
var reduce = Array.prototype.reduce;
|
||||
function TurndownService(options) {
|
||||
if (!(this instanceof TurndownService)) return new TurndownService(options);
|
||||
var defaults = {
|
||||
rules: rules,
|
||||
headingStyle: 'setext',
|
||||
hr: '* * *',
|
||||
bulletListMarker: '*',
|
||||
codeBlockStyle: 'indented',
|
||||
fence: '```',
|
||||
emDelimiter: '_',
|
||||
strongDelimiter: '**',
|
||||
linkStyle: 'inlined',
|
||||
linkReferenceStyle: 'full',
|
||||
br: ' ',
|
||||
preformattedCode: false,
|
||||
blankReplacement: function (content, node) {
|
||||
return node.isBlock ? '\n\n' : '';
|
||||
},
|
||||
keepReplacement: function (content, node) {
|
||||
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML;
|
||||
},
|
||||
defaultReplacement: function (content, node) {
|
||||
return node.isBlock ? '\n\n' + content + '\n\n' : content;
|
||||
}
|
||||
};
|
||||
this.options = extend({}, defaults, options);
|
||||
this.rules = new Rules(this.options);
|
||||
}
|
||||
TurndownService.prototype = {
|
||||
/**
|
||||
* The entry point for converting a string or DOM node to Markdown
|
||||
* @public
|
||||
* @param {String|HTMLElement} input The string or DOM node to convert
|
||||
* @returns A Markdown representation of the input
|
||||
* @type String
|
||||
*/
|
||||
|
||||
turndown: function (input) {
|
||||
if (!canConvert(input)) {
|
||||
throw new TypeError(input + ' is not a string, or an element/document/fragment node.');
|
||||
}
|
||||
if (input === '') return '';
|
||||
var output = process.call(this, new RootNode(input, this.options));
|
||||
return postProcess.call(this, output);
|
||||
},
|
||||
/**
|
||||
* Add one or more plugins
|
||||
* @public
|
||||
* @param {Function|Array} plugin The plugin or array of plugins to add
|
||||
* @returns The Turndown instance for chaining
|
||||
* @type Object
|
||||
*/
|
||||
|
||||
use: function (plugin) {
|
||||
if (Array.isArray(plugin)) {
|
||||
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
|
||||
} else if (typeof plugin === 'function') {
|
||||
plugin(this);
|
||||
} else {
|
||||
throw new TypeError('plugin must be a Function or an Array of Functions');
|
||||
}
|
||||
return this;
|
||||
},
|
||||
/**
|
||||
* Adds a rule
|
||||
* @public
|
||||
* @param {String} key The unique key of the rule
|
||||
* @param {Object} rule The rule
|
||||
* @returns The Turndown instance for chaining
|
||||
* @type Object
|
||||
*/
|
||||
|
||||
addRule: function (key, rule) {
|
||||
this.rules.add(key, rule);
|
||||
return this;
|
||||
},
|
||||
/**
|
||||
* Keep a node (as HTML) that matches the filter
|
||||
* @public
|
||||
* @param {String|Array|Function} filter The unique key of the rule
|
||||
* @returns The Turndown instance for chaining
|
||||
* @type Object
|
||||
*/
|
||||
|
||||
keep: function (filter) {
|
||||
this.rules.keep(filter);
|
||||
return this;
|
||||
},
|
||||
/**
|
||||
* Remove a node that matches the filter
|
||||
* @public
|
||||
* @param {String|Array|Function} filter The unique key of the rule
|
||||
* @returns The Turndown instance for chaining
|
||||
* @type Object
|
||||
*/
|
||||
|
||||
remove: function (filter) {
|
||||
this.rules.remove(filter);
|
||||
return this;
|
||||
},
|
||||
/**
|
||||
* Escapes Markdown syntax
|
||||
* @public
|
||||
* @param {String} string The string to escape
|
||||
* @returns A string with Markdown syntax escaped
|
||||
* @type String
|
||||
*/
|
||||
|
||||
escape: function (string) {
|
||||
return escapeMarkdown(string);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces a DOM node down to its Markdown string equivalent
|
||||
* @private
|
||||
* @param {HTMLElement} parentNode The node to convert
|
||||
* @returns A Markdown representation of the node
|
||||
* @type String
|
||||
*/
|
||||
|
||||
function process(parentNode) {
|
||||
var self = this;
|
||||
return reduce.call(parentNode.childNodes, function (output, node) {
|
||||
node = new Node(node, self.options);
|
||||
var replacement = '';
|
||||
if (node.nodeType === 3) {
|
||||
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
|
||||
} else if (node.nodeType === 1) {
|
||||
replacement = replacementForNode.call(self, node);
|
||||
}
|
||||
return join(output, replacement);
|
||||
}, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends strings as each rule requires and trims the output
|
||||
* @private
|
||||
* @param {String} output The conversion output
|
||||
* @returns A trimmed version of the ouput
|
||||
* @type String
|
||||
*/
|
||||
|
||||
function postProcess(output) {
|
||||
var self = this;
|
||||
this.rules.forEach(function (rule) {
|
||||
if (typeof rule.append === 'function') {
|
||||
output = join(output, rule.append(self.options));
|
||||
}
|
||||
});
|
||||
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an element node to its Markdown equivalent
|
||||
* @private
|
||||
* @param {HTMLElement} node The node to convert
|
||||
* @returns A Markdown representation of the node
|
||||
* @type String
|
||||
*/
|
||||
|
||||
function replacementForNode(node) {
|
||||
var rule = this.rules.forNode(node);
|
||||
var content = process.call(this, node);
|
||||
var whitespace = node.flankingWhitespace;
|
||||
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
||||
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins replacement to the current output with appropriate number of new lines
|
||||
* @private
|
||||
* @param {String} output The current conversion output
|
||||
* @param {String} replacement The string to append to the output
|
||||
* @returns Joined output
|
||||
* @type String
|
||||
*/
|
||||
|
||||
function join(output, replacement) {
|
||||
var s1 = trimTrailingNewlines(output);
|
||||
var s2 = trimLeadingNewlines(replacement);
|
||||
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
|
||||
var separator = '\n\n'.substring(0, nls);
|
||||
return s1 + separator + s2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an input can be converted
|
||||
* @private
|
||||
* @param {String|HTMLElement} input Describe this parameter
|
||||
* @returns Describe what it returns
|
||||
* @type String|Object|Array|Boolean|Number
|
||||
*/
|
||||
|
||||
function canConvert(input) {
|
||||
return input != null && (typeof input === 'string' || input.nodeType && (input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11));
|
||||
}
|
||||
|
||||
return TurndownService;
|
||||
|
||||
}));
|
||||
@@ -0,0 +1,56 @@
|
||||
importScripts("clipper-core.js");
|
||||
|
||||
const COMMAND_NAME = "clip-current-page";
|
||||
let badgeTimer;
|
||||
let clipInFlight = false;
|
||||
|
||||
async function setBadge(text, color, title, clearAfterMs = 0) {
|
||||
clearTimeout(badgeTimer);
|
||||
await chrome.action.setBadgeBackgroundColor({ color });
|
||||
await chrome.action.setBadgeText({ text });
|
||||
if (title) await chrome.action.setTitle({ title });
|
||||
if (clearAfterMs > 0) {
|
||||
badgeTimer = setTimeout(() => {
|
||||
void chrome.action.setBadgeText({ text: "" });
|
||||
void chrome.action.setTitle({ title: "LLM Wiki Clipper" });
|
||||
}, clearAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function clipCurrentPage(commandTab) {
|
||||
if (clipInFlight) {
|
||||
await setBadge("…", "#4f46e5", "A page clip is already in progress");
|
||||
return;
|
||||
}
|
||||
clipInFlight = true;
|
||||
const core = globalThis.LLMWikiClipper;
|
||||
try {
|
||||
await setBadge("…", "#4f46e5", "Clipping current page...");
|
||||
const settings = await core.loadSettings();
|
||||
const connection = {
|
||||
serverUrl: settings.serverUrl,
|
||||
accessToken: settings.accessToken,
|
||||
};
|
||||
const { projects, baseUrl } = await core.loadProjects(connection);
|
||||
connection.serverUrl = baseUrl;
|
||||
const project = core.selectProject(projects, settings.preferredProjectPath);
|
||||
if (!project) throw new Error("No LLM Wiki project is available");
|
||||
|
||||
const page = await core.extractActiveTab(commandTab);
|
||||
const submitted = await core.submitClip(page, project.path, connection);
|
||||
await chrome.storage.local.set({
|
||||
serverUrl: submitted.baseUrl,
|
||||
});
|
||||
await setBadge("✓", "#059669", `Saved to ${project.name || "LLM Wiki"}`, 4000);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[LLM Wiki Clipper] shortcut failed:", error);
|
||||
await setBadge("!", "#dc2626", `Clip failed: ${message}`, 7000);
|
||||
} finally {
|
||||
clipInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.commands.onCommand.addListener((command, tab) => {
|
||||
if (command === COMMAND_NAME) void clipCurrentPage(tab);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
(function initializeClipperCore(global) {
|
||||
const DEFAULT_API_URLS = ["http://127.0.0.1:19827", "http://localhost:19827"];
|
||||
const MAX_EXTRACTED_CONTENT_CHARS = 1_000_000;
|
||||
const TRUNCATION_NOTICE = "\n\n[LLM Wiki Clipper: page content truncated at 1,000,000 characters.]";
|
||||
|
||||
function limitExtractedContent(content) {
|
||||
const value = String(content || "");
|
||||
if (value.length <= MAX_EXTRACTED_CONTENT_CHARS) return value;
|
||||
return `${value.slice(0, MAX_EXTRACTED_CONTENT_CHARS)}${TRUNCATION_NOTICE}`;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(value) {
|
||||
let candidate = String(value || "").trim();
|
||||
if (!candidate) return DEFAULT_API_URLS[0];
|
||||
if (!/^https?:\/\//i.test(candidate)) candidate = `http://${candidate}`;
|
||||
const parsed = new URL(candidate);
|
||||
if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
|
||||
throw new Error("Use an http(s) address without embedded credentials");
|
||||
}
|
||||
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
throw new Error("Enter only the server origin, without a path, query, or fragment");
|
||||
}
|
||||
if (!parsed.port) parsed.port = "19827";
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const saved = await chrome.storage.local.get([
|
||||
"serverUrl",
|
||||
"accessToken",
|
||||
"preferredProjectPath",
|
||||
]);
|
||||
let serverUrl;
|
||||
try {
|
||||
serverUrl = normalizeServerUrl(saved.serverUrl || DEFAULT_API_URLS[0]);
|
||||
} catch {
|
||||
serverUrl = DEFAULT_API_URLS[0];
|
||||
}
|
||||
return {
|
||||
serverUrl,
|
||||
accessToken: String(saved.accessToken || ""),
|
||||
preferredProjectPath: String(saved.preferredProjectPath || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function requestHeaders(accessToken, options) {
|
||||
const headers = new Headers(options?.headers || {});
|
||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function clipFetch(path, options, connection) {
|
||||
const method = String(options?.method || "GET").toUpperCase();
|
||||
const serverUrl = normalizeServerUrl(connection?.serverUrl || DEFAULT_API_URLS[0]);
|
||||
// A POST is never retried because the first request may have reached the
|
||||
// Clip Server even when its response was lost.
|
||||
const isDefaultLocalAddress = DEFAULT_API_URLS.includes(serverUrl);
|
||||
const urls = method === "GET" && isDefaultLocalAddress
|
||||
? [serverUrl, ...DEFAULT_API_URLS.filter((url) => url !== serverUrl)]
|
||||
: [serverUrl];
|
||||
let lastError;
|
||||
|
||||
for (const baseUrl of urls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: requestHeaders(connection?.accessToken, options),
|
||||
});
|
||||
return { response, baseUrl };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("Unable to connect to LLM Wiki");
|
||||
}
|
||||
|
||||
// This function is serialized into the active tab by chrome.scripting, so it
|
||||
// must remain self-contained and must not capture extension-scope variables.
|
||||
function extractReadablePage() {
|
||||
try {
|
||||
const documentClone = document.cloneNode(true);
|
||||
const reader = new window.Readability(documentClone);
|
||||
const article = reader.parse();
|
||||
if (!article || !article.content) {
|
||||
return { error: "Readability could not extract content" };
|
||||
}
|
||||
|
||||
const turndown = new window.TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
});
|
||||
turndown.addRule("tableCell", {
|
||||
filter: ["th", "td"],
|
||||
replacement: (content) => ` ${content.trim()} |`,
|
||||
});
|
||||
turndown.addRule("tableRow", {
|
||||
filter: "tr",
|
||||
replacement: (content) => `|${content}\n`,
|
||||
});
|
||||
turndown.addRule("table", {
|
||||
filter: "table",
|
||||
replacement: (content) => {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length > 0) {
|
||||
const columns = (lines[0].match(/\|/g) || []).length - 1;
|
||||
lines.splice(1, 0, `|${" --- |".repeat(columns)}`);
|
||||
}
|
||||
return `\n\n${lines.join("\n")}\n\n`;
|
||||
},
|
||||
});
|
||||
turndown.addRule("removeSmallImages", {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== "IMG") return false;
|
||||
const width = parseInt(node.getAttribute("width") || "999");
|
||||
const height = parseInt(node.getAttribute("height") || "999");
|
||||
return width < 10 || height < 10;
|
||||
},
|
||||
replacement: () => "",
|
||||
});
|
||||
|
||||
return {
|
||||
title: article.title || document.title || "Untitled",
|
||||
content: turndown.turndown(article.content),
|
||||
excerpt: article.excerpt || "",
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function extractFallbackPage() {
|
||||
const clone = document.body?.cloneNode(true);
|
||||
if (!clone) return "";
|
||||
["script", "style", "nav", "header", "footer", ".sidebar", ".ad", ".comments"]
|
||||
.forEach((selector) => clone.querySelectorAll(selector).forEach((element) => element.remove()));
|
||||
return clone.innerText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function extractActiveTab(commandTab) {
|
||||
// Chrome passes the exact shortcut target to commands.onCommand together
|
||||
// with the temporary activeTab grant. Popup callers do not have that value
|
||||
// and intentionally resolve their own currently active tab instead.
|
||||
const tab = commandTab?.id
|
||||
? commandTab
|
||||
: (await chrome.tabs.query({ active: true, currentWindow: true }))[0];
|
||||
if (!tab?.id) throw new Error("No active browser tab");
|
||||
if (!/^https?:\/\//i.test(tab.url || "")) {
|
||||
throw new Error("This browser page cannot be clipped");
|
||||
}
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
files: ["Readability.js", "Turndown.js"],
|
||||
});
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: extractReadablePage,
|
||||
});
|
||||
const extracted = results?.[0]?.result;
|
||||
let content = extracted?.content || "";
|
||||
if (!content) {
|
||||
const fallback = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: extractFallbackPage,
|
||||
});
|
||||
content = fallback?.[0]?.result || "";
|
||||
}
|
||||
if (!content.trim()) throw new Error(extracted?.error || "Failed to extract page content");
|
||||
content = limitExtractedContent(content);
|
||||
|
||||
return {
|
||||
title: extracted?.title || tab.title || "Untitled",
|
||||
url: tab.url || "",
|
||||
content,
|
||||
excerpt: extracted?.excerpt || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProjects(connection) {
|
||||
const { response, baseUrl } = await clipFetch("/projects", { method: "GET" }, connection);
|
||||
if (response.status === 401) throw new Error("Access token required or invalid");
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || "Failed to load projects");
|
||||
return { projects: data.projects || [], baseUrl };
|
||||
}
|
||||
|
||||
function selectProject(projects, preferredProjectPath) {
|
||||
return projects.find((project) => project.path === preferredProjectPath)
|
||||
|| projects.find((project) => project.current)
|
||||
|| projects[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
async function submitClip(page, projectPath, connection) {
|
||||
const { response, baseUrl } = await clipFetch("/clip", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
content: page.content,
|
||||
projectPath,
|
||||
}),
|
||||
}, connection);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || `Clip failed: HTTP ${response.status}`);
|
||||
return { data, baseUrl };
|
||||
}
|
||||
|
||||
global.LLMWikiClipper = Object.freeze({
|
||||
DEFAULT_API_URLS,
|
||||
MAX_EXTRACTED_CONTENT_CHARS,
|
||||
normalizeServerUrl,
|
||||
loadSettings,
|
||||
clipFetch,
|
||||
extractActiveTab,
|
||||
loadProjects,
|
||||
selectProject,
|
||||
submitClip,
|
||||
});
|
||||
})(globalThis);
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "LLM Wiki Clipper",
|
||||
"version": "0.1.0",
|
||||
"description": "Clip web pages to your LLM Wiki knowledge base",
|
||||
"permissions": ["activeTab", "scripting", "storage"],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:19827/*",
|
||||
"http://localhost:19827/*"
|
||||
],
|
||||
"optional_host_permissions": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"commands": {
|
||||
"clip-current-page": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+Shift+L",
|
||||
"mac": "Command+Shift+L"
|
||||
},
|
||||
"description": "Clip the current page to LLM Wiki"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["Readability.js", "Turndown.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 480px;
|
||||
height: 500px;
|
||||
max-height: 500px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
background: #fafafa;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header h1 { font-size: 14px; font-weight: 600; }
|
||||
.header .icon { font-size: 18px; }
|
||||
.content { padding: 12px 16px; }
|
||||
.status {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.status.connected { background: #ecfdf5; color: #065f46; }
|
||||
.status.disconnected { background: #fef2f2; color: #991b1b; }
|
||||
.status.sending { background: #eff6ff; color: #1e40af; }
|
||||
.status.success { background: #ecfdf5; color: #065f46; }
|
||||
.status.error { background: #fef2f2; color: #991b1b; }
|
||||
.field { margin-bottom: 8px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
background: white;
|
||||
}
|
||||
.field input:focus, .field select:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99,102,241,0.1);
|
||||
}
|
||||
.field select { cursor: pointer; }
|
||||
.connection-settings {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
}
|
||||
.connection-settings summary {
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
.connection-settings-body { padding: 0 10px 9px; }
|
||||
.btn-secondary { margin-top: 2px; padding: 7px; background: #e5e7eb; color: #374151; }
|
||||
.btn-secondary:hover { background: #d1d5db; }
|
||||
.url-preview {
|
||||
padding: 4px 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#contentPreview {
|
||||
padding: 10px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
#contentPreview::-webkit-scrollbar { width: 8px; }
|
||||
#contentPreview::-webkit-scrollbar-track { background: #f3f4f6; border-radius: 0 6px 6px 0; }
|
||||
#contentPreview::-webkit-scrollbar-thumb { background: #c5c5c5; border-radius: 4px; }
|
||||
#contentPreview::-webkit-scrollbar-thumb:hover { background: #999; }
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.btn-primary { background: #4f46e5; color: white; }
|
||||
.btn-primary:hover { background: #4338ca; }
|
||||
.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
.footer {
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span class="icon">📚</span>
|
||||
<h1>LLM Wiki Clipper</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div id="statusBar" class="status disconnected">Checking connection...</div>
|
||||
|
||||
<details class="connection-settings" id="connectionSettings">
|
||||
<summary>Connection settings</summary>
|
||||
<div class="connection-settings-body">
|
||||
<div class="field">
|
||||
<label for="serverUrlInput">Server address</label>
|
||||
<input type="text" id="serverUrlInput" placeholder="http://192.168.1.50:19827">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="accessTokenInput">Access token</label>
|
||||
<input type="password" id="accessTokenInput" placeholder="Required for LAN access">
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="saveConnectionBtn" type="button">Save and reconnect</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="field">
|
||||
<label>Save to Project</label>
|
||||
<select id="projectSelect">
|
||||
<option value="">Loading projects...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input type="text" id="titleInput" placeholder="Page title">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>URL</label>
|
||||
<div class="url-preview" id="urlPreview">—</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Content Preview</label>
|
||||
<div id="contentPreview">Extracting content...</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="clipBtn" disabled>
|
||||
📎 Clip to Wiki
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>Content will be saved and auto-ingested into your wiki</div>
|
||||
<div id="shortcutHint" style="margin-top: 2px; color: #6b7280;">Loading shortcut...</div>
|
||||
</div>
|
||||
|
||||
<script src="clipper-core.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
const clipperCore = globalThis.LLMWikiClipper;
|
||||
|
||||
const statusBar = document.getElementById("statusBar");
|
||||
const titleInput = document.getElementById("titleInput");
|
||||
const urlPreview = document.getElementById("urlPreview");
|
||||
const contentPreview = document.getElementById("contentPreview");
|
||||
const clipBtn = document.getElementById("clipBtn");
|
||||
const projectSelect = document.getElementById("projectSelect");
|
||||
const serverUrlInput = document.getElementById("serverUrlInput");
|
||||
const accessTokenInput = document.getElementById("accessTokenInput");
|
||||
const saveConnectionBtn = document.getElementById("saveConnectionBtn");
|
||||
const connectionSettings = document.getElementById("connectionSettings");
|
||||
const shortcutHint = document.getElementById("shortcutHint");
|
||||
|
||||
let extractedContent = "";
|
||||
let pageUrl = "";
|
||||
let apiUrl = clipperCore.DEFAULT_API_URLS[0];
|
||||
let accessToken = "";
|
||||
|
||||
async function loadConnectionSettings() {
|
||||
const saved = await clipperCore.loadSettings();
|
||||
apiUrl = saved.serverUrl;
|
||||
accessToken = saved.accessToken;
|
||||
serverUrlInput.value = apiUrl;
|
||||
accessTokenInput.value = accessToken;
|
||||
}
|
||||
|
||||
async function clipFetch(path, options) {
|
||||
const result = await clipperCore.clipFetch(path, options, {
|
||||
serverUrl: apiUrl,
|
||||
accessToken,
|
||||
});
|
||||
apiUrl = result.baseUrl;
|
||||
return result.response;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
let connectionError = "";
|
||||
try {
|
||||
const res = await clipFetch("/status", { method: "GET" });
|
||||
const data = await res.json();
|
||||
if (res.status === 401) throw new Error("Access token required or invalid");
|
||||
if (data.ok) {
|
||||
statusBar.className = "status connected";
|
||||
statusBar.textContent = "✓ Connected to LLM Wiki";
|
||||
await loadProjects();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
connectionError = err?.message || "";
|
||||
}
|
||||
statusBar.className = "status disconnected";
|
||||
statusBar.textContent = connectionError.includes("token")
|
||||
? "✗ Access token required or invalid"
|
||||
: "✗ Cannot connect to LLM Wiki"
|
||||
statusBar.title = connectionError;
|
||||
clipBtn.disabled = true;
|
||||
projectSelect.innerHTML = '<option value="">App not running</option>';
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await clipFetch("/projects", { method: "GET" });
|
||||
const data = await res.json();
|
||||
if (data.ok && data.projects?.length > 0) {
|
||||
const { preferredProjectPath } = await clipperCore.loadSettings();
|
||||
projectSelect.innerHTML = "";
|
||||
for (const proj of data.projects) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = proj.path;
|
||||
opt.textContent = proj.name + (proj.current ? " (current)" : "");
|
||||
if (proj.path === preferredProjectPath || (!preferredProjectPath && proj.current)) {
|
||||
opt.selected = true;
|
||||
}
|
||||
projectSelect.appendChild(opt);
|
||||
}
|
||||
if (!projectSelect.value && data.projects[0]) {
|
||||
projectSelect.value = data.projects[0].path;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
// Fallback to current project
|
||||
try {
|
||||
const res = await clipFetch("/project", { method: "GET" });
|
||||
const data = await res.json();
|
||||
if (data.ok && data.path) {
|
||||
const name = data.path.replace(/\\/g, "/").split("/").pop() || data.path;
|
||||
projectSelect.innerHTML = `<option value="${data.path}">${name}</option>`;
|
||||
}
|
||||
} catch {
|
||||
projectSelect.innerHTML = '<option value="">No projects</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function extractContent() {
|
||||
try {
|
||||
const page = await clipperCore.extractActiveTab();
|
||||
pageUrl = page.url;
|
||||
titleInput.value = page.title;
|
||||
urlPreview.textContent = pageUrl;
|
||||
extractedContent = page.content;
|
||||
contentPreview.textContent = page.excerpt
|
||||
? `📝 ${page.excerpt}\n\n---\n\n${extractedContent}`
|
||||
: extractedContent;
|
||||
clipBtn.disabled = false;
|
||||
} catch (err) {
|
||||
contentPreview.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendClip() {
|
||||
const selectedProject = projectSelect.value;
|
||||
if (!selectedProject) {
|
||||
statusBar.className = "status error";
|
||||
statusBar.textContent = "✗ Please select a project";
|
||||
return;
|
||||
}
|
||||
|
||||
clipBtn.disabled = true;
|
||||
statusBar.className = "status sending";
|
||||
statusBar.textContent = "⏳ Sending to LLM Wiki...";
|
||||
|
||||
try {
|
||||
const result = await clipperCore.submitClip({
|
||||
title: titleInput.value,
|
||||
url: pageUrl,
|
||||
content: extractedContent,
|
||||
}, selectedProject, {
|
||||
serverUrl: apiUrl,
|
||||
accessToken,
|
||||
});
|
||||
apiUrl = result.baseUrl;
|
||||
await chrome.storage.local.set({
|
||||
serverUrl: apiUrl,
|
||||
preferredProjectPath: selectedProject,
|
||||
});
|
||||
const projectName = projectSelect.options[projectSelect.selectedIndex]?.textContent || "project";
|
||||
statusBar.className = "status success";
|
||||
statusBar.textContent = `✓ Saved to ${projectName}`;
|
||||
clipBtn.textContent = "✓ Clipped!";
|
||||
} catch (err) {
|
||||
statusBar.className = "status error";
|
||||
statusBar.textContent = `✗ Connection failed: ${err.message}`;
|
||||
clipBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
clipBtn.addEventListener("click", sendClip);
|
||||
|
||||
projectSelect.addEventListener("change", () => {
|
||||
if (projectSelect.value) {
|
||||
void chrome.storage.local.set({ preferredProjectPath: projectSelect.value });
|
||||
}
|
||||
});
|
||||
|
||||
saveConnectionBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const nextUrl = clipperCore.normalizeServerUrl(serverUrlInput.value);
|
||||
const originPattern = `${new URL(nextUrl).origin}/*`;
|
||||
const granted = await chrome.permissions.request({ origins: [originPattern] });
|
||||
if (!granted) throw new Error("Host permission was not granted");
|
||||
apiUrl = nextUrl;
|
||||
accessToken = accessTokenInput.value.trim();
|
||||
await chrome.storage.local.set({ serverUrl: apiUrl, accessToken });
|
||||
connectionSettings.open = false;
|
||||
clipBtn.disabled = true;
|
||||
await checkConnection();
|
||||
} catch (err) {
|
||||
connectionSettings.open = true;
|
||||
statusBar.className = "status error";
|
||||
statusBar.textContent = `✗ ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Resize content preview to fill available space without causing popup scroll
|
||||
function resizePreview() {
|
||||
const totalHeight = 500; // matches html/body height
|
||||
const preview = document.getElementById("contentPreview");
|
||||
if (!preview) return;
|
||||
|
||||
// Calculate space used by everything except the preview
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const bottomSpace = totalHeight - previewRect.top - 60; // 60px for button + footer
|
||||
const maxH = Math.max(100, Math.min(300, bottomSpace));
|
||||
preview.style.maxHeight = maxH + "px";
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const commands = await chrome.commands.getAll();
|
||||
const clipCommand = commands.find((command) => command.name === "clip-current-page");
|
||||
shortcutHint.textContent = clipCommand?.shortcut
|
||||
? `Shortcut: ${clipCommand.shortcut}`
|
||||
: "Set a shortcut at chrome://extensions/shortcuts";
|
||||
await loadConnectionSettings();
|
||||
const connected = await checkConnection();
|
||||
// Always extract content so user can preview, even if app not running
|
||||
await extractContent();
|
||||
if (!connected) {
|
||||
clipBtn.disabled = true;
|
||||
clipBtn.textContent = "📎 App not running — cannot save";
|
||||
}
|
||||
setTimeout(resizePreview, 100);
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LLM Wiki</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
# LLM Wiki
|
||||
|
||||
A pattern for building personal knowledge bases using LLMs.
|
||||
|
||||
This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.
|
||||
|
||||
## The core idea
|
||||
|
||||
Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.
|
||||
|
||||
The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.
|
||||
|
||||
This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.
|
||||
|
||||
You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.
|
||||
|
||||
This can apply to a lot of different contexts. A few examples:
|
||||
|
||||
- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
|
||||
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
|
||||
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
|
||||
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
|
||||
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.
|
||||
|
||||
## Architecture
|
||||
|
||||
There are three layers:
|
||||
|
||||
**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.
|
||||
|
||||
**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.
|
||||
|
||||
**The schema** — a document (e.g. CLAUDE.md for Claude Code or AGENTS.md for Codex) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.
|
||||
|
||||
## Operations
|
||||
|
||||
**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.
|
||||
|
||||
**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.
|
||||
|
||||
**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.
|
||||
|
||||
## Indexing and logging
|
||||
|
||||
Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:
|
||||
|
||||
**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.
|
||||
|
||||
**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.
|
||||
|
||||
## Optional: CLI tools
|
||||
|
||||
At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
|
||||
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
|
||||
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
|
||||
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
|
||||
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
|
||||
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.
|
||||
|
||||
## Why this works
|
||||
|
||||
The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.
|
||||
|
||||
The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.
|
||||
|
||||
The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.
|
||||
|
||||
|
||||
## Note
|
||||
|
||||
This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,77 @@
|
||||
# LLM Wiki MCP Server
|
||||
|
||||
This package exposes the running LLM Wiki desktop app as a Model Context Protocol server.
|
||||
|
||||
It does **not** scan project folders directly and does **not** copy the app's search or graph logic. Every tool calls the local desktop API at `http://127.0.0.1:19828/api/v1`, so MCP clients use the same project registry, file permissions, search backend, graph backend, and Source Watch rules as the app.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- LLM Wiki desktop app running
|
||||
- Settings → API + MCP → "Enable local HTTP API"
|
||||
- Settings → API + MCP → "Enable MCP access"
|
||||
- Either:
|
||||
- Settings → API + MCP → "Allow access without a token", or
|
||||
- `LLM_WIKI_API_TOKEN` set to the configured API token
|
||||
|
||||
Optional:
|
||||
|
||||
- `LLM_WIKI_API_BASE_URL` to override the default API base URL.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
LLM_WIKI_API_TOKEN=your-token node dist/src/index.js
|
||||
```
|
||||
|
||||
Example MCP client config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"llm-wiki": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/llm_wiki/mcp-server/dist/src/index.js"],
|
||||
"env": {
|
||||
"LLM_WIKI_API_TOKEN": "your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When API unauthenticated mode is enabled, omit `LLM_WIKI_API_TOKEN`. If MCP access is disabled in Settings, `llm_wiki_status` still works for diagnosis but other tools return an explicit disabled error.
|
||||
|
||||
## Tools
|
||||
|
||||
- `llm_wiki_status`: health and current project summary.
|
||||
- `llm_wiki_projects`: known projects and active project.
|
||||
- `llm_wiki_set_project`: pin the MCP process session to a project. Once pinned, other project tools reject attempts to access a different project.
|
||||
- `llm_wiki_files`: list project files. `project_id` can be a project UUID, a project filesystem path, or `current`.
|
||||
- `llm_wiki_read_file`: read an allowed text file such as `wiki/index.md`.
|
||||
- `llm_wiki_reviews`: list Review tab items. Defaults to unresolved items and supports `status`, `type`, and `limit` filters.
|
||||
- `llm_wiki_search`: search with the app's shared keyword/vector backend.
|
||||
- `llm_wiki_chat`: ask the backend Agent chat endpoint and receive answer text, references, usage, and tool events. `mode: deep` broadens backend evidence collection; full Deep Research workflows still live in the desktop app.
|
||||
- `llm_wiki_graph`: query the app's knowledge graph endpoint.
|
||||
- `llm_wiki_rescan_sources`: trigger a Source Watch rescan using the user's configured rules.
|
||||
|
||||
## Security model
|
||||
|
||||
The MCP server inherits the desktop API's security model:
|
||||
|
||||
- It only talks to `127.0.0.1` by default.
|
||||
- It uses the same API token or unauthenticated setting as Settings → API + MCP.
|
||||
- File reads go through the API path allow-list. Internal app state files are not exposed.
|
||||
- Review data is exposed only through the dedicated Review endpoint/tool, which defaults to unresolved items rather than opening internal state files directly.
|
||||
- Search and graph tools operate on projects known to the app; use `project_id: "current"` for the active project.
|
||||
- For multi-project use, call `llm_wiki_set_project` once. The resolved project ID remains fixed for the lifetime of the MCP subprocess even if the desktop UI switches projects, and every project-tool response includes an `activeProject` marker.
|
||||
|
||||
Do not pass API tokens via command-line arguments. Prefer environment variables so they do not appear in shell history.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "llm-wiki-mcp-server",
|
||||
"version": "0.4.25",
|
||||
"description": "MCP server for LLM Wiki local API",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
"llm-wiki-mcp": "dist/src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/src/index.js",
|
||||
"test": "npm run build && node --test dist/test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"llm-wiki",
|
||||
"knowledge-base"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
export const DEFAULT_API_BASE_URL = "http://127.0.0.1:19828"
|
||||
|
||||
export interface LlmWikiApiClientOptions {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export interface ApiProject {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export interface ApiFileNode {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
children?: ApiFileNode[]
|
||||
}
|
||||
|
||||
export interface ApiSearchResult {
|
||||
path: string
|
||||
title: string
|
||||
snippet: string
|
||||
score: number
|
||||
titleMatch?: boolean
|
||||
images?: Array<{ url: string; alt: string }>
|
||||
vectorScore?: number | null
|
||||
}
|
||||
|
||||
export interface ApiSearchResponse {
|
||||
results: ApiSearchResult[]
|
||||
mode?: string
|
||||
tokenHits?: number
|
||||
vectorHits?: number
|
||||
}
|
||||
|
||||
export interface ApiChatReference {
|
||||
title: string
|
||||
path: string
|
||||
kind: string
|
||||
snippet?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
export interface ApiChatToolEvent {
|
||||
tool: string
|
||||
status: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ApiChatEvent {
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ApiChatUsage {
|
||||
promptChars?: number
|
||||
completionChars?: number
|
||||
referenceCount?: number
|
||||
toolEventCount?: number
|
||||
}
|
||||
|
||||
export interface ApiChatResponse {
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
mode?: string
|
||||
message: {
|
||||
role: string
|
||||
content: string
|
||||
}
|
||||
references: ApiChatReference[]
|
||||
toolEvents: ApiChatToolEvent[]
|
||||
events: ApiChatEvent[]
|
||||
usage?: ApiChatUsage
|
||||
}
|
||||
|
||||
export interface ApiGraphNode {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
path?: string
|
||||
linkCount?: number
|
||||
weight?: number
|
||||
}
|
||||
|
||||
export interface ApiGraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
weight?: number
|
||||
}
|
||||
|
||||
export type ApiReviewStatus = "unresolved" | "resolved" | "all"
|
||||
|
||||
export interface ApiReviewOption {
|
||||
label: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface ApiReviewItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
sourcePath?: string
|
||||
affectedPages?: string[]
|
||||
searchQueries?: string[]
|
||||
options: ApiReviewOption[]
|
||||
resolved: boolean
|
||||
resolvedAction?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface ApiReviewsResponse {
|
||||
projectId?: string
|
||||
status: ApiReviewStatus
|
||||
count: number
|
||||
reviews: ApiReviewItem[]
|
||||
}
|
||||
|
||||
export interface ApiFilesResponse {
|
||||
files: ApiFileNode[]
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface ApiHealth {
|
||||
ok?: boolean
|
||||
status?: string
|
||||
enabled?: boolean
|
||||
mcpEnabled?: boolean
|
||||
authRequired?: boolean
|
||||
authConfigured?: boolean
|
||||
allowUnauthenticated?: boolean
|
||||
tokenSource?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value?: string): string {
|
||||
const raw = (value ?? DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL
|
||||
return raw.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function apiPath(path: string): string {
|
||||
return path.startsWith("/api/v1") ? path : `/api/v1${path.startsWith("/") ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, context: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${context}: expected JSON object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
export class LlmWikiApiClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly token?: string
|
||||
private readonly fetchImpl: typeof fetch
|
||||
|
||||
constructor(options: LlmWikiApiClientOptions = {}) {
|
||||
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.LLM_WIKI_API_BASE_URL)
|
||||
this.token = options.token ?? process.env.LLM_WIKI_API_TOKEN
|
||||
this.fetchImpl = options.fetchImpl ?? fetch
|
||||
}
|
||||
|
||||
async health(): Promise<ApiHealth> {
|
||||
return this.request("/health", { auth: false }) as Promise<ApiHealth>
|
||||
}
|
||||
|
||||
async projects(): Promise<{ projects: ApiProject[]; currentProject: ApiProject | null }> {
|
||||
const json = await this.request("/projects")
|
||||
const projects = Array.isArray(json.projects) ? json.projects.map(parseProject) : []
|
||||
const currentProject = json.currentProject ? parseProject(json.currentProject) : null
|
||||
return { projects, currentProject }
|
||||
}
|
||||
|
||||
async files(projectId = "current", options: { root?: "wiki" | "sources" | "all"; recursive?: boolean; maxFiles?: number } = {}): Promise<ApiFilesResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set("root", options.root ?? "wiki")
|
||||
if (options.recursive !== undefined) params.set("recursive", String(options.recursive))
|
||||
if (options.maxFiles !== undefined) params.set("maxFiles", String(options.maxFiles))
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/files?${params.toString()}`)
|
||||
return {
|
||||
files: Array.isArray(json.files) ? json.files.map(parseFileNode) : [],
|
||||
truncated: json.truncated === true,
|
||||
}
|
||||
}
|
||||
|
||||
async fileContent(projectId = "current", path: string): Promise<{ path: string; content: string }> {
|
||||
const params = new URLSearchParams({ path })
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/files/content?${params.toString()}`)
|
||||
return {
|
||||
path: typeof json.path === "string" ? json.path : path,
|
||||
content: typeof json.content === "string" ? json.content : "",
|
||||
}
|
||||
}
|
||||
|
||||
async reviews(projectId = "current", options: { status?: ApiReviewStatus; type?: string; limit?: number } = {}): Promise<ApiReviewsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set("status", options.status)
|
||||
if (options.type) params.set("type", options.type)
|
||||
if (options.limit !== undefined) params.set("limit", String(options.limit))
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/reviews${suffix}`)
|
||||
const reviews = Array.isArray(json.reviews) ? json.reviews.map(parseReviewItem) : []
|
||||
return {
|
||||
projectId: typeof json.projectId === "string" ? json.projectId : undefined,
|
||||
status: parseReviewStatus(json.status),
|
||||
count: numberOrUndefined(json.count) ?? reviews.length,
|
||||
reviews,
|
||||
}
|
||||
}
|
||||
|
||||
async search(projectId = "current", query: string, options: { topK?: number; includeContent?: boolean } = {}): Promise<ApiSearchResponse> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/search`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
query,
|
||||
topK: options.topK,
|
||||
includeContent: options.includeContent,
|
||||
},
|
||||
})
|
||||
return {
|
||||
results: Array.isArray(json.results) ? json.results.map(parseSearchResult) : [],
|
||||
mode: typeof json.mode === "string" ? json.mode : undefined,
|
||||
tokenHits: numberOrUndefined(json.tokenHits),
|
||||
vectorHits: numberOrUndefined(json.vectorHits),
|
||||
}
|
||||
}
|
||||
|
||||
async chat(projectId = "current", message: string, options: { sessionId?: string; mode?: string; topK?: number; includeContent?: boolean; wiki?: boolean; web?: boolean; anytxt?: boolean; skills?: string[]; persistSession?: boolean } = {}): Promise<ApiChatResponse> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/chat`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
message,
|
||||
sessionId: options.sessionId,
|
||||
persistSession: options.persistSession,
|
||||
mode: options.mode,
|
||||
topK: options.topK,
|
||||
includeContent: options.includeContent,
|
||||
tools: {
|
||||
wiki: options.wiki ?? true,
|
||||
web: options.web ?? false,
|
||||
anytxt: options.anytxt ?? false,
|
||||
},
|
||||
skills: options.skills,
|
||||
},
|
||||
})
|
||||
const msg = requireObject(json.message, "chat message")
|
||||
return {
|
||||
projectId: typeof json.projectId === "string" ? json.projectId : undefined,
|
||||
sessionId: typeof json.sessionId === "string" ? json.sessionId : "",
|
||||
mode: typeof json.mode === "string" ? json.mode : undefined,
|
||||
message: {
|
||||
role: typeof msg.role === "string" ? msg.role : "assistant",
|
||||
content: typeof msg.content === "string" ? msg.content : "",
|
||||
},
|
||||
references: Array.isArray(json.references) ? json.references.map(parseChatReference) : [],
|
||||
toolEvents: Array.isArray(json.toolEvents) ? json.toolEvents.map(parseChatToolEvent) : [],
|
||||
events: Array.isArray(json.events) ? json.events.map(parseChatEvent) : [],
|
||||
usage: parseChatUsage(json.usage),
|
||||
}
|
||||
}
|
||||
|
||||
async cancelChat(projectId = "current", sessionId: string): Promise<{ sessionId: string; cancelled: boolean }> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/chat/${encodeURIComponent(sessionId)}/cancel`, {
|
||||
method: "POST",
|
||||
})
|
||||
return {
|
||||
sessionId: typeof json.sessionId === "string" ? json.sessionId : sessionId,
|
||||
cancelled: json.cancelled === true,
|
||||
}
|
||||
}
|
||||
|
||||
async graph(projectId = "current", options: { q?: string; nodeType?: string; limit?: number } = {}): Promise<{ nodes: ApiGraphNode[]; edges: ApiGraphEdge[] }> {
|
||||
const params = new URLSearchParams()
|
||||
if (options.q) params.set("q", options.q)
|
||||
if (options.nodeType) params.set("nodeType", options.nodeType)
|
||||
if (options.limit !== undefined) params.set("limit", String(options.limit))
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/graph${suffix}`)
|
||||
return {
|
||||
nodes: Array.isArray(json.nodes) ? json.nodes.map(parseGraphNode) : [],
|
||||
edges: Array.isArray(json.edges) ? json.edges.map(parseGraphEdge) : [],
|
||||
}
|
||||
}
|
||||
|
||||
async rescan(projectId = "current"): Promise<Record<string, unknown>> {
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/sources/rescan`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
private async request(path: string, options: { method?: "GET" | "POST"; body?: unknown; auth?: boolean } = {}): Promise<Record<string, unknown>> {
|
||||
const url = `${this.baseUrl}${apiPath(path)}`
|
||||
const headers: Record<string, string> = { Accept: "application/json" }
|
||||
if (options.auth !== false && this.token?.trim()) {
|
||||
headers.Authorization = `Bearer ${this.token.trim()}`
|
||||
}
|
||||
if (options.body !== undefined) headers["Content-Type"] = "application/json"
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: options.method ?? (options.body === undefined ? "GET" : "POST"),
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(`LLM Wiki API request failed. Is the desktop app running? ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let json: Record<string, unknown>
|
||||
try {
|
||||
json = text ? requireObject(JSON.parse(text), "LLM Wiki API response") : {}
|
||||
} catch (err) {
|
||||
throw new Error(`LLM Wiki API returned non-JSON response (${response.status}): ${text.slice(0, 300)}${err instanceof Error ? ` (${err.message})` : ""}`)
|
||||
}
|
||||
|
||||
if (!response.ok || json.ok === false) {
|
||||
const message = typeof json.error === "string" ? json.error : response.statusText
|
||||
throw new Error(`LLM Wiki API ${response.status}: ${message}`)
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): ApiProject {
|
||||
const obj = requireObject(value, "project")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
name: String(obj.name ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
current: obj.current === true,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFileNode(value: unknown): ApiFileNode {
|
||||
const obj = requireObject(value, "file node")
|
||||
const children = Array.isArray(obj.children) ? obj.children.map(parseFileNode) : undefined
|
||||
return {
|
||||
name: String(obj.name ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
isDir: obj.isDir === true || obj.is_dir === true,
|
||||
...(children ? { children } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchResult(value: unknown): ApiSearchResult {
|
||||
const obj = requireObject(value, "search result")
|
||||
return {
|
||||
path: String(obj.path ?? ""),
|
||||
title: String(obj.title ?? ""),
|
||||
snippet: String(obj.snippet ?? ""),
|
||||
score: numberOrUndefined(obj.score) ?? 0,
|
||||
titleMatch: obj.titleMatch === true,
|
||||
images: Array.isArray(obj.images) ? obj.images.map((image) => {
|
||||
const item = requireObject(image, "image")
|
||||
return { url: String(item.url ?? ""), alt: String(item.alt ?? "") }
|
||||
}) : [],
|
||||
vectorScore: numberOrUndefined(obj.vectorScore) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatReference(value: unknown): ApiChatReference {
|
||||
const obj = requireObject(value, "chat reference")
|
||||
return {
|
||||
title: String(obj.title ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
kind: String(obj.kind ?? "wiki"),
|
||||
snippet: typeof obj.snippet === "string" ? obj.snippet : undefined,
|
||||
score: numberOrUndefined(obj.score),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatToolEvent(value: unknown): ApiChatToolEvent {
|
||||
const obj = requireObject(value, "chat tool event")
|
||||
return {
|
||||
tool: String(obj.tool ?? ""),
|
||||
status: String(obj.status ?? ""),
|
||||
detail: typeof obj.detail === "string" ? obj.detail : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatEvent(value: unknown): ApiChatEvent {
|
||||
const obj = requireObject(value, "chat event")
|
||||
return {
|
||||
...obj,
|
||||
type: String(obj.type ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatUsage(value: unknown): ApiChatUsage | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
const obj = requireObject(value, "chat usage")
|
||||
return {
|
||||
promptChars: numberOrUndefined(obj.promptChars),
|
||||
completionChars: numberOrUndefined(obj.completionChars),
|
||||
referenceCount: numberOrUndefined(obj.referenceCount),
|
||||
toolEventCount: numberOrUndefined(obj.toolEventCount),
|
||||
}
|
||||
}
|
||||
|
||||
function parseReviewStatus(value: unknown): ApiReviewStatus {
|
||||
return value === "resolved" || value === "all" ? value : "unresolved"
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.map((item) => String(item))
|
||||
}
|
||||
|
||||
function parseReviewItem(value: unknown): ApiReviewItem {
|
||||
const obj = requireObject(value, "review item")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
type: String(obj.type ?? ""),
|
||||
title: String(obj.title ?? ""),
|
||||
description: String(obj.description ?? ""),
|
||||
sourcePath: typeof obj.sourcePath === "string" ? obj.sourcePath : undefined,
|
||||
affectedPages: stringArray(obj.affectedPages),
|
||||
searchQueries: stringArray(obj.searchQueries),
|
||||
options: Array.isArray(obj.options) ? obj.options.map((option) => {
|
||||
const item = requireObject(option, "review option")
|
||||
return { label: String(item.label ?? ""), action: String(item.action ?? "") }
|
||||
}) : [],
|
||||
resolved: obj.resolved === true,
|
||||
resolvedAction: typeof obj.resolvedAction === "string" ? obj.resolvedAction : undefined,
|
||||
createdAt: numberOrUndefined(obj.createdAt) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function parseGraphNode(value: unknown): ApiGraphNode {
|
||||
const obj = requireObject(value, "graph node")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
label: String(obj.label ?? ""),
|
||||
type: String(obj.nodeType ?? obj.type ?? "other"),
|
||||
path: typeof obj.path === "string" ? obj.path : undefined,
|
||||
linkCount: numberOrUndefined(obj.linkCount),
|
||||
weight: numberOrUndefined(obj.weight),
|
||||
}
|
||||
}
|
||||
|
||||
function parseGraphEdge(value: unknown): ApiGraphEdge {
|
||||
const obj = requireObject(value, "graph edge")
|
||||
return {
|
||||
source: String(obj.source ?? ""),
|
||||
target: String(obj.target ?? ""),
|
||||
weight: numberOrUndefined(obj.weight),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env node
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import {
|
||||
LlmWikiApiClient,
|
||||
type ApiFileNode,
|
||||
type ApiGraphNode,
|
||||
type ApiReviewItem,
|
||||
type ApiReviewsResponse,
|
||||
type ApiChatResponse,
|
||||
type ApiSearchResult,
|
||||
type ApiProject,
|
||||
} from "./api-client.js"
|
||||
import { VERSION } from "./version.js"
|
||||
import { McpProjectBinding, withActiveProject } from "./project-binding.js"
|
||||
|
||||
const DEFAULT_PROJECT_ID = "current"
|
||||
const MAX_TEXT_BYTES = 120_000
|
||||
|
||||
const client = new LlmWikiApiClient()
|
||||
const projectBinding = new McpProjectBinding()
|
||||
|
||||
const server = new Server(
|
||||
{ name: "llm-wiki", version: VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: "llm_wiki_status",
|
||||
description: "Check whether the LLM Wiki desktop local API is reachable and list the current project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_projects",
|
||||
description: "List known LLM Wiki projects. The response includes currentProject when the desktop app has an active project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_set_project",
|
||||
description: "Pin this MCP process session to one LLM Wiki project. Once pinned, project tools cannot access another project until this tool changes the binding.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, exact filesystem path, or 'current'." },
|
||||
},
|
||||
required: ["project_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_files",
|
||||
description: "List files from a project using the desktop app's API permissions. project_id may be a UUID, filesystem path, or 'current'.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
root: { type: "string", enum: ["wiki", "sources", "all"], description: "Tree root to list. Defaults to wiki." },
|
||||
recursive: { type: "boolean", description: "Whether to list recursively. Defaults to true." },
|
||||
max_files: { type: "number", description: "Maximum files returned by the local API. Max 10000." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_read_file",
|
||||
description: "Read a text file from a project through the desktop app API. Only public project paths such as wiki/ and raw/sources/ are allowed by the API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
path: { type: "string", description: "Project-relative file path, for example wiki/index.md." },
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_reviews",
|
||||
description: "List Review tab items from a project. Defaults to unresolved items so agent clients can help manage pending wiki review work.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
status: { type: "string", enum: ["unresolved", "resolved", "all"], description: "Review status filter. Defaults to unresolved." },
|
||||
type: { type: "string", description: "Optional Review item type filter, for example missing-page, duplicate, contradiction, confirm, or suggestion." },
|
||||
limit: { type: "number", description: "Maximum review items returned. The local API clamps to its configured maximum." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_search",
|
||||
description: "Search a project using the same backend keyword/vector retrieval used by the desktop API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
query: { type: "string", description: "Search query." },
|
||||
top_k: { type: "number", description: "Maximum results. The local API clamps to its configured maximum." },
|
||||
include_content: { type: "boolean", description: "Include full page content in results when supported by the API." },
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_chat",
|
||||
description: "Ask the LLM Wiki backend Agent a question about a project. This initial backend Agent uses the desktop API's shared retrieval service and returns references.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
message: { type: "string", description: "User message or question." },
|
||||
session_id: { type: "string", description: "Optional caller-managed session id." },
|
||||
mode: { type: "string", enum: ["fast", "standard", "deep", "local_first"], description: "Agent mode. Defaults to standard." },
|
||||
top_k: { type: "number", description: "Maximum wiki references to retrieve. The API clamps to its configured maximum." },
|
||||
include_content: { type: "boolean", description: "Include full page content in retrieval when supported by the API. Defaults to false." },
|
||||
wiki: { type: "boolean", description: "Enable wiki retrieval. Defaults to true." },
|
||||
web: { type: "boolean", description: "Enable backend web.search when the Agent router decides external search is useful. Defaults to false." },
|
||||
anytxt: { type: "boolean", description: "Enable backend anytxt.search for source/local-file questions when AnyTXT is configured. Defaults to false." },
|
||||
skills: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional project skills to inject from .llm-wiki/skills.",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_graph",
|
||||
description: "Query the project knowledge graph through the desktop app API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
q: { type: "string", description: "Optional text filter." },
|
||||
node_type: { type: "string", description: "Optional node type filter." },
|
||||
limit: { type: "number", description: "Maximum nodes. The local API clamps to its configured maximum." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_rescan_sources",
|
||||
description: "Trigger the desktop app's source folder rescan for a project, using the user's Source Watch rules.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const args = asObject(request.params.arguments ?? {})
|
||||
try {
|
||||
switch (request.params.name) {
|
||||
case "llm_wiki_status": {
|
||||
const [health, projects] = await Promise.all([
|
||||
client.health(),
|
||||
client.projects().catch(() => ({ projects: [], currentProject: null })),
|
||||
])
|
||||
return textResult(JSON.stringify({ ...health, ...projects, sessionProject: projectBinding.project }, null, 2))
|
||||
}
|
||||
case "llm_wiki_projects": {
|
||||
await assertMcpEnabled()
|
||||
return textResult(JSON.stringify({ ...(await client.projects()), sessionProject: projectBinding.project }, null, 2))
|
||||
}
|
||||
case "llm_wiki_set_project": {
|
||||
await assertMcpEnabled()
|
||||
const requested = stringArg(args.project_id, "project_id")
|
||||
const projects = await client.projects()
|
||||
let pinned: ApiProject
|
||||
try {
|
||||
pinned = projectBinding.pin(requested, projects.projects, projects.currentProject)
|
||||
} catch (error) {
|
||||
throw new McpError(ErrorCode.InvalidParams, scopedErrorMessage(error))
|
||||
}
|
||||
return textResult(JSON.stringify({ activeProject: pinned, pinned: true }, null, 2))
|
||||
}
|
||||
case "llm_wiki_files": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const response = await client.files(scope.id, {
|
||||
root: enumArg(args.root, ["wiki", "sources", "all"] as const, "wiki"),
|
||||
recursive: boolArg(args.recursive, true),
|
||||
maxFiles: numberArg(args.max_files),
|
||||
})
|
||||
return textResult(withActiveProject(formatFileTree(response.files, response.truncated), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_read_file": {
|
||||
await assertMcpEnabled()
|
||||
const relPath = stringArg(args.path, "path")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const { path, content } = await client.fileContent(scope.id, relPath)
|
||||
return textResult(withActiveProject(`# ${path}\n\n${truncateText(content, MAX_TEXT_BYTES)}`, scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_reviews": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const reviews = await client.reviews(scope.id, {
|
||||
status: enumArg(args.status, ["unresolved", "resolved", "all"] as const, "unresolved"),
|
||||
type: optionalStringArg(args.type),
|
||||
limit: numberArg(args.limit),
|
||||
})
|
||||
return textResult(withActiveProject(formatReviews(reviews), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_search": {
|
||||
await assertMcpEnabled()
|
||||
const query = stringArg(args.query, "query")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const search = await client.search(scope.id, query, {
|
||||
topK: numberArg(args.top_k),
|
||||
includeContent: boolArg(args.include_content, false),
|
||||
})
|
||||
return textResult(withActiveProject(formatSearchResults(query, search), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_chat": {
|
||||
await assertMcpEnabled()
|
||||
const message = stringArg(args.message, "message")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const chat = await client.chat(scope.id, message, {
|
||||
sessionId: optionalStringArg(args.session_id),
|
||||
mode: enumArg(args.mode, ["fast", "standard", "deep", "local_first"] as const, "standard"),
|
||||
topK: numberArg(args.top_k),
|
||||
includeContent: boolArg(args.include_content, false),
|
||||
wiki: boolArg(args.wiki, true),
|
||||
web: boolArg(args.web, false),
|
||||
anytxt: boolArg(args.anytxt, false),
|
||||
skills: stringArrayArg(args.skills),
|
||||
persistSession: optionalStringArg(args.session_id) !== undefined,
|
||||
})
|
||||
return textResult(withActiveProject(formatChatResponse(chat), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_graph": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const graph = await client.graph(scope.id, {
|
||||
q: optionalStringArg(args.q),
|
||||
nodeType: optionalStringArg(args.node_type),
|
||||
limit: numberArg(args.limit),
|
||||
})
|
||||
return textResult(withActiveProject(formatGraph(graph.nodes, graph.edges), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_rescan_sources": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
return textResult(withActiveProject(JSON.stringify(await client.rescan(scope.id), null, 2), scope.project, scope.id))
|
||||
}
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) {
|
||||
throw new McpError(err.code, scopedErrorMessage(err.message))
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
scopedErrorMessage(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
async function assertMcpEnabled(): Promise<void> {
|
||||
const health = await client.health()
|
||||
if (health.mcpEnabled === false) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
"LLM Wiki MCP access is disabled. Enable Settings -> API + MCP -> Enable MCP access in the desktop app.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function textResult(text: string) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function resolveProjectScope(args: Record<string, unknown>): Promise<{ id: string; project: ApiProject | null }> {
|
||||
let id: string
|
||||
try {
|
||||
id = projectBinding.resolve(optionalStringArg(args.project_id) ?? undefined)
|
||||
} catch (error) {
|
||||
throw new McpError(ErrorCode.InvalidParams, scopedErrorMessage(error))
|
||||
}
|
||||
if (projectBinding.project) return { id, project: projectBinding.project }
|
||||
const projects = await client.projects()
|
||||
const project = id === DEFAULT_PROJECT_ID
|
||||
? projects.currentProject
|
||||
: projects.projects.find((candidate) => candidate.id === id || candidate.path === id) ?? null
|
||||
return { id, project }
|
||||
}
|
||||
|
||||
function scopedErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const project = projectBinding.project
|
||||
if (!project || message.includes("[activeProject:")) return message
|
||||
return `[activeProject: ${project.name} (${project.id})] ${message}`
|
||||
}
|
||||
|
||||
function stringArg(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new McpError(ErrorCode.InvalidParams, `${name} is required`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalStringArg(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() !== "" ? value : undefined
|
||||
}
|
||||
|
||||
function boolArg(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback
|
||||
}
|
||||
|
||||
function numberArg(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function enumArg<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === "string" && allowed.includes(value as T) ? value as T : fallback
|
||||
}
|
||||
|
||||
function stringArrayArg(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxBytes: number): string {
|
||||
const bytes = Buffer.byteLength(value, "utf8")
|
||||
if (bytes <= maxBytes) return value
|
||||
let out = ""
|
||||
let used = 0
|
||||
for (const ch of value) {
|
||||
const size = Buffer.byteLength(ch, "utf8")
|
||||
if (used + size > maxBytes) break
|
||||
out += ch
|
||||
used += size
|
||||
}
|
||||
return `${out}\n\n[truncated: ${bytes - used} bytes omitted]`
|
||||
}
|
||||
|
||||
function formatFileTree(files: ApiFileNode[], truncated = false): string {
|
||||
if (files.length === 0) return "No files found."
|
||||
const lines: string[] = truncated
|
||||
? ["[warning] File tree was truncated by the LLM Wiki API maxFiles limit.", ""]
|
||||
: []
|
||||
const walk = (nodes: ApiFileNode[], depth: number) => {
|
||||
for (const node of nodes) {
|
||||
const prefix = " ".repeat(depth)
|
||||
lines.push(`${prefix}${node.isDir ? "📁" : "📄"} ${node.path}`)
|
||||
if (node.children) walk(node.children, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(files, 0)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatSearchResults(query: string, search: { results: ApiSearchResult[]; mode?: string; tokenHits?: number; vectorHits?: number }): string {
|
||||
const { results } = search
|
||||
if (results.length === 0) return `No results for "${query}".`
|
||||
const meta = [
|
||||
search.mode ? `Mode: ${search.mode}` : null,
|
||||
typeof search.tokenHits === "number" ? `Token hits: ${search.tokenHits}` : null,
|
||||
typeof search.vectorHits === "number" ? `Vector hits: ${search.vectorHits}` : null,
|
||||
].filter(Boolean)
|
||||
const lines = [`# Search results for "${query}"`, ...(meta.length > 0 ? [meta.join(" | ")] : []), ""]
|
||||
results.forEach((result, index) => {
|
||||
lines.push(`## ${index + 1}. ${result.title}`)
|
||||
lines.push(`Path: ${result.path}`)
|
||||
lines.push(`Score: ${result.score.toFixed(6)}${typeof result.vectorScore === "number" ? ` | Vector score: ${result.vectorScore.toFixed(6)}` : ""}`)
|
||||
if (result.snippet) lines.push(`Snippet: ${result.snippet}`)
|
||||
if (result.images && result.images.length > 0) {
|
||||
lines.push(`Images: ${result.images.map((image) => image.url).join(", ")}`)
|
||||
}
|
||||
lines.push("")
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatChatResponse(chat: ApiChatResponse): string {
|
||||
const lines = [
|
||||
"# LLM Wiki Agent response",
|
||||
"",
|
||||
`Session: ${chat.sessionId || "(none)"}`,
|
||||
chat.mode ? `Mode: ${chat.mode}` : null,
|
||||
chat.projectId ? `Project: ${chat.projectId}` : null,
|
||||
chat.usage
|
||||
? `Usage: promptChars=${chat.usage.promptChars ?? 0}, completionChars=${chat.usage.completionChars ?? 0}, references=${chat.usage.referenceCount ?? chat.references.length}`
|
||||
: null,
|
||||
"",
|
||||
chat.message.content || "(empty response)",
|
||||
"",
|
||||
].filter((line): line is string => line !== null)
|
||||
|
||||
if (chat.references.length > 0) {
|
||||
lines.push("## References")
|
||||
chat.references.forEach((reference, index) => {
|
||||
lines.push(`${index + 1}. ${reference.title || reference.path}`)
|
||||
lines.push(` Kind: ${reference.kind}`)
|
||||
lines.push(` Path: ${reference.path}`)
|
||||
if (typeof reference.score === "number") lines.push(` Score: ${reference.score.toFixed(6)}`)
|
||||
if (reference.snippet) lines.push(` Snippet: ${reference.snippet}`)
|
||||
})
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (chat.toolEvents.length > 0) {
|
||||
lines.push("## Tool events")
|
||||
chat.toolEvents.forEach((event) => {
|
||||
lines.push(`- ${event.tool}: ${event.status}${event.detail ? ` (${event.detail})` : ""}`)
|
||||
})
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatReviews(response: ApiReviewsResponse): string {
|
||||
const { reviews } = response
|
||||
if (reviews.length === 0) return `No ${response.status} review items found.`
|
||||
const lines = [
|
||||
"# Review items",
|
||||
"",
|
||||
`Status: ${response.status}`,
|
||||
`Count: ${response.count}`,
|
||||
"",
|
||||
]
|
||||
reviews.forEach((review, index) => {
|
||||
lines.push(`## ${index + 1}. ${review.title || review.id}`)
|
||||
lines.push(`ID: ${review.id}`)
|
||||
lines.push(`Type: ${review.type}`)
|
||||
lines.push(`Resolved: ${review.resolved ? "yes" : "no"}`)
|
||||
if (review.sourcePath) lines.push(`Source: ${review.sourcePath}`)
|
||||
if (review.affectedPages && review.affectedPages.length > 0) {
|
||||
lines.push(`Affected pages: ${review.affectedPages.join(", ")}`)
|
||||
}
|
||||
if (review.searchQueries && review.searchQueries.length > 0) {
|
||||
lines.push(`Search queries: ${review.searchQueries.join(", ")}`)
|
||||
}
|
||||
if (review.description) lines.push(`Description: ${review.description}`)
|
||||
const optionSummary = formatReviewOptions(review)
|
||||
if (optionSummary) lines.push(`Options: ${optionSummary}`)
|
||||
lines.push("")
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatReviewOptions(review: ApiReviewItem): string {
|
||||
if (!review.options || review.options.length === 0) return ""
|
||||
return review.options
|
||||
.map((option) => option.label ? `${option.label} (${option.action})` : option.action)
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
function formatGraph(nodes: ApiGraphNode[], edges: Array<{ source: string; target: string; weight?: number }>): string {
|
||||
const typeCounts = new Map<string, number>()
|
||||
for (const node of nodes) typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1)
|
||||
const lines = [
|
||||
"# Knowledge graph",
|
||||
"",
|
||||
`Nodes: ${nodes.length}`,
|
||||
`Edges: ${edges.length}`,
|
||||
"",
|
||||
"## Node types",
|
||||
...[...typeCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => `- ${type}: ${count}`),
|
||||
"",
|
||||
"## Top nodes",
|
||||
...nodes
|
||||
.slice()
|
||||
.sort((a, b) => (b.linkCount ?? 0) - (a.linkCount ?? 0))
|
||||
.slice(0, 30)
|
||||
.map((node) => `- ${node.label} (${node.type}, ${node.linkCount ?? 0} links)${node.path ? ` — ${node.path}` : ""}`),
|
||||
]
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error(`LLM Wiki MCP server v${VERSION} connected to ${process.env.LLM_WIKI_API_BASE_URL ?? "http://127.0.0.1:19828"}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start LLM Wiki MCP server:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ApiProject } from "./api-client.js"
|
||||
|
||||
export class McpProjectBinding {
|
||||
private pinned: ApiProject | null = null
|
||||
|
||||
get project(): ApiProject | null {
|
||||
return this.pinned
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pinned = null
|
||||
}
|
||||
|
||||
pin(requested: string, projects: ApiProject[], current: ApiProject | null): ApiProject {
|
||||
const candidate = requested === "current"
|
||||
? current
|
||||
: projects.find((project) => project.id === requested || project.path === requested) ?? null
|
||||
if (!candidate) throw new Error(`Unknown LLM Wiki project: ${requested}`)
|
||||
this.pinned = candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
resolve(requested?: string): string {
|
||||
if (!this.pinned) return requested ?? "current"
|
||||
if (
|
||||
requested &&
|
||||
requested !== "current" &&
|
||||
requested !== this.pinned.id &&
|
||||
requested !== this.pinned.path
|
||||
) {
|
||||
throw new Error(
|
||||
`This MCP session is pinned to ${this.pinned.name} (${this.pinned.id}); ` +
|
||||
`project override ${requested} was rejected. Call llm_wiki_set_project to change scope.`,
|
||||
)
|
||||
}
|
||||
return this.pinned.id
|
||||
}
|
||||
}
|
||||
|
||||
export function withActiveProject(text: string, project: ApiProject | null, requestedId: string): string {
|
||||
const scope = project
|
||||
? `${project.name} (${project.id})`
|
||||
: requestedId
|
||||
return `[activeProject: ${scope}]\n\n${text}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
export const FALLBACK_VERSION = "0.0.0"
|
||||
|
||||
export function loadMcpServerVersion(metaUrl: string = import.meta.url): string {
|
||||
// These layouts are mutually exclusive: source/dev execution resolves via
|
||||
// ../package.json, while compiled dist/src execution resolves via
|
||||
// ../../package.json.
|
||||
for (const relativePackageJson of ["../package.json", "../../package.json"]) {
|
||||
try {
|
||||
const candidate = new URL(relativePackageJson, metaUrl)
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as { version?: unknown }
|
||||
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
||||
return parsed.version
|
||||
}
|
||||
} catch {
|
||||
// Try the next layout.
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("[llm-wiki-mcp] package.json version not found; using fallback 0.0.0\n")
|
||||
return FALLBACK_VERSION
|
||||
}
|
||||
|
||||
export const VERSION = loadMcpServerVersion()
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { LlmWikiApiClient, normalizeBaseUrl } from "../src/api-client.js"
|
||||
|
||||
test("normalizeBaseUrl trims trailing slashes and falls back to localhost", () => {
|
||||
assert.equal(normalizeBaseUrl("http://127.0.0.1:19828///"), "http://127.0.0.1:19828")
|
||||
assert.equal(normalizeBaseUrl(""), "http://127.0.0.1:19828")
|
||||
})
|
||||
|
||||
test("projects sends bearer token and parses current project", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = []
|
||||
const fetchImpl = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
calls.push({ url: String(url), init })
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projects: [{ id: "p1", name: "Demo", path: "/tmp/demo", current: true }],
|
||||
currentProject: { id: "p1", name: "Demo", path: "/tmp/demo", current: true },
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({
|
||||
baseUrl: "http://localhost:19828/",
|
||||
token: "secret",
|
||||
fetchImpl,
|
||||
})
|
||||
const result = await client.projects()
|
||||
|
||||
assert.equal(calls[0]?.url, "http://localhost:19828/api/v1/projects")
|
||||
assert.equal((calls[0]?.init?.headers as Record<string, string>).Authorization, "Bearer secret")
|
||||
assert.equal(result.currentProject?.id, "p1")
|
||||
assert.equal(result.projects[0]?.current, true)
|
||||
})
|
||||
|
||||
test("health does not send authorization", async () => {
|
||||
const calls: Array<RequestInit | undefined> = []
|
||||
const fetchImpl = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
calls.push(init)
|
||||
return new Response(JSON.stringify({ ok: true, status: "running" }), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ token: "secret", fetchImpl })
|
||||
await client.health()
|
||||
|
||||
assert.equal((calls[0]?.headers as Record<string, string> | undefined)?.Authorization, undefined)
|
||||
})
|
||||
|
||||
test("search posts JSON body to current project", async () => {
|
||||
let body = ""
|
||||
const fetchImpl = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
body = String(init?.body ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
mode: "hybrid",
|
||||
tokenHits: 2,
|
||||
vectorHits: 1,
|
||||
results: [{ path: "wiki/a.md", title: "A", snippet: "hit", score: 0.5, vectorScore: 0.9 }],
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const results = await client.search("current", "query", { topK: 3, includeContent: true })
|
||||
|
||||
assert.deepEqual(JSON.parse(body), { query: "query", topK: 3, includeContent: true })
|
||||
assert.equal(results.mode, "hybrid")
|
||||
assert.equal(results.tokenHits, 2)
|
||||
assert.equal(results.vectorHits, 1)
|
||||
assert.equal(results.results[0]?.vectorScore, 0.9)
|
||||
})
|
||||
|
||||
test("chat posts agent request and parses references", async () => {
|
||||
let url = ""
|
||||
let body = ""
|
||||
const fetchImpl = async (requestUrl: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
url = String(requestUrl)
|
||||
body = String(init?.body ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectId: "p1",
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
message: { role: "assistant", content: "answer" },
|
||||
references: [{ title: "A", path: "wiki/a.md", kind: "wiki", snippet: "hit", score: 0.5 }],
|
||||
toolEvents: [{ tool: "wiki.search", status: "completed", detail: "1 result" }],
|
||||
events: [{ type: "toolEnd", tool: "wiki.search" }],
|
||||
usage: { promptChars: 100, completionChars: 6, referenceCount: 1, toolEventCount: 1 },
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const response = await client.chat("current", "question", {
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
topK: 4,
|
||||
includeContent: true,
|
||||
wiki: true,
|
||||
web: false,
|
||||
anytxt: true,
|
||||
skills: ["reviewer"],
|
||||
})
|
||||
|
||||
assert.equal(url, "http://localhost:19828/api/v1/projects/current/chat")
|
||||
assert.deepEqual(JSON.parse(body), {
|
||||
message: "question",
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
topK: 4,
|
||||
includeContent: true,
|
||||
tools: { wiki: true, web: false, anytxt: true },
|
||||
skills: ["reviewer"],
|
||||
})
|
||||
assert.equal(response.sessionId, "s1")
|
||||
assert.equal(response.message.content, "answer")
|
||||
assert.equal(response.references[0]?.path, "wiki/a.md")
|
||||
assert.equal(response.toolEvents[0]?.tool, "wiki.search")
|
||||
assert.equal(response.events[0]?.type, "toolEnd")
|
||||
assert.equal(response.usage?.promptChars, 100)
|
||||
})
|
||||
|
||||
test("cancelChat posts to the chat cancellation endpoint", async () => {
|
||||
let url = ""
|
||||
let method = ""
|
||||
const fetchImpl = async (requestUrl: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
url = String(requestUrl)
|
||||
method = String(init?.method ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: "s1",
|
||||
cancelled: true,
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const response = await client.cancelChat("current", "s1")
|
||||
|
||||
assert.equal(url, "http://localhost:19828/api/v1/projects/current/chat/s1/cancel")
|
||||
assert.equal(method, "POST")
|
||||
assert.deepEqual(response, { sessionId: "s1", cancelled: true })
|
||||
})
|
||||
|
||||
test("graph parses nodeType from API graph nodes", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({
|
||||
ok: true,
|
||||
nodes: [{ id: "n1", label: "Node", nodeType: "concept", path: "wiki/concepts/n1.md", linkCount: 4 }],
|
||||
edges: [{ source: "n1", target: "n2", weight: 0.75 }],
|
||||
}), { status: 200 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const graph = await client.graph("current")
|
||||
|
||||
assert.equal(graph.nodes[0]?.type, "concept")
|
||||
assert.equal(graph.nodes[0]?.linkCount, 4)
|
||||
assert.equal(graph.edges[0]?.weight, 0.75)
|
||||
})
|
||||
|
||||
test("files exposes truncated flag", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({
|
||||
ok: true,
|
||||
files: [{ name: "index.md", path: "wiki/index.md", isDir: false }],
|
||||
truncated: true,
|
||||
}), { status: 200 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const files = await client.files("current")
|
||||
|
||||
assert.equal(files.truncated, true)
|
||||
assert.equal(files.files[0]?.path, "wiki/index.md")
|
||||
})
|
||||
|
||||
test("reviews requests unresolved review items with filters", async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = async (url: string | URL | Request): Promise<Response> => {
|
||||
calls.push(String(url))
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectId: "p1",
|
||||
status: "unresolved",
|
||||
count: 1,
|
||||
reviews: [{
|
||||
id: "r1",
|
||||
type: "missing-page",
|
||||
title: "Missing page: Attention",
|
||||
description: "Add the Attention page",
|
||||
options: [],
|
||||
resolved: false,
|
||||
createdAt: 1,
|
||||
}],
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const result = await client.reviews("current", {
|
||||
status: "unresolved",
|
||||
type: "missing-page",
|
||||
limit: 5,
|
||||
})
|
||||
|
||||
assert.equal(calls[0], "http://localhost:19828/api/v1/projects/current/reviews?status=unresolved&type=missing-page&limit=5")
|
||||
assert.equal(result.status, "unresolved")
|
||||
assert.equal(result.count, 1)
|
||||
assert.equal(result.reviews[0]?.id, "r1")
|
||||
assert.equal(result.reviews[0]?.resolved, false)
|
||||
})
|
||||
|
||||
test("network failures include desktop app hint", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => {
|
||||
throw new Error("ECONNREFUSED")
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /Is the desktop app running\? ECONNREFUSED/)
|
||||
})
|
||||
|
||||
test("non-JSON responses include status and body preview", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response("not json", { status: 502, statusText: "Bad Gateway" })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /non-JSON response \(502\): not json/)
|
||||
})
|
||||
|
||||
test("API errors include status and server message", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), { status: 401 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /LLM Wiki API 401: Unauthorized/)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { McpProjectBinding, withActiveProject } from "../src/project-binding.js"
|
||||
|
||||
const alpha = { id: "p1", name: "Alpha", path: "/wiki/alpha", current: true }
|
||||
const beta = { id: "p2", name: "Beta", path: "/wiki/beta", current: false }
|
||||
|
||||
test("pin resolves current to a stable project id", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
binding.pin("current", [alpha, beta], alpha)
|
||||
assert.equal(binding.resolve(), "p1")
|
||||
assert.equal(binding.resolve("current"), "p1")
|
||||
})
|
||||
|
||||
test("pinned sessions reject cross-project overrides", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
binding.pin("p1", [alpha, beta], alpha)
|
||||
assert.equal(binding.resolve("/wiki/alpha"), "p1")
|
||||
assert.throws(() => binding.resolve("p2"), /override p2 was rejected/)
|
||||
})
|
||||
|
||||
test("unbound sessions preserve the current-project compatibility default", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
assert.equal(binding.resolve(), "current")
|
||||
assert.equal(binding.resolve("p2"), "p2")
|
||||
})
|
||||
|
||||
test("responses carry a structural active-project reminder", () => {
|
||||
assert.match(withActiveProject("result", alpha, "p1"), /^\[activeProject: Alpha \(p1\)\]/)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { test } from "node:test"
|
||||
import { FALLBACK_VERSION, VERSION, loadMcpServerVersion } from "../src/version.js"
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as {
|
||||
version: string
|
||||
}
|
||||
|
||||
test("MCP server version is read from package.json", () => {
|
||||
assert.equal(VERSION, pkg.version)
|
||||
})
|
||||
|
||||
test("MCP server version supports source-layout execution", () => {
|
||||
assert.equal(
|
||||
loadMcpServerVersion(new URL("../../src/version.ts", import.meta.url).href),
|
||||
pkg.version,
|
||||
)
|
||||
})
|
||||
|
||||
test("MCP server version falls back when package.json cannot be found", () => {
|
||||
assert.equal(loadMcpServerVersion("file:///tmp/llm-wiki-missing/dist/src/version.js"), FALLBACK_VERSION)
|
||||
})
|
||||
|
||||
test("MCP server version falls back for invalid meta URLs", () => {
|
||||
assert.equal(loadMcpServerVersion("not a url"), FALLBACK_VERSION)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "llm-wiki",
|
||||
"private": true,
|
||||
"version": "0.6.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --build --pretty",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"build:desktop": "npm --prefix mcp-server ci && npm run mcp:build && npm run build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:mocks && npm run test:llm",
|
||||
"test:mocks": "vitest run --exclude='**/*.real-llm.test.ts' --exclude='**/mcp-server/**'",
|
||||
"test:llm": "vitest run real-llm --no-file-parallelism --reporter=verbose",
|
||||
"mcp:build": "npm --prefix mcp-server run build",
|
||||
"mcp:test": "npm --prefix mcp-server test",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@milkdown/kit": "^7.20.0",
|
||||
"@milkdown/plugin-math": "^7.5.9",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/theme-nord": "^7.20.0",
|
||||
"@react-sigma/core": "^5.0.6",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"i18next": "^26.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.9.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shadcn": "^4.1.2",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"fast-check": "^4.7.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
# Multimodal: image extraction + indexing for documents
|
||||
|
||||
**Status:** Spec, not started. Branch will be cut from `main` at commit `63d8538`.
|
||||
|
||||
**Goal:** When a user ingests a PDF / PPTX / DOCX that contains images
|
||||
(charts, diagrams, photos, screenshots), the images become discoverable
|
||||
via the existing wiki search + chat flow alongside the document's text.
|
||||
|
||||
**Non-goals (this round):**
|
||||
- "Search by image" / image-to-image retrieval — deferred to Phase 5.
|
||||
- Editing or annotating images post-ingest.
|
||||
- OCR-only path (Tesseract). VLM caption is strictly more capable; if
|
||||
cost becomes the issue we'll add OCR as a fallback later.
|
||||
- Replacing / changing the existing chunker, embedding API, search
|
||||
ranking, or RAG pipeline. This work strictly **adds** a vision step
|
||||
on top.
|
||||
|
||||
---
|
||||
|
||||
## Current state (audit, not assumption)
|
||||
|
||||
`src-tauri/src/commands/fs.rs::preprocess_file`:
|
||||
|
||||
| Format | Current behavior | Image handling |
|
||||
|---|---|---|
|
||||
| PDF | `pdfium_render` → `page.text().all()` | **Ignored.** Embedded images, scans, charts all dropped. |
|
||||
| PPTX | unzip → parse `ppt/slides/slideN.xml` | **Ignored.** `ppt/media/*.png|jpg` already in the ZIP, just not read. |
|
||||
| DOCX | unzip → parse `word/document.xml` | **Ignored.** `word/media/*` same as above. |
|
||||
| XLSX/ODS | `calamine` → cell text | Ignored. |
|
||||
| Standalone images (.png/.jpg/...) | Read as binary in `read_file`; preview UI shows them | **Do NOT enter the ingest pipeline.** Never become wiki pages. |
|
||||
|
||||
Whole TS chain (`text-chunker.ts`, `embedding.ts`, `search.ts`,
|
||||
`chat-panel.tsx`) is text-only. LanceDB v2 schema field is
|
||||
`chunk_text: Utf8` — no provision for image bytes or paths.
|
||||
|
||||
Dependencies already present we can lean on:
|
||||
- `pdfium-render` 0.9 — supports `page.objects()` iteration, including
|
||||
`PdfPageObjectType::Image` extraction
|
||||
- `zip` 2.x — direct access to PPTX/DOCX `media/` directories
|
||||
- LLM provider abstraction in `llm-providers.ts` — every provider
|
||||
(OpenAI / Anthropic / Gemini / Claude Code CLI) supports
|
||||
vision-message input on its native wire; we just don't expose it
|
||||
|
||||
---
|
||||
|
||||
## Design: caption-first hybrid (Option C from planning)
|
||||
|
||||
Three rejected alternatives are documented at the bottom of this file
|
||||
for posterity. The chosen path:
|
||||
|
||||
1. **Extract images** from PDF / PPTX / DOCX during preprocess
|
||||
2. **Save originals** to `<project>/wiki/media/<source-slug>/<n>.<ext>`
|
||||
3. **Caption with vision LLM** ("describe factually, include any text,
|
||||
chart axes, key visual elements; 2–4 sentences")
|
||||
4. **Inject as markdown** ``
|
||||
into the source content fed to the analysis / generation prompts —
|
||||
so the LLM that builds the wiki page can place these images
|
||||
contextually
|
||||
5. **Captions are ordinary text** — they flow through the existing
|
||||
`chunkMarkdown` → `embedPage` → `vector_upsert_chunks` pipeline
|
||||
with zero changes
|
||||
6. **chat-message renders the markdown image** — the existing
|
||||
`react-markdown` setup already does this; it just needs the path
|
||||
to resolve to the right place
|
||||
|
||||
### Why this design
|
||||
|
||||
- **No schema change** to LanceDB. Captions are text chunks. Search
|
||||
works without modification.
|
||||
- **No retrieval-quality regression.** Existing text-only retrieval
|
||||
paths are untouched. The chunker just sees more text (the
|
||||
captions) which makes images cite-able by their semantic content.
|
||||
- **User sees the actual image** in chat replies, not just a textual
|
||||
description.
|
||||
- **Provider-agnostic.** Every LLM provider we support has a vision
|
||||
format; we abstract over them in `buildBody`.
|
||||
- **Phased.** Each phase is independently shippable and reversible.
|
||||
|
||||
### What this design does NOT solve (and that's OK for v1)
|
||||
|
||||
- Retrieving "an image that LOOKS like X" (visual similarity) — needs
|
||||
multimodal embedding (Phase 5, deferred).
|
||||
- Captions that miss subtle details (e.g. "the third bar is taller
|
||||
than the second") — limited by VLM quality. Pro-tier models help;
|
||||
Flash Lite captions will be shallow.
|
||||
- Image dedup across files (same logo / icon appearing 50 times) —
|
||||
handled by a SHA-256 hash cache, see Phase 1 risks below.
|
||||
|
||||
---
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: Rust-side image extraction
|
||||
|
||||
New commands in `src-tauri/src/commands/fs.rs` (or a new
|
||||
`src-tauri/src/commands/extract_images.rs` if `fs.rs` is getting too
|
||||
big — currently 1100+ lines, leaning toward new file).
|
||||
|
||||
Public API shape (Tauri commands, callable from TS):
|
||||
|
||||
```rust
|
||||
#[derive(Serialize)]
|
||||
struct ExtractedImage {
|
||||
/// 1-based image index within the document (for filename)
|
||||
index: u32,
|
||||
/// PNG / JPEG / etc., as a MIME type
|
||||
mime_type: String,
|
||||
/// Page (PDF) or slide (PPTX) the image came from. None for DOCX.
|
||||
page: Option<u32>,
|
||||
/// Pixel width / height — used to filter out logos / icons.
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Image bytes, base64-encoded for IPC.
|
||||
data_base64: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn extract_pdf_images(path: String) -> Result<Vec<ExtractedImage>, String>
|
||||
|
||||
#[tauri::command]
|
||||
async fn extract_office_images(path: String) -> Result<Vec<ExtractedImage>, String>
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- **PDF**: iterate `doc.pages()` → `page.objects()` → filter
|
||||
`PdfPageObjectType::Image` → `as_image_object().get_raw_image()`
|
||||
→ encode to PNG via `image` crate (already a transitive dep
|
||||
through pdfium-render).
|
||||
- **PPTX/DOCX**: open as ZIP, iterate file names matching
|
||||
`^(ppt|word)/media/.*\.(png|jpe?g|gif|webp|bmp)$`, read bytes
|
||||
directly — already in their native format.
|
||||
- **Size filter**: drop images smaller than 100×100 (configurable
|
||||
later). Saves VLM cost on logos / decorations / cropping
|
||||
artifacts. ~80% noise removal in practice for slide decks.
|
||||
- **Memory**: extract images in a `for` loop, not `collect()` — a
|
||||
100-page PDF with 50 images is ~50 MB before base64 (~67 MB
|
||||
after). Streaming through a `Vec<ExtractedImage>` is OK for IPC
|
||||
but we should be defensive against a pathological 5000-image
|
||||
document — add a `max_images: 500` cap.
|
||||
|
||||
Tests (`src-tauri/src/commands/extract_images.rs::tests`):
|
||||
- Synthetic PDF with 1 known image → extract returns 1 entry with
|
||||
expected dims and non-empty bytes.
|
||||
- Real PPTX from `tests/fixtures/` with multiple slides containing
|
||||
images → counts and sizes match.
|
||||
- DOCX with no images → returns `Ok([])`, not an error.
|
||||
- Password-protected PDF → returns the same error string the text
|
||||
extractor returns (consistent UX).
|
||||
|
||||
### Phase 2: Vision-message support in LLM abstraction
|
||||
|
||||
`src/lib/llm-providers.ts`:
|
||||
|
||||
```ts
|
||||
// New union — replaces the existing `content: string` on ChatMessage
|
||||
export type ContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; mediaType: string; dataBase64: string }
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant"
|
||||
// Backwards-compatible: providers that don't get an image keep
|
||||
// calling sites working with plain strings. Block-array form
|
||||
// unlocks vision input.
|
||||
content: string | ContentBlock[]
|
||||
}
|
||||
```
|
||||
|
||||
Each provider's `buildBody` learns to translate `ContentBlock[]`:
|
||||
- **OpenAI**: `[{type:"text",...}, {type:"image_url",image_url:{url:"data:image/png;base64,..."}}]`
|
||||
- **Anthropic**: `[{type:"text",...}, {type:"image",source:{type:"base64",media_type:"image/png",data:"..."}}]`
|
||||
- **Gemini**: `parts:[{text:"..."},{inline_data:{mime_type:"image/png",data:"..."}}]`
|
||||
- **Claude Code CLI**: already takes content blocks (PR #61), just
|
||||
add `image` block type passthrough.
|
||||
- **Ollama**: `messages[].images: [base64]` (separate field, not
|
||||
inline blocks). Conditional on the model — only `llava`,
|
||||
`qwen2.5-vl`, etc. accept it.
|
||||
|
||||
Existing test files (`llm-providers.test.ts`,
|
||||
`__tests__/claude-cli-transport.test.ts`) need vision cases added.
|
||||
|
||||
### Phase 3: Captioning helper + ingest integration
|
||||
|
||||
`src/lib/vision-caption.ts` (new):
|
||||
|
||||
```ts
|
||||
export async function captionImage(
|
||||
imageBase64: string,
|
||||
mediaType: string,
|
||||
llmConfig: LlmConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
Implementation: build a `streamChat` call with a single user message
|
||||
whose content is `[{type:"text",text:CAPTION_PROMPT},{type:"image",...}]`,
|
||||
collect all tokens, return the joined string.
|
||||
|
||||
Caption prompt (pinned, factual, no markdown):
|
||||
|
||||
> Describe this image factually for a knowledge-base index. Include:
|
||||
> any visible text verbatim, chart axes and values, diagram structure
|
||||
> (boxes/arrows/labels), key visual elements. Do NOT speculate or
|
||||
> editorialize. 2 to 4 sentences. Output plain text only — no
|
||||
> markdown, no preamble.
|
||||
|
||||
`src/lib/ingest.ts` integration:
|
||||
|
||||
After `preprocess_file` returns text, BEFORE the analysis stage:
|
||||
|
||||
```ts
|
||||
const images = await invoke('extract_pdf_images' or 'extract_office_images', { path })
|
||||
const captioned = []
|
||||
for (const img of images) {
|
||||
const relPath = `wiki/media/${slug}/img-${img.index}.${ext}`
|
||||
await writeFile(`${pp}/${relPath}`, base64ToBytes(img.data_base64))
|
||||
const caption = await captionImage(img.data_base64, img.mime_type, llmConfig, signal)
|
||||
captioned.push({ relPath, caption, page: img.page })
|
||||
}
|
||||
|
||||
// Inject into sourceContent so the LLM sees them in context
|
||||
const imageSection = captioned.length > 0
|
||||
? '\n\n## Embedded Images\n\n' +
|
||||
captioned.map(c =>
|
||||
c.page
|
||||
? `**[Page ${c.page}]** `
|
||||
: ``
|
||||
).join('\n\n')
|
||||
: ''
|
||||
const enrichedSource = sourceContent + imageSection
|
||||
// ... rest of autoIngest uses enrichedSource
|
||||
```
|
||||
|
||||
Per-image cache keyed by SHA-256 of image bytes — same logo across
|
||||
50 PDFs = 1 caption call, not 50. Cache lives in
|
||||
`<project>/.llm-wiki/image-caption-cache.json` mapping
|
||||
`hash → caption` (and image dimensions, mime, optionally the cached
|
||||
file path so we deduplicate file storage too).
|
||||
|
||||
### Phase 4: Settings toggle + cost guardrails
|
||||
|
||||
`src/components/settings/sections/embedding-section.tsx` (or a new
|
||||
"Multimodal" section if it grows): add a toggle.
|
||||
|
||||
```
|
||||
☐ Index images from documents (uses extra LLM credits)
|
||||
Each image is captioned with a vision model. A 100-page paper
|
||||
with 30 images = 30 vision calls per ingest.
|
||||
Max images per document: [500]
|
||||
Skip images smaller than: [100]px on either side
|
||||
```
|
||||
|
||||
Stored in `useWikiStore.embeddingConfig` (or a sibling
|
||||
`multimodalConfig` if we want to keep them separate). Read by
|
||||
`autoIngest` to decide whether to run Phase 1 + 3 at all.
|
||||
|
||||
**Default off.** Users opt in. README / changelog notes the cost
|
||||
implication clearly.
|
||||
|
||||
### Phase 5 (deferred, NOT this round): multimodal embedding
|
||||
|
||||
Add a parallel embedding path that hits a multimodal endpoint
|
||||
(`/v1/embeddings` with image input — supported by Voyage Multimodal,
|
||||
Jina CLIP v2, some local CLIP servers). Store image-vector alongside
|
||||
text chunk-vector in LanceDB (either same table with a `kind` field,
|
||||
or a sibling `wiki_images` table).
|
||||
|
||||
This unlocks "find an image that looks like X" but is **strictly
|
||||
additive** — caption-based retrieval keeps working as-is.
|
||||
|
||||
Requires user to have a multimodal embedding endpoint, which their
|
||||
current LM Studio `qwen3-embedding-0.6b` is NOT.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (resolve before / during Phase 1)
|
||||
|
||||
1. **Provider matrix**: which providers should the vision toggle
|
||||
actually enable? OpenAI / Anthropic / Gemini / Claude Code CLI all
|
||||
work. Ollama needs a vision-capable model (must check `cfg.model`
|
||||
against a known list). Custom endpoint depends on user's setup.
|
||||
MiniMax — uncertain, needs probe. **Tentative answer**: silently
|
||||
skip vision step on providers that don't support it; show a banner
|
||||
in Settings.
|
||||
|
||||
2. **Image size threshold**: 100×100 vs 80×80 vs 5KB byte threshold.
|
||||
Small images are usually icons / decorations. **Tentative**:
|
||||
100×100 default, exposed in Settings.
|
||||
|
||||
3. **Dedup strategy**: SHA-256 hash of image bytes → cache caption
|
||||
for that hash project-wide. **Tentative**: yes, default on.
|
||||
Cache invalidation tied to caption-prompt version.
|
||||
|
||||
4. **Per-document VLM cap**: a 500-page slide deck with 1500 images
|
||||
could blow up costs unnoticed. **Tentative**: hard cap at 500
|
||||
images per document, configurable. Beyond that, surface a
|
||||
warning in the activity panel and skip.
|
||||
|
||||
5. **What if the VLM call fails / times out?** Caption-less image
|
||||
should still be saved to disk and embedded as ``
|
||||
without a caption — it's at least visible to the user, just not
|
||||
searchable by content. Soft failure, not hard.
|
||||
|
||||
6. **Standalone .png / .jpg imports**: do we treat them as
|
||||
single-image "documents" and run them through the caption path?
|
||||
**Tentative**: yes, but as a follow-up after Phase 1–4 land for
|
||||
embedded images.
|
||||
|
||||
7. **Image sub-dir naming**: `wiki/media/<source-slug>/` or flat
|
||||
`wiki/media/<slug>-<n>.<ext>`? Subdirs are cleaner; conflicts
|
||||
resolved by source-delete cascade automatically. **Tentative**:
|
||||
subdirs.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Cost**: Phase 3 is the expensive step. Mitigated by Phase 4
|
||||
toggle (default off) + dedup cache + per-doc cap.
|
||||
- **Caption quality**: Flash Lite produces near-useless 1-sentence
|
||||
captions. Document this in Settings hint; recommend Sonnet+ for
|
||||
multimodal.
|
||||
- **Performance**: each ingest now does N additional LLM calls in
|
||||
series. For a 30-image PDF, that's 30 × ~3s = 90s extra latency.
|
||||
We can parallelize the caption calls with `Promise.all` (the
|
||||
caption mutex doesn't apply — they're independent).
|
||||
- **PDF extraction quality**: pdfium's image extraction returns the
|
||||
raw embedded image; for vector graphics (which PDFs sometimes
|
||||
use for charts) this fails — those are paths/text, not Image
|
||||
objects. We'd miss them. **Mitigation**: render the entire page
|
||||
to a PNG as fallback when no Image objects found AND the page
|
||||
has structural complexity. Defer to Phase 1.5 if Phase 1
|
||||
results are weak.
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Per phase, in priority order:
|
||||
|
||||
**Phase 1 (Rust extraction):**
|
||||
- Unit tests with synthetic + real fixtures
|
||||
- Test on a known-good PDF (e.g. an arxiv paper) — verify image
|
||||
count matches manual count
|
||||
|
||||
**Phase 2 (vision message format):**
|
||||
- Per-provider unit tests: assert correct wire format for each
|
||||
- Mock-server test that the bytes-on-wire match each provider's
|
||||
documented schema
|
||||
|
||||
**Phase 3 (captioning + ingest):**
|
||||
- Real-LLM test (gated by `RUN_LLM_TESTS=1`): pass a known image,
|
||||
verify caption is non-trivial and contains expected keywords
|
||||
- Integration test: full autoIngest on a small fixture PDF with 2
|
||||
known images → assert wiki/media/ has the files + the generated
|
||||
page references them in markdown
|
||||
|
||||
**Phase 4 (toggle):**
|
||||
- UI smoke test (manual)
|
||||
- Unit test: when toggle is off, extract_*_images is never called
|
||||
|
||||
---
|
||||
|
||||
## Rejected alternatives (for posterity)
|
||||
|
||||
- **Pure VLM caption** (Option A): same as our chosen path BUT
|
||||
without saving the original image. User loses ability to see the
|
||||
image in retrieval. Rejected — UX regression.
|
||||
- **Pure multimodal embedding** (Option B, no caption): no LLM cost
|
||||
at index time, true semantic image retrieval. Rejected because
|
||||
user's current embedding endpoint is text-only, AND we lose the
|
||||
ability to feed image content into LLM context (no caption text).
|
||||
- **OCR-only**: useless for non-textual images (charts, photos).
|
||||
Rejected for v1; could be added as a fallback for VLM failures.
|
||||
|
||||
---
|
||||
|
||||
## Branch + delivery plan
|
||||
|
||||
- Cut branch `feat/multimodal-images` from `main` @ `63d8538`
|
||||
- Phase 1 → 1 commit, ~3-4 days work
|
||||
- Phase 2 → 1 commit, ~2 days
|
||||
- Phase 3 → 2 commits (caption helper + ingest integration), ~3 days
|
||||
- Phase 4 → 1 commit, ~1 day
|
||||
- Each commit independently runnable + tested. Merge phases into
|
||||
branch as they land. Final merge to main as one big feature, OR
|
||||
as 4 separate PRs depending on review preference.
|
||||
|
||||
Total estimate: ~10 days of focused work.
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
debug_ollama_tokens.py — reproduce LLM Wiki's wiki-generation request against a
|
||||
raw Ollama endpoint so we can see exactly why generation reports "too many
|
||||
tokens".
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The app's Step-2 "Generate wiki pages" call (src/lib/ingest.ts) sends, to the
|
||||
OpenAI-compatible /v1/chat/completions endpoint:
|
||||
|
||||
{ model, stream:true, temperature:0.1, reasoning_effort:"none",
|
||||
max_tokens: computeIngestGenerationMaxTokens(maxContextSize),
|
||||
messages:[ {system: big generation prompt}, {user: analysis + source} ] }
|
||||
|
||||
`maxContextSize` is measured in CHARACTERS (default 204_800). The crucial
|
||||
mismatch: the OpenAI-compat endpoint has NO num_ctx control, so Ollama serves
|
||||
with whatever num_ctx the model was loaded at (default, NOT the model's full
|
||||
262k). When prompt_tokens + max_tokens overflow that window, Ollama complains.
|
||||
|
||||
This script lets you:
|
||||
* see the model's loaded context window (/api/show, /api/ps),
|
||||
* fire the exact app-shaped request at a chosen prompt size + max_tokens,
|
||||
* sweep prompt sizes to find the failure threshold,
|
||||
* compare the OpenAI-compat path (no num_ctx) against the native /api/chat
|
||||
path WITH options.num_ctx, to confirm num_ctx is the real lever.
|
||||
|
||||
Pure stdlib — no pip install. Run: python3 scripts/debug_ollama_tokens.py --help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys, time, urllib.request, urllib.error
|
||||
|
||||
# ── The app's actual generation max_tokens ladder (src/lib/ingest.ts:45-48,
|
||||
# 1687-1693). maxContextSize is in CHARACTERS. ────────────────────────────
|
||||
def app_generation_max_tokens(max_context_chars: int) -> int:
|
||||
if max_context_chars >= 512_000: return 32_768
|
||||
if max_context_chars >= 256_000: return 24_576
|
||||
if max_context_chars >= 128_000: return 16_384
|
||||
return 8_192
|
||||
|
||||
|
||||
def http_json(url: str, payload: dict, timeout: float) -> tuple[int, dict | str]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read().decode()
|
||||
try:
|
||||
return r.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return r.status, raw
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e: # noqa: BLE001 — we want every failure mode visible
|
||||
return -1, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def make_prompt(chars: int) -> str:
|
||||
"""Filler roughly `chars` long (~5 chars/token, so tokens ≈ chars/5)."""
|
||||
return "word " * max(1, chars // 5)
|
||||
|
||||
|
||||
def show_model(base: str, model: str, timeout: float) -> None:
|
||||
code, body = http_json(f"{base}/api/show", {"model": model}, timeout)
|
||||
print(f"── /api/show ({model}) ──")
|
||||
if isinstance(body, dict):
|
||||
print(" parameters (Modelfile defaults):")
|
||||
for line in str(body.get("parameters", "(none)")).splitlines():
|
||||
print(f" {line}")
|
||||
mi = body.get("model_info", {})
|
||||
ctx = next((v for k, v in mi.items() if k.endswith("context_length")), "?")
|
||||
print(f" model max context_length: {ctx}")
|
||||
else:
|
||||
print(f" HTTP {code}: {body}")
|
||||
code, ps = http_json(f"{base}/api/ps", {}, timeout)
|
||||
if isinstance(ps, dict):
|
||||
for m in ps.get("models", []):
|
||||
if m.get("name", "").startswith(model.split(":")[0]):
|
||||
print(f" LOADED num_ctx (context_length in /api/ps): {m.get('context_length','?')}")
|
||||
print()
|
||||
|
||||
|
||||
def call_openai(base: str, model: str, prompt: str, max_tokens: int, timeout: float) -> dict:
|
||||
"""Exactly what the app sends (OpenAI-compat, no num_ctx possible)."""
|
||||
payload = {
|
||||
"model": model, "stream": False, "temperature": 0.1,
|
||||
"reasoning_effort": "none", "max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You generate wiki FILE blocks. Reply briefly."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
}
|
||||
t0 = time.time()
|
||||
code, body = http_json(f"{base}/v1/chat/completions", payload, timeout)
|
||||
dt = time.time() - t0
|
||||
out = {"path": "openai", "http": code, "secs": round(dt, 1), "max_tokens": max_tokens}
|
||||
if isinstance(body, dict):
|
||||
u = body.get("usage", {})
|
||||
out.update(prompt_tokens=u.get("prompt_tokens"), completion_tokens=u.get("completion_tokens"),
|
||||
finish=body.get("choices", [{}])[0].get("finish_reason"))
|
||||
else:
|
||||
out["error"] = str(body)[:500]
|
||||
return out
|
||||
|
||||
|
||||
def call_native(base: str, model: str, prompt: str, max_tokens: int, num_ctx: int | None, timeout: float) -> dict:
|
||||
"""Native /api/chat — lets us set options.num_ctx, which /v1 cannot."""
|
||||
options = {"temperature": 0.1, "num_predict": max_tokens}
|
||||
if num_ctx is not None:
|
||||
options["num_ctx"] = num_ctx
|
||||
payload = {
|
||||
"model": model, "stream": False, "think": False, "options": options,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You generate wiki FILE blocks. Reply briefly."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
}
|
||||
t0 = time.time()
|
||||
code, body = http_json(f"{base}/api/chat", payload, timeout)
|
||||
dt = time.time() - t0
|
||||
out = {"path": "native", "http": code, "secs": round(dt, 1), "max_tokens": max_tokens, "num_ctx": num_ctx}
|
||||
if isinstance(body, dict):
|
||||
out.update(prompt_eval_count=body.get("prompt_eval_count"), eval_count=body.get("eval_count"),
|
||||
done_reason=body.get("done_reason"), error=body.get("error"))
|
||||
else:
|
||||
out["error"] = str(body)[:500]
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Debug Ollama 'too many tokens' for LLM Wiki generation.")
|
||||
ap.add_argument("--base", default="http://localhost:11434", help="Ollama base URL")
|
||||
ap.add_argument("--model", default="gemma4:12b")
|
||||
ap.add_argument("--timeout", type=float, default=180.0)
|
||||
ap.add_argument("--prompt-chars", type=int, default=60_000,
|
||||
help="approx prompt size in characters (tokens ~= chars/5)")
|
||||
ap.add_argument("--prompt-file", help="use this file's contents as the user prompt instead of filler")
|
||||
ap.add_argument("--max-tokens", type=int, default=None,
|
||||
help="override; default = app's ladder for --max-context-chars")
|
||||
ap.add_argument("--max-context-chars", type=int, default=204_800,
|
||||
help="the app's maxContextSize (chars); picks max_tokens via the app ladder")
|
||||
ap.add_argument("--num-ctx", type=int, default=None,
|
||||
help="native path only: num_ctx to allocate (the lever /v1 lacks)")
|
||||
ap.add_argument("--native", action="store_true", help="use native /api/chat instead of /v1")
|
||||
ap.add_argument("--sweep", action="store_true",
|
||||
help="sweep prompt sizes (2k,20k,60k,120k,200k chars) at the app's max_tokens")
|
||||
args = ap.parse_args()
|
||||
|
||||
max_tokens = args.max_tokens if args.max_tokens is not None else app_generation_max_tokens(args.max_context_chars)
|
||||
print(f"App ladder: maxContextSize={args.max_context_chars} chars -> max_tokens={max_tokens}\n")
|
||||
|
||||
show_model(args.base, args.model, args.timeout)
|
||||
|
||||
prompt = open(args.prompt_file, encoding="utf-8").read() if args.prompt_file else None
|
||||
|
||||
if args.sweep:
|
||||
print("── sweep (each row is one generation; watch where http!=200 / error appears) ──")
|
||||
for pc in [2_000, 20_000, 60_000, 120_000, 200_000]:
|
||||
p = prompt or make_prompt(pc)
|
||||
r = (call_native(args.base, args.model, p, max_tokens, args.num_ctx, args.timeout)
|
||||
if args.native else call_openai(args.base, args.model, p, max_tokens, args.timeout))
|
||||
print(f" prompt~{pc:>7}c {json.dumps(r)}")
|
||||
return 0
|
||||
|
||||
p = prompt or make_prompt(args.prompt_chars)
|
||||
r = (call_native(args.base, args.model, p, max_tokens, args.num_ctx, args.timeout)
|
||||
if args.native else call_openai(args.base, args.model, p, max_tokens, args.timeout))
|
||||
print("── single request ──")
|
||||
print(json.dumps(r, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||