Generated banner for AI Workspace Hub
Generated logo for AI Workspace Hub

AI Workspace Hub

Vault template

First committed by Benboerba620

Published in Benboerba620/ai-workspace-hub. Publisher page

83 GitHub stars

What it is

All-in-one AI 研究工作台:把 Codex / Claude Code / Cursor 变成有长期记忆的研究系统。wiki 摄入、研究闭环、快速筛选、假设追踪、播客、日报六大能力,零 API key 可跑基座。
— the description by Benboerba620

Summary from the brain’s metadata

All-in-one AI 研究工作台:把 Codex / Claude Code / Cursor 变成有长期记忆的研究系统。wiki 摄入、研究闭环、快速筛选、假设追踪、播客、日报六大能力,零 API key 可跑基座。 Turn Codex, Claude Code, Cursor, and Cline into a persistent research workspace.

How it works

Memory model: Markdown vault

AutoGineer overview, from the brain’s own files

Memory model: Markdown vault. Folders: .github (agent config, 1 file); docs (docs, 2 files); examples (examples, 22 files); inbox (raw sources, 3 files); tests (tests, 18 files); tools (scripts, 39 files); wiki (wiki, 15 files). The agent reads hypothesis/, wiki/explorations/.

Folders
FolderRoleFiles
.githubagent-config1
docsdocs2
examplesexamples22
inboxraw-sources3
teststests18
toolsscripts39
wikiwiki15
configfolder2
evidencefolder1
hypothesisfolder1
outputfolder7
portfoliofolder2
systemfolder52
workspacefolder11

The agent reads

Requirements

Runtimes: Python

Works with: Claude Code, OpenAI Codex, GitHub Copilot, Cursor, Kiro, OpenCode, Amp, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. Clone the repository at the commit AutoGineer scanned:

    git clone https://github.com/Benboerba620/ai-workspace-hub.git && cd ai-workspace-hub && git checkout e01a41a06886c6d59f714e6c133004d241ea91ac
  2. Claude Code reads CLAUDE.md from this folder automatically.

  3. cd ai-workspace-hub && claude

  4. AutoGineer scanned these files at commit e01a41a.

  5. First committed by Benboerba620. Published in Benboerba620/ai-workspace-hub. Licence: MIT License.

Usage

AutoGineer’s guidance for this type of brain

Copy the vault into a folder, open it in your notes app, and point your agent at its instruction file: the agent reads and writes the notes as its memory.

Documentation by Benboerba620

<div align="center">

AI Workspace Hub

Turn Codex, Claude Code, Cursor, and Cline into a persistent research workspace.

把 AI 编程助手变成一套能长期记忆、持续研究、自动沉淀的工作台。

Image: CI Image: Python 3.10+ Image: GitHub stars Image: License

30 秒上手 · 六大能力 · 免费数据源 · 第一周怎么用 · 故障排查

Image: 完整流程演示:一句话安装 → Core Mode READY → 首次股票研究 → 研究偏好 → 主题假设 → 日报跟踪

<sub>演示动图按安装协议与 <a href="START-HERE.md">首次股票研究流程</a>脚本化生成(<a href="docs/gen_demo_gif.py">生成脚本</a>),非实录。</sub>

</div>


最近更新(2026-09-30):当前 Agent 可直接完成材料摘要与标签;日报显示数据来源、日期和缺失状态;安装附带免费数据源指南,daily-watch 与 podcast 均已内置。

对话很聪明,但工作流没有记忆;今天做完,明天又从头解释。

AI Workspace Hub 解决这个问题。六大能力开箱即用——核心工作流不需要 API key。

📖 想先了解设计思路?看作者的公众号长文:《从0构建 AI 协作系统(一):从最小可运行的 MVP 开始》


⚡ 30 秒上手

把下面这句话发给你的 AI agent(Codex / Claude Code / Cursor / Cline):

帮我按这个协议安装 AI Workspace Hub:
https://raw.githubusercontent.com/Benboerba620/ai-workspace-hub/main/INSTALL-FOR-AI.md
抓不到协议全文就先 git clone 本仓库,再读其中的 INSTALL-FOR-AI.md 逐字执行。

Agent 会问你 4 个问题(路径、用途、现有 wiki、是否使用 Obsidian),然后自动创建完整工作区。装好后,先用样例确认安装:

把 inbox/first-note.md 整理进 personal wiki。

预期:agent 读 AGENTS.md → 按 wiki/_schema.md 整理 → 写入 wiki/sources/ → 在 active-context.md 记录进度。

不需要 API key,不需要联网,不需要写任何代码。

样例通过后,开始第一份真实股票研究:

研究一家公司,同时帮我建立第一版研究偏好。
公司是:____;我现在关注它是因为:____。

这次会同时产出研究报告、研究偏好 v0.1、可验证假设和后续跟踪指标。以后研究其他公司会复用并继续校准这套方法,而不是每次从空白开始。

<details> <summary>还没有 AI agent?先花 2 分钟装一个</summary>

任选其一,都有免费或试用档:

  • Claude Code(推荐):先装 Node.js,然后终端运行 npm install -g @anthropic-ai/claude-code,在任意目录输入 claude 登录 Claude 账号。

  • Codex CLI:npm install -g @openai/codex,输入 codex 登录 ChatGPT 账号。

  • Cursor:去 cursor.com 下载安装,打开一个文件夹后用内置对话。

装好后回到上面,把那句话发给它就行。

</details>

<details> <summary>更习惯手动 clone?</summary>

git clone https://github.com/Benboerba620/ai-workspace-hub.git my-ai-workspace
cd my-ai-workspace

用 AI agent 打开这个目录,直接试跑 inbox/first-note.md → wiki。

验证安装状态:

python3 system/scripts/check_workspace.py    # Windows: python system/scripts/check_workspace.py

Core Mode 显示 READY 即可开始使用。

</details>


📈 股票研究主流程

首次研究引导 → 研究偏好 v0.1 → 研究结论 → 可验证假设
      → 跟踪指标 → daily-watch 日报 → 独立证据 → 假设复盘 → 长期知识

START-HERE.md 是安装后的用户首页。信息摄入只是输入环节;系统的主线是让研究结论进入假设,并通过日报持续验证。

每份研究、假设和证据都有稳定 ID;当前状态只保存在 Markdown frontmatter。需要用户确认的 wiki 沉淀、假设调整、股票池新增和研究偏好变化会进入持久待确认队列。直接说:

看看我的研究系统现在有什么需要处理。

Agent 会汇总进行中的研究、待复盘证据、到期假设和待确认动作,并给出下一步。

🧩 六大能力

能力你可以怎么说写入哪里API key
📚wiki"把这篇文章整理进知识库"wiki/推荐(自动标签)
🔬research"帮我研究一下某公司 / 某行业"output/research/不需要
🔍screen"帮我筛选 AI 产业链股票"output/screen/可选
📊daily-watch"生成今天的盯盘日报"output/daily-watch/ + evidence/可选
🧪hypothesis"把这个投资假设建档并追踪"hypothesis/不需要
🎙️podcast"扫一下这几个播客并写进 wiki"wiki/sources/ + output/pod2wiki/当前 Agent 无需独立 Key;后台摘要需 Key

daily-watch 和 podcast 已经内置在 Hub 中,不需要再单独安装。 后台自动标签、独立运行的播客摘要和部分行情源需要按需填写 API key;交互整理可由当前 Agent 完成。

零 key 可用:wiki 可由当前 Agent 摄入和补齐有依据的标签,不调用独立模型 API;research、hypothesis、screen(websearch 模式)、daily-watch(报告骨架 + 美股降级源)照常可用。

默认建议配置:先按 免费数据源接入指南 选择市场——美股可零 Key 使用 Nasdaq,A 股可从 Tushare 的基础日线档起步,yfinance 可作为个人研究备用;再按需配置 LLM、FMP 或 Longbridge Skill。


🔄 工作流程

flowchart LR
    IN["inbox / web"] --> TAG["统一自动标签"]
    TAG --> KB["wiki 知识库"]
    KB --> R["research R*"]
    R --> H["hypothesis H*"]
    H --> W["唯一执行股票池"]
    W --> D["daily-watch 日报"]
    D --> E["evidence E-*"]
    E -->|"用户确认后复盘"| H
    R --> Q["review-queue Q-*"]
    H --> Q
    Q -->|"确认后执行"| KB
    S["workspace status"] -.-> R
    S -.-> H
    S -.-> E
    S -.-> Q

核心不是"文件夹长什么样",而是:agent 进入目录后知道先读什么、做研究时先查本地 wiki、输出时事实与推测分开、证据和判断不混写、待确认动作不会随对话消失、明天继续时能从断点接上。

wiki 变大后也不会每次全量加载。每次研究先运行 Research Preflight:按 rule -> pattern -> exploration 加载相关可复用知识,同时扫描 entities / concepts / sources,只返回少量命中页面、命中理由和过期/冲突提示,再决定哪些问题需要联网。扫描不调用大模型,并生成可检查的回执。知识按 source -> exploration -> pattern -> rule 逐级提炼;每次晋级、降级或退役都保留用户确认、复审日期和失效条件。

材料进入 wiki 时按同一标签契约补齐 domain / ticker / concepts / related / entity_salience / tags。交互模式由当前 Agent 处理,后台模式使用统一打标器;历史补标默认只预览,已有人工字段不会被覆盖。

用真实案例理解 Wiki 晋级

产能扩张周期见顶案例 把一条真实研究判断拆成 4 个 Source、2 个已提炼 Exploration 和 1 张 draft Pattern:光伏与锂电是产生该 Pattern 的母案例,因此不能再次冒充独立确认,primary_confirmations 保持为 0。日常研究默认不会加载这张草稿卡;复盘模式可以找到它,未来经过两次新的独立事前验证并由用户确认后,才允许变成 active Pattern。累计三次独立确认后,再讨论是否形成可执行 Rule。

案例目录可以独立运行 summary 和 load,仓库测试同时守住“母案例不计确认、draft 不进入日常加载、满足门槛后才可晋级”三条边界。

系统不在某个模型里,而在这套文件协议里。谁读懂这套协议,谁就接上你的工作流。


⚙️ Core Mode / Enhanced Mode

模式API key能做什么适合
Core不需要wiki 摄入(无脚本自动标签)、研究草稿、快速筛选、假设建档、断点续传临时未配置 API 时
Enhanced按市场选填wiki 自动标签、播客摘要、行情日报、A 股 / 多市场数据、自动监控默认日常使用

推荐路径:安装后运行 check_workspace.py → 按 DATA-SOURCES.md 选择免费或付费数据源 → 只填写实际需要的 key。暂时没有 key 时仍可先跑 Core Mode,美股基础行情还可走 Nasdaq 无 Key 源。


📅 第一周怎么用

天数动作目标
Day 1用当前 Agent 跑通 inbox/first-note.md → wiki;后台自动化再配置 Key确认摘要、自动标签和分类都能工作
Day 2放入 3-5 条真实材料建立第一批知识库
Day 3让 agent 研究一个公司 / 行业生成第一篇结构化报告
Day 4check_workspace.py,按免费数据源指南选择 Nasdaq / Tushare / yfinance 等增强行情能力
Day 5做一次主题筛选形成候选池
Day 6扫一次播客 / 博客建立外部信息流
Day 7运行结构体检,删掉没用规则防止系统变胖

适合谁?

适合:投资研究员、个人投资者、内容创作者、AI power user、Markdown / Obsidian 用户、想做长期项目而不是一次性对话的人。

不适合:想要 GUI 应用、自动交易系统,或不愿意用 Markdown 管理知识的人。


<details> <summary><b>📂 目录结构</b></summary>

ai-workspace-hub/
├── AGENTS.md                 # Codex 入口路由
├── CLAUDE.md                 # Claude Code 入口路由
├── INSTALL-FOR-AI.md         # 交给 AI agent 的安装协议
├── SMOKE-TEST.md             # 冒烟测试
├── ARCHITECTURE.md           # 架构说明
├── TROUBLESHOOTING.md        # 故障排查
├── workspace/                # 配置、断点、看板与归档
│   ├── monitoring/           # 用户阅读的监控看板
│   └── archive/              # 退出主流程的历史材料
├── inbox/                    # 临时输入材料
├── wiki/                     # personal wiki(exploration / pattern / rule)
├── output/                   # 研究、筛选、播客和日报输出
│   └── daily-watch/          # 日报归档
├── hypothesis/               # 投资假设和复盘
├── evidence/                 # 独立证据账本
├── portfolio/                # 交易记录
├── config/                   # 用户配置,不入 git
├── tools/                    # podcast / daily-watch 工具
└── system/                   # skills / integrations / scripts / templates

</details>

<details> <summary><b>🗄️ 数据源与边界</b></summary>

名称类型用途是否内置
Nasdaq无 key 降级源美股基础行情是
Finnhub免费账户 / 付费美股行情备选;免费端点和额度以官方账户为准是,需 key
EODHD免费小额度 / 付费港股、韩国、芬兰等最近 EOD 备用;免费账户当前 20 次/日,不是实时价是,需 key
yfinance无 Key多市场个人研究备用;非官方 Yahoo SDK,需额外安装是,需开启
tushare积分 / 单独权限A 股行情;当前 120 积分档可取非复权日线是,需 token
FMP免费 Basic / 付费免费档适合 EOD/profile 测试;全球覆盖和高级数据取决于套餐是,需 key
Longbridge Skill外部 Agent 扩展多市场查询、筛选、研究否,独立安装授权
websearchAgent 能力补充新闻、资料、公司信息取决于 agent

重要边界:本项目不做自动交易,不承诺数据源永远免费,不把 AI 输出包装成投资建议。缺少 API key 时优雅降级,不会让用户误以为工作区坏了。

</details>

<details> <summary><b>💻 常用命令</b></summary>

python3 system/scripts/check_workspace.py          # 总检查
python3 system/scripts/workspace_status.py         # 当前研究、证据和待办
python3 system/scripts/research_preflight.py --root . --context "你的研究问题和关键词" --research-id R-... --research-file output/research/报告.md --record  # 扫描 Wiki 并更新研究报告
python3 system/scripts/knowledge_lifecycle.py --root . summary  # 知识状态、到期复审和晋级候选
python3 system/scripts/knowledge_lifecycle.py --root . rebuild-index --apply  # 重建三个短索引
python3 system/scripts/review_queue.py list        # 查看持久待确认队列
python3 system/scripts/wiki_tagger.py --root . backfill wiki  # 预览历史 wiki 补标
python3 -m unittest discover -s tests -v            # 运行测试
python3 tools/daily-watch/scripts/check_setup.py --init  # 初始化日报配置
python3 tools/podcast/scripts/fetch_podcasts.py --help   # 播客工具帮助

Python 工具需要 3.10+。Windows 用户用 python 代替 python3,或 py -3 指定版本。

</details>

<details> <summary><b>❓ 新手常见问题</b></summary>

Q:我不会写代码,能用这个项目吗? 可以。Core Mode 只需要把文本放进 inbox/,然后用自然语言告诉 AI agent 做什么。

Q:一定要用 Codex / Claude Code 吗? 不一定。任何能读写文件的 AI agent 都可以。Codex 和 Claude Code 效果最好,Cursor 和 Cline 也可以。

Q:安装 Hub 后,还要单独安装 daily-watchlist 或 pod2wiki 吗? 不需要。两套工具及其配置模板已经包含在 tools/daily-watch/ 和 tools/podcast/;你只需要按用途安装可选依赖,并按 免费数据源接入指南 选择是否配置 key。

Q:wiki 越来越大怎么办? 用 Obsidian 打开 wiki/ 目录做可视化管理,或让 AI agent 帮你整理归档。

Q:API key 会不会泄露? config/ 已被 .gitignore 排除,不会被 git 提交。

Q:自动标签会把什么发给大模型? 会把当前 wiki 页面的 frontmatter 和正文发送给你在 config/pod2wiki.env 选择的模型服务商,只用于返回结构化标签。敏感材料应先确认所选服务商的数据政策,或跳过自动标签。

Q:可以和 Obsidian 一起用吗? 可以。安装时选择 Obsidian(或在已有 vault 中安装),Hub 会增加 reading-hub.md 和 reading-hub.base,用 9 个动态视图汇总待读内容;可直接在表格中填写 已读 / 精读 / 跳过,并优先按资料自身日期而不是本机文件时间排序。默认不安装,也不影响非 Obsidian 用户。需要 Obsidian 1.9+ 的 Bases 功能。

更多问题见 TROUBLESHOOTING.md。

</details>


Roadmap

  • 更多真实研究工作流样例

  • 更强的 screen 预设

  • 更完善的 daily-watch 降级策略

  • 证据来源质量分级和跨来源事件去重

  • 知识索引的自动健康检查与过期提示


Contributing

欢迎提 issue / PR,尤其是新 agent 适配、研究报告模板、数据源接入、真实使用摩擦点、README / 教程 / 安装流程改进。


Star History

如果这个项目对你有帮助,欢迎点一下 ⭐ Star。它会帮助更多需要"长期 AI 工作流"的人发现这个项目,也会让我知道哪些方向值得继续做。

Image: Star History Chart


Disclaimer

AI Workspace Hub 是个人研究与知识管理工具,不构成投资建议。所有市场数据、公司信息、财务数字、新闻和监管信息都应以原始来源为准。

<!-- 文件说明:项目首页、价值介绍和快速上手。 -->

为什么已经在 Agent 中使用,还可能需要模型 Key?

交互整理可以由当前 Agent 完成:播客工具使用 --agent-handoff 准备原文与待处理队列,Agent 阅读原文后完成摘要和标签,不要求另配模型 Key。脚本在后台独立生成摘要才需要模型 API。两种方式的步骤与完成标准见 Podcast 流程。

system/scripts/research_preflight.py

Shown as plain text.

#!/usr/bin/env python3
"""Build a compact, verifiable local-knowledge package before research."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import time
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import knowledge_lifecycle as lifecycle  # noqa: E402

for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

WIKI_SECTIONS = ("entities", "concepts", "sources")
SECTION_TYPES = {"entities": "entity", "concepts": "concept", "sources": "source", "raw": "raw"}
KNOWLEDGE_SECTIONS = {
    "exploration": "explorations",
    "pattern": "patterns",
    "rule": "rules",
}
REVIEW_STATUSES = {
    "weakened",
    "invalidated",
    "retired",
    "deprecated",
    "archived",
    "stale",
    "superseded",
}
STOP_TERMS = {
    "研究",
    "分析",
    "公司",
    "行业",
    "相关",
    "问题",
    "判断",
    "research",
    "analysis",
    "company",
}


def find_workspace_root(start: Path) -> Path:
    return lifecycle.find_workspace_root(start)


def resolve_wiki_root(root: Path, requested: Path | None = None) -> Path:
    if requested is not None:
        candidate = requested.expanduser()
    else:
        candidate = Path("wiki")
        config = root / "workspace" / "workspace-config.md"
        if config.is_file():
            content = config.read_text(encoding="utf-8-sig")
            match = re.search(
                r"(?m)^\s*-\s*wiki_root:\s*`?([^`\r\n]+?)`?\s*$", content
            )
            if match:
                candidate = Path(match.group(1).strip().strip('"\''))
    if not candidate.is_absolute():
        candidate = root / candidate
    return candidate.resolve()


def display_path(path: Path, root: Path) -> str:
    try:
        return lifecycle.relative(path, root)
    except ValueError:
        return str(path)


def as_list(value: Any) -> list[str]:
    return lifecycle.as_list(value)


def normalize(value: str) -> str:
    return lifecycle.normalized(value)


def query_terms(context: str, explicit: list[str]) -> list[str]:
    terms = [item.strip() for item in explicit if item.strip()]
    terms.extend(
        item.strip()
        for item in re.split(r"[\s,,。;;::/|、()()]+", context)
        if item.strip()
    )
    terms.extend(re.findall(r"\b[A-Za-z]{1,8}(?:\.[A-Za-z]{1,4})?\b", context))
    result: list[str] = []
    seen: set[str] = set()
    for term in terms:
        key = normalize(term)
        if len(key) < 2 or key in STOP_TERMS or key in seen:
            continue
        seen.add(key)
        result.append(term)
    return result


def extract_title(meta: dict[str, Any], content: str, path: Path) -> str:
    if meta.get("title"):
        return str(meta["title"])
    match = re.search(r"(?m)^#\s+(.+?)\s*$", content)
    return match.group(1).strip() if match else path.stem


def strip_frontmatter(content: str) -> str:
    return re.sub(r"^---\s*\n.*?\n---\s*(?:\n|$)", "", content, count=1, flags=re.DOTALL)


def make_snippet(content: str, terms: list[str], limit: int = 260) -> str:
    body = strip_frontmatter(content)
    lines = [line.strip(" #>-\t") for line in body.splitlines() if line.strip()]
    normalized_terms = [normalize(term) for term in terms]
    for line in lines:
        normalized_line = normalize(line)
        if any(term in normalized_line for term in normalized_terms if term):
            return line[:limit] + ("…" if len(line) > limit else "")
    if not lines:
        return ""
    text = " ".join(lines[:3])
    return text[:limit] + ("…" if len(text) > limit else "")


def substantive_counter_section(content: str) -> bool:
    pattern = re.compile(
        r"(?ims)^##\s*(?:反方证据|反例|矛盾|争议|counter[- ]?evidence|counterexamples?)\s*$"
        r"(.*?)(?=^##\s|\Z)"
    )
    for match in pattern.finditer(content):
        body = re.sub(r"<!--[\s\S]*?-->", "", match.group(1))
        body = re.sub(r"[\s#>*_`-]", "", body)
        if body:
            return True
    return False


def page_flags(section: str, meta: dict[str, Any], content: str) -> list[str]:
    flags: list[str] = []
    status = str(meta.get("status") or "").lower()
    if status in REVIEW_STATUSES:
        flags.append(f"状态需复审: {status}")
    if lifecycle.is_overdue(meta.get("review_due")):
        flags.append(f"复审已到期: {meta.get('review_due')}")
    if substantive_counter_section(content):
        flags.append("存在已填写的反方/冲突章节")
    if section == "sources" and not (meta.get("source_url") or meta.get("source_path")):
        flags.append("来源字段不完整")
    if section in KNOWLEDGE_SECTIONS:
        if not meta.get("id"):
            flags.append("缺少稳定知识 ID")
        if not meta.get("review_due"):
            flags.append("缺少复审日期")
        if not as_list(meta.get("recall_signals")):
            flags.append("缺少召回信号")
    return flags


def score_wiki_page(
    *,
    meta: dict[str, Any],
    title: str,
    path: Path,
    content: str,
    context: str,
    terms: list[str],
) -> tuple[int, list[str]]:
    score = 0
    reasons: list[str] = []
    context_norm = normalize(context)
    title_norm = normalize(title)
    filename_norm = normalize(path.stem)
    body_norm = normalize(strip_frontmatter(content))

    weighted_fields = (
        ("ticker", 14, "标的"),
        ("concepts", 9, "概念"),
        ("tags", 7, "标签"),
        ("related", 6, "关联页"),
        ("domain", 3, "领域"),
    )
    for field, weight, label in weighted_fields:
        hits = []
        for value in as_list(meta.get(field)):
            value_norm = normalize(value)
            if value_norm and value_norm in context_norm:
                hits.append(value)
        if hits:
            score += weight * len(hits)
            reasons.append(f"{label}: {'、'.join(hits[:2])}")

    title_hits = [term for term in terms if normalize(term) in title_norm or normalize(term) in filename_norm]
    if title_hits:
        score += 10 * len(title_hits)
        reasons.append("标题: " + "、".join(title_hits[:3]))
    elif len(title_norm) >= 3 and title_norm in context_norm:
        score += 12
        reasons.append(f"标题直接命中: {title}")

    body_hits = [term for term in terms if normalize(term) in body_norm]
    if body_hits:
        score += 2 * min(len(body_hits), 5)
        reasons.append("正文: " + "、".join(body_hits[:3]))

    summary_norm = normalize(str(meta.get("summary") or meta.get("description") or ""))
    summary_hits = [term for term in terms if normalize(term) in summary_norm]
    if summary_hits:
        score += 5 * len(summary_hits)
        reasons.append("摘要: " + "、".join(summary_hits[:3]))
    return score, reasons


def iter_knowledge_pages(root: Path, wiki_root: Path) -> list[dict[str, Any]]:
    pages: list[dict[str, Any]] = []
    locations: list[tuple[str, Path]] = [
        (kind, wiki_root / section) for kind, section in KNOWLEDGE_SECTIONS.items()
    ]
    locations.append(("pattern", root / "workspace/patterns"))
    for knowledge_type, folder in locations:
        if not folder.is_dir():
            continue
        for path in sorted(folder.glob("*.md")):
            if path.name.startswith("_") or path.name.lower() == "readme.md":
                continue
            meta, content, error = lifecycle.read_page(path)
            pages.append(
                {
                    "type": knowledge_type,
                    "path": path,
                    "relative_path": display_path(path, root),
                    "meta": meta,
                    "content": content,
                    "error": error,
                }
            )
    return pages


def load_reusable_knowledge(
    root: Path,
    wiki_root: Path,
    context: str,
    *,
    limit: int,
    include_review: bool,
    pages: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
    matches: list[dict[str, Any]] = []
    for page in pages if pages is not None else iter_knowledge_pages(root, wiki_root):
        meta = page["meta"]
        status = str(meta.get("status") or "")
        if not include_review and status not in lifecycle.DEFAULT_LOAD_STATUSES[page["type"]]:
            continue
        score, reasons = lifecycle.score_page(page, context)
        if score <= 0:
            continue
        matches.append(
            {
                "id": str(meta.get("id") or page["path"].stem),
                "type": page["type"],
                "title": extract_title(meta, page["content"], page["path"]),
                "status": status,
                "summary": str(meta.get("summary") or ""),
                "scope": str(meta.get("scope") or ""),
                "review_due": str(meta.get("review_due") or ""),
                "failure_signals": as_list(
                    meta.get("failure_signals") or meta.get("invalidation_signals")
                ),
                "path": page["relative_path"],
                "score": score,
                "matched_by": reasons,
                "flags": page_flags(page["type"], meta, page["content"]),
                "content": page["content"],
            }
        )
    type_order = {"rule": 0, "pattern": 1, "exploration": 2}
    matches.sort(key=lambda item: (type_order[item["type"]], -item["score"], item["id"]))
    return matches[:limit]


def scan_wiki(
    root: Path,
    wiki_root: Path,
    context: str,
    terms: list[str],
    *,
    sections: tuple[str, ...],
    limit: int,
) -> tuple[list[dict[str, Any]], dict[str, int]]:
    matches: list[dict[str, Any]] = []
    counts = {section: 0 for section in sections}
    for section in sections:
        folder = wiki_root / section
        if not folder.is_dir():
            continue
        for path in sorted(folder.rglob("*.md")):
            if path.name.startswith("_") or path.name.lower() == "readme.md":
                continue
            counts[section] += 1
            meta, content, error = lifecycle.read_page(path)
            if error and not content:
                continue
            title = extract_title(meta, content, path)
            score, reasons = score_wiki_page(
                meta=meta,
                title=title,
                path=path,
                content=content,
                context=context,
                terms=terms,
            )
            if score <= 0:
                continue
            matches.append(
                {
                    "type": SECTION_TYPES[section],
                    "title": title,
                    "status": str(meta.get("status") or ""),
                    "summary": str(meta.get("summary") or meta.get("description") or ""),
                    "path": display_path(path, root),
                    "score": score,
                    "matched_by": reasons,
                    "snippet": make_snippet(content, terms),
                    "flags": page_flags(section, meta, content),
                    "content": content,
                }
            )
    section_order = {"entity": 0, "concept": 1, "source": 2, "raw": 3}
    matches.sort(key=lambda item: (-item["score"], section_order.get(item["type"], 9), item["path"]))
    return matches[:limit], counts


def build_preflight(
    root: Path,
    *,
    context: str,
    research_id: str = "",
    wiki_root: Path | None = None,
    explicit_terms: list[str] | None = None,
    tickers: list[str] | None = None,
    domains: list[str] | None = None,
    knowledge_limit: int = 8,
    wiki_limit: int = 12,
    include_review: bool = False,
    include_raw: bool = False,
) -> dict[str, Any]:
    started = time.perf_counter()
    root = find_workspace_root(root)
    resolved_wiki = resolve_wiki_root(root, wiki_root)
    additions = [*(explicit_terms or []), *(tickers or []), *(domains or [])]
    enriched_context = " ".join([context, *additions]).strip()
    terms = query_terms(enriched_context, additions)
    knowledge_pages = iter_knowledge_pages(root, resolved_wiki)
    knowledge = load_reusable_knowledge(
        root,
        resolved_wiki,
        enriched_context,
        limit=max(1, knowledge_limit),
        include_review=include_review,
        pages=knowledge_pages,
    )
    sections = WIKI_SECTIONS + (("raw",) if include_raw else ())
    wiki_matches, scanned = scan_wiki(
        root,
        resolved_wiki,
        enriched_context,
        terms,
        sections=sections,
        limit=max(1, wiki_limit),
    )
    digest = hashlib.sha256(
        (research_id + enriched_context + str(resolved_wiki)).encode("utf-8")
    ).hexdigest()[:8]
    prefix = re.sub(r"[^A-Za-z0-9-]+", "-", research_id).strip("-") or date.today().strftime("%Y%m%d")
    flags = [
        {"path": item["path"], "flags": item["flags"]}
        for item in [*knowledge, *wiki_matches]
        if item["flags"]
    ]
    return {
        "id": f"PF-{prefix}-{digest}",
        "research_id": research_id or None,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "context": enriched_context,
        "terms": terms,
        "wiki_root": str(resolved_wiki),
        "scanned": {
            "knowledge": len(knowledge_pages),
            **scanned,
        },
        "knowledge": knowledge,
        "wiki_matches": wiki_matches,
        "review_flags": flags,
        "elapsed_ms": round((time.perf_counter() - started) * 1000, 2),
    }


def yaml_list(values: list[str]) -> str:
    return "[" + ", ".join(json.dumps(value, ensure_ascii=False) for value in values) + "]"


def render_receipt(result: dict[str, Any]) -> str:
    knowledge_ids = [item["id"] for item in result["knowledge"]]
    wiki_paths = [item["path"] for item in result["wiki_matches"]]
    lines = [
        "---",
        f"id: {result['id']}",
        "type: research-preflight",
        f"research_id: {result['research_id'] or ''}",
        f"created_at: {result['created_at']}",
        f"context: {json.dumps(result['context'], ensure_ascii=False)}",
        f"knowledge_used: {yaml_list(knowledge_ids)}",
        f"wiki_pages_loaded: {yaml_list(wiki_paths)}",
        "---",
        "",
        f"# Research Preflight: {result['research_id'] or result['id']}",
        "",
        f"> 本地扫描耗时 {result['elapsed_ms']} ms;只记录命中和风险提示,不代表结论已验证。",
        "",
        "## 扫描范围",
        "",
        f"- Wiki:`{result['wiki_root']}`",
        "- 扫描:" + " / ".join(f"{key} {value}" for key, value in result["scanned"].items()),
        "- 检索词:" + ("、".join(result["terms"]) or "无"),
        "",
        "## 可复用知识",
        "",
    ]
    if result["knowledge"]:
        for item in result["knowledge"]:
            lines.append(
                f"- `{item['id']}` [{item['type']}] {item['title']} | {item['path']} | "
                f"命中:{';'.join(item['matched_by'])}"
            )
    else:
        lines.append("- 未命中可复用 Rule / Pattern / Exploration。")
    lines.extend(["", "## 相关 Wiki 页面", ""])
    if result["wiki_matches"]:
        for item in result["wiki_matches"]:
            lines.append(
                f"- [{item['type']}] {item['title']} | {item['path']} | "
                f"命中:{';'.join(item['matched_by'])} | 摘要:{item['summary'] or item['snippet'] or '-'}"
            )
    else:
        lines.append("- 未命中 Entity / Concept / Source 页面。")
    lines.extend(["", "## 复审提示", ""])
    if result["review_flags"]:
        for item in result["review_flags"]:
            lines.append(f"- {item['path']}:{';'.join(item['flags'])}")
    else:
        lines.append("- 未发现显式过期、弱化或反方章节提示。")
    lines.extend(
        [
            "",
            "## 加载回执",
            "",
            f"Wiki check: 扫描 {sum(result['scanned'].values())} 篇,命中知识卡 "
            f"{len(result['knowledge'])} 张、普通 Wiki {len(result['wiki_matches'])} 篇;"
            f"复审提示 {len(result['review_flags'])} 条。",
            "",
        ]
    )
    return "\n".join(lines)


def save_receipt(root: Path, result: dict[str, Any], requested: Path | None = None) -> Path:
    if requested is None:
        filename = re.sub(r"[^A-Za-z0-9._-]+", "-", str(result["research_id"] or result["id"]))
        path = root / "output" / "research" / "preflight" / f"{filename}.md"
    else:
        path = requested if requested.is_absolute() else root / requested
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(render_receipt(result), encoding="utf-8")
    return path


def update_research_file(root: Path, requested: Path, result: dict[str, Any]) -> Path:
    path = requested if requested.is_absolute() else root / requested
    if not path.is_file():
        raise ValueError(f"research file does not exist: {display_path(path, root)}")
    meta, content, error = lifecycle.read_page(path)
    if error:
        raise ValueError(f"invalid research file: {error}")
    research_id = str(result.get("research_id") or "")
    file_id = str(meta.get("id") or "")
    if file_id and research_id and file_id != research_id:
        raise ValueError(f"research id mismatch: file={file_id}, preflight={research_id}")
    content = lifecycle.replace_frontmatter_scalar(content, "preflight_id", str(result["id"]))
    content = lifecycle.replace_frontmatter_scalar(
        content, "knowledge_used", yaml_list([item["id"] for item in result["knowledge"]])
    )
    content = lifecycle.replace_frontmatter_scalar(
        content,
        "wiki_pages_loaded",
        yaml_list([item["path"] for item in result["wiki_matches"]]),
    )
    path.write_text(content, encoding="utf-8")
    return path


def serializable_result(result: dict[str, Any], full: bool) -> dict[str, Any]:
    payload = json.loads(json.dumps(result, ensure_ascii=False))
    if not full:
        for item in [*payload["knowledge"], *payload["wiki_matches"]]:
            item.pop("content", None)
    return payload


def render_text(result: dict[str, Any], full: bool) -> str:
    lines = [render_receipt(result).rstrip()]
    if full:
        for item in [*result["knowledge"], *result["wiki_matches"]]:
            lines.extend(["", f"---\n\n## 全文:{item['title']}\n", item["content"].rstrip()])
    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=Path, default=Path.cwd())
    parser.add_argument("--wiki-root", type=Path)
    parser.add_argument("--context", required=True)
    parser.add_argument("--research-id", default="")
    parser.add_argument("--term", action="append", default=[])
    parser.add_argument("--ticker", action="append", default=[])
    parser.add_argument("--domain", action="append", default=[])
    parser.add_argument("--knowledge-limit", type=int, default=8)
    parser.add_argument("--wiki-limit", type=int, default=12)
    parser.add_argument("--include-review", action="store_true")
    parser.add_argument("--include-raw", action="store_true")
    parser.add_argument("--record", action="store_true")
    parser.add_argument("--save", type=Path)
    parser.add_argument("--research-file", type=Path)
    parser.add_argument("--full", action="store_true")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    if args.record and (not args.research_id or not args.research_file):
        print(
            "ERROR: --record 需要同时提供 --research-id 和 --research-file,"
            "以便生成可追踪回执并更新研究报告",
            file=sys.stderr,
        )
        return 2
    root = find_workspace_root(args.root)
    result = build_preflight(
        root,
        context=args.context,
        research_id=args.research_id,
        wiki_root=args.wiki_root,
        explicit_terms=args.term,
        tickers=args.ticker,
        domains=args.domain,
        knowledge_limit=args.knowledge_limit,
        wiki_limit=args.wiki_limit,
        include_review=args.include_review,
        include_raw=args.include_raw,
    )
    if args.research_file:
        try:
            research_path = update_research_file(root, args.research_file, result)
        except ValueError as exc:
            print(f"ERROR: {exc}", file=sys.stderr)
            return 2
        result["research_file"] = display_path(research_path, root)
    if args.record:
        result["knowledge_invocation_id"] = lifecycle.record_load(
            root, result["context"], result["knowledge"], args.research_id
        )
    saved = save_receipt(root, result, args.save) if args.record or args.save else None
    if saved:
        result["receipt_path"] = display_path(saved, root)
    if args.json:
        print(json.dumps(serializable_result(result, args.full), ensure_ascii=False, indent=2))
    else:
        print(render_text(result, args.full))
        if saved:
            print(f"回执:{display_path(saved, root)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
193 files · 8 not served here

Advantages

  • Local-first: the memory stays in files or a database on your own machine (a rule-derived fact from the brain’s files, evidence: README.md)
  • Plain markdown memory that you can read and edit yourself (a rule-derived fact from the brain’s files, evidence: wiki)
  • No API key is named in its files (a rule-derived fact from the brain’s files, evidence: README.md)
  • Open licence (MIT) (a rule-derived fact from the brain’s files)

Limitations

  • No ZIP download: install it from its source (a rule-derived fact from the brain’s files)

Security

Risk level: Low risk

This brain runs

Agent instructions that run or fetch something (24)

  • AGENTS.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • AGENTS.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • AGENTS.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • AGENTS.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • AGENTS.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • AGENTS.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • CLAUDE.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • CLAUDE.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • CLAUDE.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • CLAUDE.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • CLAUDE.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • CLAUDE.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • system/templates/AGENTS.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • system/templates/AGENTS.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • system/templates/AGENTS.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • system/templates/AGENTS.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • system/templates/AGENTS.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • system/templates/AGENTS.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • system/templates/CLAUDE.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • system/templates/CLAUDE.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • system/templates/CLAUDE.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • system/templates/CLAUDE.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • system/templates/CLAUDE.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • system/templates/CLAUDE.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • Low risk: network-instructions (AGENTS.md:52)
  • Low risk: network-instructions (CLAUDE.md:52)
  • Low risk: scripts-present (system/scripts/prune_active_context.py)
  • Low risk: network-instructions (system/templates/AGENTS.md:52)
  • Low risk: network-instructions (system/templates/CLAUDE.md:52)

Attribution and licence

First committed by
GitHub avatar of Benboerba620Benboerba620
Publisher
Benbor (Community)
Source
Benboerba620/ai-workspace-hub · commit e01a41a
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Contributors
Benboerba620

AutoGineer is not affiliated with or endorsed by Benboerba620.

Install from source

8 of its files are not served here, so there is no ZIP. Benboerba620/ai-workspace-hub at e01a41a