Compare commits

..

23 Commits

Author SHA1 Message Date
4745ce1f1c feat: add AI endpoint manager plugin with configuration and routing capabilities
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- Introduced `ai_endpoint_mgr` plugin to manage multiple AI provider endpoints.
- Added configuration reference documentation for `config.toml`.
- Implemented endpoint loading, active endpoint switching, and model mutation.
- Included error handling for missing endpoints and configuration failures.
- Developed unit tests covering various scenarios including error paths and concurrency.
2026-06-03 21:07:25 +08:00
28ae90a6cc W23: close mailroom metadata and network validation tests
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
- Refresh agents STATUS to W22.6 and exclude mailroom from metadata scans
- Add mailroom dispatch checklist and defensive rules
- Register F-23.D-1 and tag network input validation defense-in-depth
- Update network plugin tests for header length limits
- Fix LSP test metadata and remove orphan anthropic_internal.hpp

Verification:
- cmake --build build --config Release: 0 error, 0 warning
- ctest --test-dir build --output-on-failure: 10/10 passed
- ctest --test-dir build -R dstalk_smoke_test --output-on-failure: passed
- python scripts/check_agents_metadata.py --strict: passed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:45 +08:00
c0af9c65c7 feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`.
- Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web).
- Implemented configuration file discovery and service querying in `dstalk_frontend_init`.
- Added internal headers for LSP and Anthropic plugins to facilitate unit testing.
- Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
2026-06-01 08:51:40 +08:00
8faa02c3d5 W17: extract ai_common shared module + fix anthropic data race + brace bugs
- New plugins_upper/ai_common/ static library: shared PluginConfig, ToolCallAccum,
  StreamContext, secure_zero, extract_host_port, serialize_tool_calls, free_chat_result
- Refactored openai/anthropic plugins to use dstalk_ai:: namespace from ai_common
- Fixed anthropic g_config raw pointer → std::atomic (data race)
- Added SSE parse error counter with threshold abort (kMaxSseParseErrors=5)
- Fixed missing closing brace in both plugins' error-body catch block
- Updated test targets: ai_common include path + link, using namespace dstalk_ai
- plugin_loader_test: added stub_unreg + service_registry.cpp for unregister_service
- Includes pre-existing uncommitted changes from prior waves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 16:58:25 +08:00
ba7382db2a feat: add OpenAI-compatible AI provider plugin with SSE streaming support
- Implemented the OpenAI-compatible AI provider plugin, including configuration, chat, and chat_stream functionalities.
- Added support for SSE streaming and tool calls.
- Integrated Boost.JSON for JSON handling.
- Created CMake configuration for the plugin.
- Added error handling and logging throughout the plugin.
2026-05-31 05:37:04 +08:00
f6cb51b40a Add unit tests for OpenAI plugin and establish coding standards
- Introduced comprehensive unit tests for the OpenAI plugin, covering SSE parsing, sentinel matching, delta extraction, request building, and more.
- Created a new markdown file detailing coding and naming conventions for the dstalk project, including guidelines for comments, naming rules, code organization, and memory management practices.
2026-05-31 00:51:59 +08:00
f2da0f2ed4 Add metadata validation script and module documentation
- Introduced a new Python script `check_agents_metadata.py` for validating agent metadata, including YAML parsing, rating ranges, and cross-references.
- Added usage instructions and exit codes for the script.
- Created a new markdown file `模块目录和功能说明.md` to outline the directory structure and functionality of the modules.
- Added a text file `说明此文件不可AI修改.txt` to specify that certain files should not be modified by AI, including important information about the `dstalk` framework and its modules.
2026-05-31 00:00:58 +08:00
3cc9ee95e4 W22.2 补充:qa-xu profile 更新 + network_plugin_test 清理
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:27:23 +08:00
df3bf298ee W22: coverage metric + network tests + Tool stream feedback + stdin pipe + session path + dependency check (W22.1-W22.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
- W22.1: gcovr 覆盖率度量 + CI coverage job(40% 阈值 warning)
- W22.2: network_plugin 单元测试(parse_headers_json/extract_host_port/SSE/异常保护)
- W22.3: Tool Calling 流式反馈(chat_stream + "[工具调用]/[工具结果]" 状态行)
- W22.4: --prompt stdin pipe(--prompt - 从 stdin 读取)
- W22.5: session 路径健壮化(static 缓存 + mkdir + fallback)
- W22.6: 插件依赖拓扑静态校验(validate_dependencies 循环/缺失检测)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:21:24 +08:00
b2b381b9b3 W21: anthropic Stream+Tools + --prompt batch + sanitizer fix + plugin unit tests (W21.1-W21.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
- W21.1: ci-sanitize preset 独立 Linux-clang + ci-threadsan (TSan)
- W21.2: anthropic tool_use content_block 解析 + configure 缓存 tools_json
- W21.3: --prompt 非交互批处理模式
- W21.4: session auto-save 失败告警 + 当前目录 fallback
- W21.5: smoke 补 tool_calls 边界用例 4 块 12 断言
- W21.6: anthropic 11 块 78 CHECK + deepseek 12 块 78 CHECK

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:40:58 +08:00
20ead86e88 W20: Tool Calling 闭环 + Stream+Tools + 回归测试 + session auto-save + ASan CI (W20.1-W20.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
- W20.1: CLI tool_calls→execute→result→re-call 循环(5轮上限)
- W20.2: deepseek 流式 tool_calls 增量解析(configure 缓存,无 ABI break)
- W20.3: plugin_loader 回归测试 5 块 32 断言(路径/原子性/mock 日志)
- W20.4: plugin_loader ABI 契约校验(name/version/on_init 字段验证)
- W20.5: ASan/UBSan CMake preset + CI sanitizer job(PR-only Linux)
- W20.6: session auto-save(on_shutdown 写 %APPDATA%/dstalk/session.json)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:15:00 +08:00
3250b5a8bf W19: plugin_loader hardening — ABI try/catch, path validation, atomic IDs, CLI exit codes (W19.1-W19.5)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Fixes: F-18.3-1 through F-18.3-5 (all CLOSED, findings registry at zero)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:34:43 +08:00
c545d16120 W18: context cleanup + CLI fixes + loader audit + CI matrix (W18.1-W18.4)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W18.1 (王测+林深): Remove g_max_tokens dead API, UTF-8 bounds protection, deduplicate token counting, 0xC0/0xC1 handling, add 13 test blocks (36 checks)
- W18.2 (赵码+朱晴): Fix /context no-session error message, /status 3-state connection display
- W18.3 (曹武+徐磊): plugin_loader security audit — 9 dimensions, rating C, 1 HIGH + 2 MEDIUM findings
- W18.4 (马奔+胡桐): CI dual-platform matrix (Ubuntu clang-18 + Windows clang-cl), ccache, build timing baseline

Build 0 error, ctest 5/5 pass, metadata check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:09:21 +08:00
852e2cac08 W17: close 9 audit findings — atomic pointers, SSE robustness, leak fix, verification (W17.1-W17.4)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W17.1 (曹武): Verify F-13.3-1/2/3 all fixed by W14.1, close 3 findings (1 CRITICAL + 1 HIGH + 1 MEDIUM)
- W17.2 (赵码): Fix F-13.2-3 SSE [DONE] sentinel trimming + F-13.2-4 g_host/g_http/g_config atomic pointers in deepseek_plugin
- W17.3 (王测): Verify 4 W14 findings (F-13.1-1/4, F-13.2-1/2) + F-11.7-2 confirmed fixed, close 5 findings
- W17.4 (马奔): Fix F-13.1-2 response_body leak on error path + F-13.1-3 g_host/g_http atomic pointers in anthropic_plugin

Build 0 error, ctest 4/4 pass, metadata check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:56:12 +08:00
6f492489c6 W16: close CRITICAL/HIGH findings, integrate metadata gate, complete audit summaries (W16.1-W16.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W16.1 (曹武): F-11.7-1 CLOSED — confirmed W12.4 fix, corrupt binary eliminated
- W16.2 (孙宇): F-11.1-1 FIXED — context_plugin.cpp try/catch on set_max_tokens + on_shutdown
- W16.3 (陈风): F-11.1-2 CLOSED — confirmed W12.1 fix, strdup OOM protection already in place
- W16.4 (胡桐): Integrate check_agents_metadata into refresh_status.py as pre-gate (error→exit 1)
- W16.5 (周岩): Add Findings Summary to W13.3 network audit, register 3 findings
- W16.6 (赵码): Add Findings Summary to W13.1+W13.2 AI audits, register 8 findings (4 already W14-fixed)

Build 0 error, ctest 4/4 pass, metadata check 0 error 0 warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:45:03 +08:00
f010af6c07 W15: conflict resolution — E7 CRITICAL gate, T11/T18 audit refs, §5 metadata check, PROMPT_TEMPLATE Fixes (W15.4-W15.9)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W15.4 (杨帆): §11/§14 cross-reference audit — PASS-WITH-NOTES, 3 fixes needed
- W15.5 (王测): §14 internal consistency — PASS-WITH-NOTES, 4 fixes needed
- W15.6 (胡桐): self-check script + YAML verification — PASS
- W15.7 (杨帆): Add E7 (no OPEN CRITICAL) to EXPRESS conditions, update T11 to include §14.4 A1-A4, add T18 finding status in §14.5
- W15.8 (王测): Fix findings-registry Close Date, add historical finding time-limit rule, add legacy audit Findings Summary note, add Fixes annotation to PROMPT_TEMPLATE
- W15.9 (胡桐): Fix false-positive warning in check_agents_metadata.py (skip audits/ dir), add metadata check to §5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:33:02 +08:00
0e41c8c6f6 W15: workflow improvements — EXPRESS fast-path, audit→fix closed loop, metadata self-check (W15.1-W15.3)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W15.1 (杨帆): Add EXPRESS fast-path to §11 state machine (T17/T18, E1-E6 conditions, escalation safety valve)
- W15.2 (王测): Add §14 audit→fix closed loop — findings-registry.md, severity-driven auto-triage, CRITICAL blocking rule
- W15.3 (胡桐): Create scripts/check_agents_metadata.py (5-check: YAML parse, rating range, group/member refs, duplicate IDs)
- Fix YAML orphan bugs in 3 profiles: devops-hu, engineer-sun, security-cao (perf_log entries outside array)
- Pre-fill findings-registry.md with 10 historical findings from W11.1/W11.7 audits

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:19:37 +08:00
102cd3e141 Harden plugin runtime: TLS verify, LSP deadlock, path traversal, ABI exception safety (W14)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
W14 addresses the five most critical findings from the W13 plugin audits:

- W14.1 network: enable ssl::verify_peer + SSL_set1_host SNI hostname
  verification (fixes TLS bypass, W13.3 CVSS 7.4); add steady_timer DNS
  timeout and bottom-up catch(...) hardening (engineer-zhou)
- W14.2 lsp: fix reader_loop/stop mutex deadlock via stop_nolock/stop_locked
  split (W13.4); wrap 11 vtable/entry functions in try/catch with cv
  notification on reader exit (engineer-sun)
- W14.3 tools: add is_safe_path() rejecting empty/absolute/.. paths before
  file_io calls (fixes path traversal, W13.5 CVSS 7.5); guard g_tools and
  g_session/g_history under mutex; 9 vtable try/catch (security-cao)
- W14.4 host: add fallback plugin search (../plugins/) so binaries run from
  build/tests/ load current DLLs, resolving the W13.6 R2 stale-DLL false
  alarm (architect-lin)
- W14.5 anthropic+deepseek: wrap 12 ABI boundary functions in try/catch with
  log-guard, preventing exceptions from crossing the C ABI (engineer-chen)

Verified: cmake build 0 error 0 warning, ctest 4/4 pass, smoke R2 now
passes naturally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:03:50 +08:00
47082376ef Wave 10: deep audits of 5 unaudited plugins, smoke regression set (W13.1-W13.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W13.1 anthropic_plugin (architect-yang, 497 lines): rated C. 6 C ABI
  functions lack try/catch (§8 violation); my_chat leaks response_body on
  error path; tool_use response silently dropped.
- W13.2 deepseek_plugin (engineer-sun, 486 lines): rated C+. 7 ABI entries
  unprotected including json::parse paths (malformed JSON terminates);
  SSE [DONE] sentinel match brittle; ~55% code overlap with anthropic
  suggests an ai_plugin_base extraction.
- W13.3 network_plugin (qa-wang, 322 lines): rated C. CRITICAL: TLS
  certificate verification fully disabled (set_verify_mode never called,
  default verify_none accepts any cert) — all AI traffic incl. api_key
  is MITM-vulnerable. DNS resolve has no timeout; catch lacks (...).
- W13.4 lsp_plugin (architect-huang, 749 lines): rated C. CRITICAL:
  guaranteed deadlock at L519-526 → L547 (g_lsp_impl_start holds mutex
  then calls g_lsp_impl_stop which re-locks the same non-recursive
  mutex); 7 vtable funcs unprotected; server→client requests dropped.
- W13.5 session+tools (security-cao, 264+251 lines): rated D+/D. Path
  traversal in builtin_file_read/write (zero validation); global
  static state in both plugins lacks mutex (UAF risk); 9 vtable funcs
  lack try/catch.
- W13.6 smoke regression (qa-xu, +193 lines): 4 new cases — context
  max_tokens trim, config dual-store consistency (exposes that W12.2
  merge is incomplete: dstalk_config_set→config_service.get returns
  null), HTTP error path no-crash, repeated init/shutdown cycle.

Verified: cmake build 0 error 0 warning, ctest 4/4 pass.

Top W14 priorities surfaced: TLS verification (W13.3), LSP deadlock
(W13.4), file-tool path traversal (W13.5), config dual-store still
broken (W13.6 R2), shared try/catch wrapper across all AI plugins.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 09:32:13 +08:00
58869abc15 Wave 9: fix audit findings, harden ABI, deduplicate config (W12.1-W12.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W12.1 context_plugin (engineer-zhou): wrap C ABI surface in try/catch,
  add OOM-safe strdup_message_fields helper, make g_max_tokens drive
  message-count trim (option A).
- W12.2 config refactor (architect-lin): introduce
  plugins/config/include/toml_parse.h to eliminate 74-line parser
  duplication; config_plugin delegates to host->config_get/set,
  collapsing the dual-store data island; ConfigStore::get() now copies
  via thread_local std::string to remove c_str() dangling under
  concurrent set(). Zero ABI changes.
- W12.3 CLI command parsing (engineer-zhao): guard /clear and /context
  on missing session service; refactor /file dispatch so bare
  /file write hits usage instead of unknown-command.
- W12.4 build path unification (devops-hu): set per-target
  RUNTIME_OUTPUT_DIRECTORY on dstalk-cli; remove stale
  build/dstalk-cli/dstalk-cli.exe so build/bin/ is the sole binary.
- W12.5 STATUS.md auto-refresh (engineer-li): run W11.6 script to
  regenerate STATUS from live profile/group data.
- W12.6 plugin-abi.md (writer-deng): add §8 exception safety across
  ABI boundary and §9 string return lifetime; reference real
  audit-found violations as anti-examples.

Verified: cmake build 0 error 0 warning, ctest 4/4 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 09:19:17 +08:00
bb2e8c0220 Wave 8: tech-debt audits, core unit tests, CLI pipe input (W11.1-W11.7)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- W11.1 context_plugin audit (architect-huang): 3 findings on ABI exception
  safety, strdup null checks, dead g_max_tokens variable. Rating: B.
- W11.2 config audit (engineer-chen): identified 74-line TOML parser
  duplication between config_plugin and config_store, dual-store data
  isolation, dangling c_str() risk. Rating: C.
- W11.3 event_bus + service_registry unit tests (qa-liu): 12 cases total,
  ctest coverage 2 -> 4 targets, 100% pass.
- W11.4 CLI stdin pipe mode (engineer-zhao): isatty detection, single-shot
  inference path with exit codes 0/1/2/3.
- W11.6 scripts/refresh_status.py (engineer-li): 431-line generator that
  scans 16 profile.md + 5 group.md to regenerate STATUS.md.
- W11.7 destructive testing (qa-xu): 10 input scenarios PASS, found bin
  copy mismatch (BUG-1) plus 3 minor UX bugs for follow-up.

Verified: cmake build 0 error, ctest 4/4 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 09:06:25 +08:00
004a81db96 Wave 7: collaboration framework hardening (W10.1-W10.4)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Pure agents/ documentation work — first contributions from 4
previously-idle members (yang/li/zhu/xu).

- W10.1 yang: WORKFLOW §11-§13 — collaboration state machine
  (9 states / 16 transitions), 10-item acceptance checklist,
  7-scenario failure rollback playbook (+227 lines)
- W10.2 li: agents/STATUS.md — live roster + group + Wave
  progress snapshot (65 lines)
- W10.3 zhu: agents/PROMPT_TEMPLATE.md — subagent prompt
  template with 6 anti-patterns + 1 worked example +
  4-step pre-dispatch checklist (193 lines)
- W10.4 xu: agents/POSTMORTEM.md — 5 incident records
  (PM-001 stale-obj, PM-002 boost-json, PM-003 cross-DLL-heap,
  PM-004 loader-fail-fast, PM-005 push-force) + 7 defensive
  rules (172 lines)

No code changes. WORKFLOW.md §9 has a pointer to the new
PROMPT_TEMPLATE.md. STATUS.md updated to reflect W10.1
completion (yang status flipped working→idle).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 05:52:02 +08:00
5766938524 Wave 5+6: plugin ABI hardening, build modernization, ABI/security docs
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Wave 5 (9 parallel agents):
- W1.1 atomic diag callback + DLL handle release on shutdown (lin)
- W2.1 unify cross-DLL heap discipline (host->alloc/free/strdup) (chen)
- W2.2 secure_zero api_key on shutdown for deepseek/anthropic (cao)
- W3 CMake modernization: target-based cxx_std_20, dstalk_boost_config
  INTERFACE lib, root-level RUNTIME_OUTPUT_DIRECTORY (hu)
- W4 GitHub Actions CI with dynamic Linux/Windows matrix (ma)
- W5.1 SSE buffer_body to cut peak memory ~67% on 32K streams (zhou)
- W6.1 LSP JSON-RPC frame parser hardened against header reordering (sun)
- W7 smoke test: copy plugin DLLs post-build + Boost.JSON src.hpp fix
  for full 9-plugin load coverage (wang)
- W8.1 README slimmed 398->92, Diataxis docs/ skeleton (deng)

Wave 6 (6 parallel agents):
- W9.1 docs/explanation: architecture + plugin-lifecycle (deng)
- W9.3 log credential leak audit (0 vulns, audit trail in
  docs/explanation/security-logging.md) (cao)
- W9.4 docs/reference/plugin-abi.md - 7-point ABI contract (lin)
- W9.6 CLI /history command + status integration (zhao)
- W9.8 plugin_loader fault tolerance: per-plugin failure no longer
  aborts dstalk_init (huang)
- W9.10 host_api unit tests: tests/host_api_test.cpp, 8 cases (liu)

CEO oversight (preexisting bugs fixed during Wave 5 verification):
- lsp_plugin.cpp:449 forward decl mismatch (int vs void)
- tools_plugin.cpp:109 missing forward decl

Multi-agent collaboration framework:
- agents/WORKFLOW.md: 6-stage protocol, two-tier governance,
  prompt template, technical constraints registry

Build: cmake --build 0 error / 0 warning. Tests: 2/2 100% pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 05:39:10 +08:00
155 changed files with 20751 additions and 5187 deletions

330
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,330 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CMAKE_BUILD_PARALLEL_LEVEL: 0
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 256M
jobs:
# ── 动态矩阵 ──────────────────────────────────────────────
# PR 仅跑 Ubuntu 节省 minutespush master 跑全矩阵 Ubuntu + Windows
matrix:
name: Determine matrix
runs-on: ubuntu-24.04
outputs:
os: ${{ steps.set-matrix.outputs.os }}
steps:
- id: set-matrix
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/master" ]; then
echo "os=[\"ubuntu-24.04\",\"windows-2025\"]" >> "$GITHUB_OUTPUT"
else
echo "os=[\"ubuntu-24.04\"]" >> "$GITHUB_OUTPUT"
fi
# ── 构建 & 测试 ───────────────────────────────────────────
build:
name: ${{ matrix.os }} / ${{ matrix.build_type }}
needs: matrix
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.matrix.outputs.os) }}
build_type: [Release]
runs-on: ${{ matrix.os }}
steps:
# ── 1. 源码检出 ──────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# ── 2. 平台编译工具链 ─────────────────────────────────
# Ubuntu: clang-18 + Ninja + ccache
- name: Install toolchain (Ubuntu)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq clang-18 ninja-build ccache
echo "CC=clang-18" >> $GITHUB_ENV
echo "CXX=clang++-18" >> $GITHUB_ENV
# Windows: LLVM (clang-cl) + Ninja + ccache
- name: Install toolchain (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
choco install -y llvm ninja ccache --no-progress 2>/dev/null || true
# Add clang-cl to PATH (both possible locations)
# Prefer choco-installed LLVM; fall back to VS-bundled clang-cl
if [ -d "/c/Program Files/LLVM/bin" ]; then
echo "/c/Program Files/LLVM/bin" >> $GITHUB_PATH
elif [ -d "/c/Program Files/Microsoft Visual Studio/2026/Enterprise/VC/Tools/Llvm/x64/bin" ]; then
echo "/c/Program Files/Microsoft Visual Studio/2026/Enterprise/VC/Tools/Llvm/x64/bin" >> $GITHUB_PATH
elif [ -d "/c/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Tools/Llvm/x64/bin" ]; then
echo "/c/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Tools/Llvm/x64/bin" >> $GITHUB_PATH
fi
echo "CC=clang-cl" >> $GITHUB_ENV
echo "CXX=clang-cl" >> $GITHUB_ENV
# ── 3. ccache 缓存恢复 ────────────────────────────────
- name: Cache ccache
uses: actions/cache@v4
with:
path: ${{ env.CCACHE_DIR }}
key: ${{ runner.os }}-ccache-${{ matrix.build_type }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-ccache-${{ matrix.build_type }}-
${{ runner.os }}-ccache-
# ── 4. Python + Conan ─────────────────────────────────
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Conan
run: pip install conan
# ── 5. Conan 依赖缓存 ─────────────────────────────────
- name: Cache Conan
uses: actions/cache@v4
with:
path: |
~/.conan2
~/.conan2/p
key: ${{ runner.os }}-conan-${{ matrix.build_type }}-${{ hashFiles('deps/conanfile.txt') }}
restore-keys: |
${{ runner.os }}-conan-${{ matrix.build_type }}-
${{ runner.os }}-conan-
# ── 6. Conan 依赖安装 ─────────────────────────────────
- name: Install Conan dependencies
shell: bash
run: |
conan profile detect --force
conan install deps --build=missing -s build_type=${{ matrix.build_type }}
# ── 7. CMake 配置 ─────────────────────────────────────
- name: Configure CMake
shell: bash
run: |
cmake --preset ci-release \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
# ── 8. 构建(含计时) ─────────────────────────────────
- name: Build
id: build
shell: bash
run: |
echo "::group::Build (${{ matrix.os }})"
START_NS=$(date +%s%N 2>/dev/null || echo 0)
START_S=$(date +%s)
cmake --build --preset ci-release --config ${{ matrix.build_type }}
END_S=$(date +%s)
END_NS=$(date +%s%N 2>/dev/null || echo 0)
DURATION=$((END_S - START_S))
echo "::endgroup::"
echo "duration=${DURATION}" >> $GITHUB_OUTPUT
echo "Build wall time: ${DURATION}s"
# ── 9. ccache 统计 ────────────────────────────────────
- name: ccache stats
if: always()
shell: bash
run: |
ccache -s || echo "ccache stats unavailable"
ccache -z || true
# ── 10. 测试 ──────────────────────────────────────────
- name: Test
shell: bash
run: ctest --preset ci-release -C ${{ matrix.build_type }} --output-on-failure
# ── 11. 构建时间摘要 ──────────────────────────────────
- name: Build time summary
if: always()
shell: bash
run: |
DURATION="${{ steps.build.outputs.duration }}"
echo "| Platform | Compiler | Build Time |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|-----------|" >> $GITHUB_STEP_SUMMARY
echo "| ${{ matrix.os }} | ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} | ${DURATION}s |" >> $GITHUB_STEP_SUMMARY
echo "CI build: ${{ matrix.os }} / ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} wall time ${DURATION}s"
# ── Sanitizer (PR-only, Linux clang-18, no ccache) ───────
sanitize:
name: Sanitizer (ASan+UBSan) / ubuntu-24.04
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
steps:
# ── 1. 源码检出 ──────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# ── 2. 工具链 (clang-18) ─────────────────────────────
- name: Install toolchain (Ubuntu)
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq clang-18 ninja-build
echo "CC=clang-18" >> $GITHUB_ENV
echo "CXX=clang++-18" >> $GITHUB_ENV
# ── 3. Python + Conan ─────────────────────────────────
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Conan
run: pip install conan
# ── 4. Conan 依赖缓存 ─────────────────────────────────
- name: Cache Conan
uses: actions/cache@v4
with:
path: |
~/.conan2
~/.conan2/p
key: ${{ runner.os }}-conan-Release-${{ hashFiles('deps/conanfile.txt') }}
restore-keys: |
${{ runner.os }}-conan-Release-
${{ runner.os }}-conan-
# ── 5. Conan 依赖安装 ─────────────────────────────────
- name: Install Conan dependencies
shell: bash
run: |
conan profile detect --force
conan install deps --build=missing -s build_type=Release
# ── 6. CMake 配置 ─────────────────────────────────────
- name: Configure CMake (Sanitizer)
shell: bash
run: cmake --preset ci-sanitize
# ── 7. 构建 ───────────────────────────────────────────
- name: Build (Sanitizer)
shell: bash
run: cmake --build --preset ci-sanitize
# ── 8. 测试 ──────────────────────────────────────────
- name: Test (Sanitizer)
shell: bash
run: ctest --preset ci-sanitize --output-on-failure
# ── Coverage (PR + push master, Linux clang-18, gcovr) ──
coverage:
name: Coverage (gcovr) / ubuntu-24.04
runs-on: ubuntu-24.04
steps:
# ── 1. 源码检出 ──────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# ── 2. 工具链 (clang-18) ─────────────────────────────
- name: Install toolchain (Ubuntu)
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq clang-18 ninja-build
echo "CC=clang-18" >> $GITHUB_ENV
echo "CXX=clang++-18" >> $GITHUB_ENV
# ── 3. Python + Conan ─────────────────────────────────
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Conan + gcovr
run: pip install conan gcovr
# ── 4. Conan 依赖缓存 ─────────────────────────────────
- name: Cache Conan
uses: actions/cache@v4
with:
path: |
~/.conan2
~/.conan2/p
key: ${{ runner.os }}-conan-Release-${{ hashFiles('deps/conanfile.txt') }}
restore-keys: |
${{ runner.os }}-conan-Release-
${{ runner.os }}-conan-
# ── 5. Conan 依赖安装 ─────────────────────────────────
- name: Install Conan dependencies
shell: bash
run: |
conan profile detect --force
conan install deps --build=missing -s build_type=Release
# ── 6. CMake 配置 ─────────────────────────────────────
- name: Configure CMake (Coverage)
shell: bash
run: cmake --preset ci-coverage
# ── 7. 构建 ───────────────────────────────────────────
- name: Build (Coverage)
shell: bash
run: cmake --build --preset ci-coverage
# ── 8. 测试 ──────────────────────────────────────────
- name: Test (Coverage)
shell: bash
run: ctest --preset ci-coverage --output-on-failure
# ── 9. 覆盖率报告 ────────────────────────────────────
- name: Coverage report
id: coverage
shell: bash
run: |
gcovr -r . --object-directory=build/ci-coverage \
--gcov-executable "llvm-cov-18 gcov" \
--print-summary > coverage_summary.txt 2>&1 || true
cat coverage_summary.txt
# Extract line coverage percentage
LINE_COV=$(grep -oP 'lines:\s*\K[\d.]+' coverage_summary.txt || echo "0")
echo "line_rate=${LINE_COV}" >> $GITHUB_OUTPUT
# Also generate HTML report
mkdir -p build/ci-coverage/coverage
gcovr -r . --object-directory=build/ci-coverage \
--gcov-executable "llvm-cov-18 gcov" \
--html --html-details \
-o build/ci-coverage/coverage/index.html || true
echo "HTML report: build/ci-coverage/coverage/index.html"
# ── 10. 覆盖率摘要 + 阈值门禁 ─────────────────────────
- name: Coverage summary
if: always()
shell: bash
run: |
LINE_COV="${{ steps.coverage.outputs.line_rate }}"
THRESHOLD=40
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value | Threshold | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|-----------|--------|" >> $GITHUB_STEP_SUMMARY
if [ -z "$LINE_COV" ] || [ "$LINE_COV" = "0" ]; then
echo "| Line Coverage | N/A | ${THRESHOLD}% | :grey_question: (no data) |" >> $GITHUB_STEP_SUMMARY
elif awk "BEGIN {exit !($LINE_COV < $THRESHOLD)}"; then
echo "| Line Coverage | ${LINE_COV}% | ${THRESHOLD}% | :warning: BELOW THRESHOLD |" >> $GITHUB_STEP_SUMMARY
echo "::warning title=Coverage below threshold::Line coverage ${LINE_COV}% is below ${THRESHOLD}% threshold"
else
echo "| Line Coverage | ${LINE_COV}% | ${THRESHOLD}% | :white_check_mark: OK |" >> $GITHUB_STEP_SUMMARY
echo "Line coverage ${LINE_COV}% >= ${THRESHOLD}% threshold - OK"
fi

4
.gitignore vendored
View File

@@ -22,6 +22,10 @@ tools/ninja/
tools/llvm/ tools/llvm/
tools/.venv/ tools/.venv/
# Python bytecode
__pycache__/
*.pyc
# IDE # IDE
.vs/ .vs/
.vscode/ .vscode/

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & run
The repo ships its own toolchain under `tools/` (clang-cl/clang, CMake, Ninja, Conan 2 in a venv). Build scripts prefer those over PATH.
- **One-shot build (Windows)**: `build.bat` — checks/installs toolchain, runs Conan install against `deps/conanfile.txt`, configures CMake with clang-cl, builds with Ninja into `build/`.
- **One-shot build (Linux/macOS)**: `bash build.sh [Release|Debug]` — same flow with clang (or gcc fallback) and Ninja.
- **First-time setup**: `tools/setup.bat` or `bash tools/setup.sh` installs CMake, Ninja, and Conan 2 into `tools/` without touching system PATH.
- **CI-style build**: `scripts/ci-build.sh` / `scripts/ci-build.bat` — minimal Conan + CMake + Ninja flow used by CI.
- **Incremental rebuild after edits**: `cd build && <tools/ninja or system ninja> -j<N>` from inside `build/`. No need to re-run Conan unless `deps/conanfile.txt` changes.
Build outputs:
- Executables → `build/bin/` (`dstalk_cli`, test binaries, optional `dstalk_gui`/`dstalk_web`)
- Plugin DLLs → `build/plugins/` (each `plugin_*` target writes here, prefix stripped)
CMake options (root `CMakeLists.txt`):
- `DSTALK_BUILD_GUI=ON` — also builds the SDL3 GUI frontend (off by default; requires SDL3 from Conan/system)
- `DSTALK_BUILD_WEB=ON` — also builds the Boost.Beast web frontend
- `DSTALK_BUILD_TESTS=ON` — on by default; turn off to skip `tests/` subdir
## Tests
CTest, hand-rolled `CHECK`-macro tests (no GoogleTest — see "Network-restricted environments" below).
- **Run all tests**: `cd build && ctest --output-on-failure`
- **Run one test**: `cd build && ctest -R dstalk_smoke_test --output-on-failure` (or any other target name from `tests/CMakeLists.txt`)
- **Run a test binary directly**: `build/bin/dstalk_smoke_test.exe`
- **Coverage**: `cmake --build build --target coverage` (requires gcovr + gcov/llvm-cov; produces `build/coverage/index.html`). Only meaningful when configured with `--coverage` flags.
Test targets and what they cover (see `tests/CMakeLists.txt` for the authoritative list):
- `dstalk_smoke_test` — loads real plugins from `build/plugins/`, end-to-end integration; also carries regression cases (R1-R4, W21.5 tool calls)
- `dstalk_host_api_test`, `dstalk_event_bus_test`, `dstalk_service_registry_test` — core unit tests; compile core sources directly
- `dstalk_plugin_loader_test``PluginLoader` regression tests; sets `DSTALK_TEST_PLUGINS_DIR` to `build/plugins`
- `dstalk_context_plugin_test` — token/trim/UTF-8 boundaries
- `dstalk_anthropic_plugin_test`, `dstalk_openai_plugin_test` — currently `#include` the plugin `.cpp` to reach static functions; link `ai_common` and need its include dir
- `dstalk_network_plugin_test``#include`s `network_plugin.cpp`; needs OpenSSL
- `dstalk_lsp_plugin_test` — tests `lsp_trim` / `lsp_frame_message` / `lsp_parse_content_length` via `lsp_internal.hpp`
> After changing the `dstalk_host_api_t` vtable layout, **clean rebuild is mandatory** — stale `.obj` files from incremental builds will surface as segfaults in `dstalk_smoke_test` / `dstalk_host_api_test` because the test binary and plugin DLLs disagree on struct layout. `rm -rf build && bash build.sh` (or delete the specific stale `.obj` files) before re-running ctest.
## Architecture
Plugin-host design. One process loads a host DLL and many plugin DLLs over a C ABI.
```
Frontend (dstalk_cli / dstalk_gui / dstalk_web)
│ links dstalk_frontend_common (shared bootstrap: config discovery, init,
│ service queries, FrontendServices struct)
dstalk_core.dll ─ PluginLoader · ServiceRegistry · EventBus · Config · Logging · Memory
│ C ABI (dstalk_host.h)
Plugins (loaded from build/plugins/, each plugin_*.dll exports dstalk_plugin_init)
plugins_base/ config, file_io, lsp
plugins_middle/ network, session, tools (may depend on base)
plugins_upper/ context, openai, anthropic (may depend on base + middle)
ai_common (static lib, shared by openai + anthropic)
```
**Plugin tiers matter for build order and dependency direction**: `plugins_upper` may depend on `plugins_middle` may depend on `plugins_base`. Never let a lower tier depend on a higher one.
**Service vtables are the only cross-DLL contract.** Plugins register service vtables (e.g. `dstalk_ai_service_t`, `dstalk_http_service_t`, `dstalk_session_service_t`) with the host's `ServiceRegistry`. Other plugins query them by `(name, min_version)`. The vtable shapes live in `dstalk_core/include/dstalk/dstalk_services.h`; the host API the loader passes into each plugin's `on_init(const dstalk_host_api_t* host)` lives in `dstalk_host.h`.
**`ai_common`** (`plugins_upper/ai_common/`) is a static library, not a plugin. It holds shared types (`PluginConfig`, `ToolCallAccum`, `StreamContext`) and utilities (`secure_zero`, `extract_host_port`, `serialize_tool_calls`, `free_chat_result`) used by both `openai` and `anthropic` plugins, all under `namespace dstalk_ai`. Each plugin still compiles as its own DLL and links `ai_common` privately.
### Hard rules — violating these is undefined behavior
These come from `docs/reference/plugin-abi.md`. They are not style; they are correctness.
1. **Cross-DLL heap discipline.** Plugins must NOT call `std::malloc`/`std::free`/`std::strdup`/`new`/`delete` on data that crosses the host↔plugin boundary. Use `host->alloc` / `host->free` / `host->strdup` (passed in via `on_init`). Windows /MD CRTs and even some Linux/libc configs give each DLL its own heap — mismatched alloc/free crashes.
2. **API version is a hard match.** `dstalk_plugin_info_t.api_version` must equal `DSTALK_API_VERSION` (currently 1). The loader rejects mismatches. There is no backward compat — rebuild plugins against the new host.
3. **String ownership.** `dstalk_chat_result_t.content` / `.error` / `.tool_calls_json` are allocated with `host->strdup` by the producing plugin; the **caller** frees them with `host->free`. Never return `std::string::c_str()` or stack buffers across the ABI.
4. **C ABI + atomic globals.** Service vtable function pointers and cached service pointers stored in plugin global state must be `std::atomic<T*>` with `memory_order_acquire`/`release`. Raw pointers race during shutdown (the anthropic plugin's `g_config` was a real data race fixed in W17). Same applies to `g_host`, `g_http`, etc.
5. **No new on plugin info strings.** `name`/`version`/`description` in `dstalk_plugin_info_t` only need to live for the duration of `dstalk_plugin_init()` — string literals are fine; the host copies them.
6. **`api_key` lifecycle.** On `on_shutdown`, overwrite key bytes via `volatile char*` loop, then clear the string. Use `dstalk_ai::secure_zero()` from `ai_common`.
### Build system facts that bite
- **Conan provides only `boost::boost`** (the umbrella target) — granular `boost::json` / `boost::asio` / `boost::beast` targets do **not** exist in this Conan setup. Don't migrate to them.
- Every Boost-using target needs its own `find_package(Boost REQUIRED CONFIG)` call before `target_link_libraries(... boost::boost)`. Doing it once at the root is not enough; Conan-generated config exposes the target per-subdir.
- `dstalk_boost_config` (defined in `dstalk_core/CMakeLists.txt`) is an INTERFACE target carrying `BOOST_ALL_NO_LIB` / `BOOST_ERROR_CODE_HEADER_ONLY` / `BOOST_JSON_HEADER_ONLY`. Link it from any target using `<boost/json.hpp>`. Boost.JSON is header-only here, so each TU using it must `#include <boost/json/src.hpp>` in exactly one file (see `dstalk_core/src/boost_json.cpp`).
- The network plugin links `openssl::openssl` directly and calls OpenSSL C APIs (`SSL_set_tlsext_host_name`, `SSL_set1_host`). Don't remove that link "because Boost.Asio already pulls SSL" — it doesn't, at least not for these symbols.
- Plugin DLLs are written to `build/plugins/` with `PREFIX ""` (so `plugin_openai.dll`, not `libplugin_openai.dll`). The smoke test and plugin_loader_test look there.
### Network-restricted environments
This repo has been built in environments where outbound network is locked down. Two consequences:
- **Do not add `FetchContent` for GitHub-hosted deps.** `git clone` over smart-HTTP fails with "early EOF" through restrictive proxies (W17.3 lesson — GoogleTest dropped, tests use hand-rolled `CHECK` macros instead). Vendor or skip.
- Conan packages must be pre-cached in `~/.conan2/p/` or available through an allowed mirror; first-run `conan install` from a clean cache will fail offline.
## Repo-specific conventions
- **README is in Chinese (Simplified).** Comments and docs are routinely bilingual (Chinese first, English after a `/`). Match the surrounding style in any file you edit.
- **Plugin code style.** Each AI/network plugin has the same skeleton: `g_host` (atomic), `g_cfg`, `on_init`/`on_shutdown`, service vtable, then `dstalk_plugin_init()` returning a static `dstalk_plugin_info_t`. Keep that shape when adding a provider.
- **CRT.** `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL` (/MD). All plugins and the host must agree.
## The `agents/` directory
`agents/` (README, WORKFLOW.md, STATUS.md, per-agent `profile.md` files, `groups/`) describes a multi-agent collaboration mode used by the project owner with Claude. It is **documentation, not code** — nothing in the build references it. Treat its contents as historical context; do not invent or extend the "16-person team / waves / 6-stage workflow" apparatus unless the user explicitly asks for it. If the user does invoke it, the rules are in `agents/WORKFLOW.md`.

View File

@@ -1,24 +1,30 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(dstalk VERSION 0.1.0 LANGUAGES C CXX) project(dstalk VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
if(MSVC_VERSION LESS 1920)
set(CMAKE_CXX_STANDARD 17)
else()
set(CMAKE_CXX_STANDARD 20)
endif()
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
option(DSTALK_BUILD_GUI "Build the SDL3 GUI frontend" OFF) option(DSTALK_BUILD_GUI "Build the SDL3 GUI frontend" OFF)
option(DSTALK_BUILD_WEB "Build the web UI frontend" OFF)
option(DSTALK_BUILD_TESTS "Build dstalk tests" ON) option(DSTALK_BUILD_TESTS "Build dstalk tests" ON)
add_subdirectory(dstalk-core) add_subdirectory(dstalk_core)
add_subdirectory(dstalk-cli) add_subdirectory(dstalk_frontend_common)
add_subdirectory(plugins) add_subdirectory(dstalk_cli)
# 插件按依赖层级分三个目录 / Plugins split into three directories by dependency tier
add_subdirectory(plugins_base)
add_subdirectory(plugins_middle)
add_subdirectory(plugins_upper)
if(DSTALK_BUILD_GUI) if(DSTALK_BUILD_GUI)
add_subdirectory(dstalk-gui) add_subdirectory(dstalk_gui)
endif()
if(DSTALK_BUILD_WEB)
add_subdirectory(dstalk_web)
endif() endif()
if(DSTALK_BUILD_TESTS) if(DSTALK_BUILD_TESTS)

164
CMakePresets.json Normal file
View File

@@ -0,0 +1,164 @@
{
"version": 3,
"vendor": {
"conan": {}
},
"cmakeMinimumRequired": {
"major": 3,
"minor": 15,
"patch": 0
},
"configurePresets": [
{
"name": "conan-release",
"displayName": "'conan-release' config",
"description": "'conan-release' configure using 'Ninja' generator",
"generator": "Ninja",
"cacheVariables": {
"CMAKE_POLICY_DEFAULT_CMP0091": "NEW",
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "cl",
"CMAKE_CXX_COMPILER": "cl"
},
"toolset": {
"value": "v145",
"strategy": "external"
},
"architecture": {
"value": "x64",
"strategy": "external"
},
"toolchainFile": "${sourceDir}/build/build/Release/generators/conan_toolchain.cmake",
"binaryDir": "${sourceDir}/build/build/Release"
},
{
"name": "ci-release",
"displayName": "CI Release (cross-platform)",
"description": "Cross-platform CI build; compiler set via Conan CMakeToolchain",
"generator": "Ninja",
"cacheVariables": {
"CMAKE_POLICY_DEFAULT_CMP0091": "NEW",
"CMAKE_BUILD_TYPE": "Release"
},
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
"binaryDir": "${sourceDir}/build/ci"
},
{
"name": "ci-sanitize",
"displayName": "CI Sanitizer (ASan+UBSan)",
"description": "AddressSanitizer + UndefinedBehaviorSanitizer Linux clang CI build",
"generator": "Ninja",
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
"binaryDir": "${sourceDir}/build/ci-sanitize",
"cacheVariables": {
"CMAKE_POLICY_DEFAULT_CMP0091": "NEW",
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "clang-18",
"CMAKE_CXX_COMPILER": "clang++-18",
"CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer",
"CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
}
},
{
"name": "ci-threadsan",
"displayName": "CI ThreadSanitizer (TSan)",
"description": "ThreadSanitizer Linux clang CI build",
"generator": "Ninja",
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
"binaryDir": "${sourceDir}/build/ci-threadsan",
"cacheVariables": {
"CMAKE_POLICY_DEFAULT_CMP0091": "NEW",
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "clang-18",
"CMAKE_CXX_COMPILER": "clang++-18",
"CMAKE_C_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer",
"CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer"
}
},
{
"name": "ci-coverage",
"displayName": "CI Coverage (gcov/lcov)",
"description": "Code coverage Linux clang CI build with --coverage",
"generator": "Ninja",
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
"binaryDir": "${sourceDir}/build/ci-coverage",
"cacheVariables": {
"CMAKE_POLICY_DEFAULT_CMP0091": "NEW",
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "clang-18",
"CMAKE_CXX_COMPILER": "clang++-18",
"CMAKE_C_FLAGS": "--coverage",
"CMAKE_CXX_FLAGS": "--coverage",
"CMAKE_EXE_LINKER_FLAGS": "--coverage",
"CMAKE_SHARED_LINKER_FLAGS": "--coverage"
}
}
],
"buildPresets": [
{
"name": "conan-release",
"configurePreset": "conan-release",
"jobs": 32
},
{
"name": "ci-release",
"configurePreset": "ci-release",
"jobs": 0
},
{
"name": "ci-sanitize",
"configurePreset": "ci-sanitize",
"jobs": 0
},
{
"name": "ci-threadsan",
"configurePreset": "ci-threadsan",
"jobs": 0
},
{
"name": "ci-coverage",
"configurePreset": "ci-coverage",
"jobs": 0
}
],
"testPresets": [
{
"name": "conan-release",
"configurePreset": "conan-release",
"execution": {
"jobs": 32
},
"environment": {
"OPENSSL_MODULES": "C:\\Users\\Administrator\\.conan2\\p\\b\\opens4d81e45a1d5f5\\p\\lib\\ossl-modules"
}
},
{
"name": "ci-release",
"configurePreset": "ci-release",
"execution": {
"jobs": 0
}
},
{
"name": "ci-sanitize",
"configurePreset": "ci-sanitize",
"execution": {
"jobs": 0
}
},
{
"name": "ci-threadsan",
"configurePreset": "ci-threadsan",
"execution": {
"jobs": 0
}
},
{
"name": "ci-coverage",
"configurePreset": "ci-coverage",
"execution": {
"jobs": 0
}
}
]
}

368
README.md
View File

@@ -1,6 +1,6 @@
# dstalk # dstalk
> 基于 DeepSeek V4 大模型、兼容 OpenAI / Anthropic API 的 AI 编程 CLI > AI 编程 CLI —— 基于 DeepSeek V4, 兼容 OpenAI / Anthropic API
> >
> 官网: [dstalk.top](https://dstalk.top) > 官网: [dstalk.top](https://dstalk.top)
@@ -8,383 +8,81 @@
## 这是什么? ## 这是什么?
dstalk 是一款 AI 编程助手命令行工具通过调用 DeepSeek V4 大模型(兼容 OpenAI 和 Anthropic API在终端里用自然语言完成代码编写、重构、调试和文件操作。功能对标 Claude Code、OpenCode、KiloCode dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端里完成代码编写、重构、调试和文件操作
核心设计为 **插件化 CDLL + 多前端解耦** 核心设计为 **插件化 CDLL + 多前端解耦**:
```text ```text
┌───────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────┐
│ 前端层 (Frontends) │ │ 前端层 (Frontends) │
│ ┌──────────────────┐ ┌──────────────────────────┐ │ │ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ dstalk-cli │ │ dstalk-gui │ │ │ │ dstalk_cli │ │ dstalk_gui │ │
│ │ ANSI 终端 UI │ │ SDL3 图形化 UI │ │ │ │ ANSI 终端 UI │ │ SDL3 图形化 UI │ │
│ │ exe → dstalk.dll│ │ exe → dstalk.dll │ │
│ └────────┬─────────┘ └─────────────┬─────────────┘ │ │ └────────┬─────────┘ └─────────────┬─────────────┘ │
│ │ │ │
│ └──────────────┬───────────────┘ │ │ └──────────────┬───────────────┘ │
│ │ C ABI │ │ │ C ABI │
└──────────────────────────┼─────────────────────────────────┘ └──────────────────────────┼─────────────────────────────────┘
┌──────────────────────────▼─────────────────────────────────┐ ┌──────────────────────────▼─────────────────────────────────┐
│ 核心层 (dstalk-core.dll) — 插件宿主 │ │ 核心层 (dstalk_core.dll) — 插件宿主 │
│ ┌──────────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────────┐ │
│ │ Host: 插件加载 · 服务注册 · 事件总线 · 配置管理 │ │ │ │ Host: 插件加载 · 服务注册 · 事件总线 · 配置管理 │ │
│ └──────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────┘ │
│ │ 服务查询 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ deepseek │ │ anthropic│ │ network │ │ lsp │ │ │ │ openai │ │ anthropic│ │ network │ │ lsp │ │
│ │ (ai) │ │ (ai) │ │ (http) │ │ 客户端 │ │ │ │ (ai) │ │ (ai) │ │ (http) │ │ 客户端 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ session │ │ context │ │ file-io │ │ tools │ │ │ │ session │ │ context │ │ file_io │ │ tools │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
- **`dstalk-core`** —— C11/C++20 插件化宿主 DLL负责插件加载、服务注册/查询、事件总线、配置管理 - **dstalk_core** —— C11/C++20 插件化宿主 DLL, 负责插件加载、服务注册、事件总线、配置管理
- **`dstalk-cli`** —— 命令行前端ANSI 转义码实现,调用 `dstalk.dll` - **dstalk_cli** —— 命令行前端, ANSI 终端 UI
- **`dstalk-gui`** —— 图形化前端SDL3 跨平台窗口,调用 `dstalk.dll` - **dstalk_gui** —— 图形化前端, SDL3 跨平台窗口
- **`plugins/`** —— 9 个功能插件编译为独立 DLL通过 C ABI 动态注册服务 - **plugins_base/、plugins_middle/、plugins_upper/** —— 9 个功能插件按三层架构分布, 编译为独立 DLL, 通过 C ABI 动态注册服务
核心与界面完全解耦,可以轻松编写自己的前端或把 AI 能力嵌入到现有工具中。所有功能通过插件实现,插件只需引用 `dstalk.dll` 即可。 核心与界面完全解耦, 可编写自己的前端或把 AI 能力嵌入到现有工具中。
--- ---
## 核心功能 ## 支持的 AI 模型
| 功能 | 状态 | 说明 | | 提供商 | 模型 | 插件 |
|------|------|------| |--------|------|------|
| **多后端 AI 支持** | 已完成 | DeepSeek V4 和 Anthropic Claude 通过插件独立加载,`config.toml``ai.provider` 一键切换 | | OpenAI-compatible | gpt-4o | `ai_openai` |
| **流式输出** | 已完成 | SSE 流式响应,终端逐字打印 AI 思考过程 | | Anthropic | claude-opus-4 | `ai_anthropic` |
| **多轮会话** | 已完成 | 上下文窗口连续对话,支持 `/clear` 清空、`/save` `/load` 持久化 | | OpenAI 兼容 | GPT 系列 | `ai_openai` |
| **文件读写工具** | 已完成 | 内置 `/file` 命令集,支持列目录、查看、读取、写入文件 |
| **LSP 集成** | 已完成 | 完整 LSP 客户端子进程管理、JSON-RPC 2.0),支持诊断、悬停、补全 |
| **插件系统** | 已完成 | 9 个功能插件拓扑排序依赖管理DLL 动态加载,服务注册/查询 |
| **GUI 前端** | 已完成 | SDL3 跨平台图形界面,流式输出、会话管理、输入历史、剪贴板 |
--- 通过 `config.toml``ai.provider` 一键切换。
## 为什么用 C/C++ 实现?
| 维度 | dstalk (C/C++) | 典型竞品 (TypeScript/Node.js) |
|------|----------------|------------------------------|
| 启动速度 | 毫秒级 | 秒级 |
| 内存占用 | 数十 MB | 数百 MB 起 |
| 运行时依赖 | 零(单文件 DLL | 需要 Node.js 运行时 |
| 嵌入能力 | 任意语言通过 C ABI 调用 | 困难 |
| GC 影响 | 无 GC 停顿 | 可能内存膨胀 |
AI 编程助手需要长期驻留、频繁交互,性能特征值得用系统级语言重新思考。
---
## 与竞品的差异化
| 特性 | dstalk | Claude Code | OpenCode | KiloCode |
|------|--------|-------------|----------|----------|
| 实现语言 | C11 / C++20 | TypeScript | TypeScript | TypeScript |
| 运行时 | 零依赖 CDLL | Node.js | Node.js | Node.js |
| 前端形态 | CLI + GUI 双前端 | 终端集成 | VS Code 插件 | VS Code 插件 |
| 模型 | DeepSeek / OpenAI / Anthropic | Claude | 多模型 | 多模型 |
| 嵌入第三方 | C ABI极易 | 困难 | 困难 | 困难 |
### DLL 架构优势
- **语言无关** —— C ABI 意味着 C/C++、Python、Rust、C#、Go 都能直接调用
- **进程内集成** —— 无需 HTTP 通信、零 IPC 开销,直接函数调用
- **前端零状态** —— CLI 和 GUI 不持有业务逻辑,只负责渲染和输入
--- ---
## 快速开始 ## 快速开始
### 1. 安装工具链(全自动,存入 tools\,需系统已安装 Python 3.10+ | 平台 | 安装工具链 | 编译 | 运行 |
|------|-----------|------|------|
| Windows | `cd tools && setup.bat` | `build.bat` | `build\bin\dstalk_cli.exe` |
| Mac / Linux | `cd tools && bash setup.sh` | `bash build.sh` | `build/bin/dstalk_cli` |
```bash ```bash
cd tools # 3. 创建 config.toml # 见教程: docs/tutorial/quick-start.md
setup.bat # 下载 CMake + Ninja + LLVM/Clang并在 tools/.venv 安装 Conan2 # 4. 运行 (路径见上表)
# 5. 输入自然语言 # "帮我写一个 C 程序"
``` ```
> 网络不畅时可手动下载放入对应目录:[Ninja](https://github.com/ninja-build/ninja/releases) | [CMake](https://cmake.org/download/) | [LLVM](https://github.com/llvm/llvm-project/releases) > 详细 5 步教程 (含 config.toml 模板与对话示例): [docs/tutorial/quick-start.md](docs/tutorial/quick-start.md)
>
> 目录结构要求: `tools/cmake/bin/cmake.exe` / `tools/ninja/ninja.exe` / `tools/llvm/bin/clang.exe` / `tools/.venv/Scripts/conan.exe`
### 2. 编译
```bash
build.bat # 一键: Conan拉依赖 → CMake配置 → Ninja编译
```
### 3. 运行
```bash
build/dstalk-cli/dstalk-cli.exe # 命令行模式
# 图形模式默认关闭;需要 SDL3 时用 -DDSTALK_BUILD_GUI=ON 重新配置
```
--- ---
## 使用示例 ## 文档
```text | 文档 | 说明 |
$ dstalk-cli
dstalk v0.1.0 | 模型: deepseek-v4-pro | /help 查看帮助
> 帮我写一个读取 CSV 并计算平均值的 C 程序
[dstalk] 正在思考...
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "用法: %s <csv文件>\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (!fp) { perror("fopen"); return 1; }
double sum = 0.0;
int count = 0;
char line[1024];
while (fgets(line, sizeof(line), fp)) {
sum += atof(line);
count++;
}
fclose(fp);
printf("平均值: %.2f (共 %d 行)\n", sum / count, count);
return 0;
}
已写入 csv_avg.c。需要我帮你编译测试吗
> 把这段代码改成支持表头的
[dstalk] 已更新 csv_avg.c——跳过第一行表头增加列选择功能。
> /file show csv_avg.c
[dstalk] 已显示 csv_avg.c 内容。
```
### 常用命令
| 命令 | 说明 |
|------|------| |------|------|
| `/file list [path]` | 列出目录内容 | | [教程: 快速入门](docs/tutorial/quick-start.md) | 5 步上手, 从安装到第一个对话 |
| `/file show <path>` | 查看文件内容 | | [参考: CLI 命令](docs/reference/commands.md) | 完整命令速查表 |
| `/file read <path>` | 读取文件内容 | | [文档导航](docs/README.md) | 全部文档索引与未来计划 |
| `/file write <path> <content>` | 写入文件 |
| `/model <name>` | 切换 AI 模型 |
| `/clear` | 清空会话上下文 |
| `/save <path>` | 保存会话 |
| `/load <path>` | 恢复会话 |
| `/help` | 显示帮助 |
---
## 工程结构
```text
dstalk/
├── deps/
│ └── conanfile.txt # Conan2 依赖声明 (Boost, OpenSSL, SDL3)
├── dstalk-core/ # 核心 DLL — 插件宿主
│ ├── include/dstalk/
│ │ ├── dstalk_host.h # 公开 API: 宏定义、宿主API、插件生命周期
│ │ ├── dstalk_services.h # 服务接口 vtable 定义 (AI/Session/Context/HTTP/FileIO/Config/Tools/LSP)
│ │ ├── dstalk_types.h # 共享类型: 消息、结果、事件、日志等级
│ │ └── dstalk_lsp.h # LSP 便捷函数 (委托给 lsp 插件)
│ ├── src/
│ │ ├── host.cpp # 宿主: 初始化、服务查询、LSP 便捷函数
│ │ ├── config_store.cpp/.hpp # 配置管理 (TOML 解析)
│ │ ├── event_bus.cpp/.hpp # 事件总线 (发布/订阅)
│ │ ├── service_registry.cpp/.hpp # 服务注册表 (名称→vtable)
│ │ ├── plugin_loader.cpp/.hpp # 插件加载器 (DLL 加载、拓扑排序、依赖管理)
│ │ └── boost_json.cpp # Boost.JSON 编译单元
│ └── CMakeLists.txt
├── plugins/ # 功能插件 (每个编译为独立 DLL)
│ ├── deepseek/ # DeepSeek AI (服务名: ai.deepseek)
│ ├── anthropic/ # Anthropic Claude (服务名: ai.anthropic)
│ ├── network/ # HTTP/HTTPS 客户端 (服务名: http)
│ ├── session/ # 会话管理 (服务名: session)
│ ├── context/ # 上下文/Token 管理 (服务名: context)
│ ├── file-io/ # 文件读写 (服务名: file_io)
│ ├── tools/ # 工具注册/执行 (服务名: tools)
│ ├── lsp/ # LSP 客户端 (服务名: lsp)
│ ├── config/ # 配置服务 (服务名: config)
│ └── CMakeLists.txt # 插件构建 (按依赖顺序)
├── dstalk-cli/ # 命令行前端 (ANSI)
│ ├── src/main.cpp
│ └── CMakeLists.txt
├── dstalk-gui/ # 图形化前端 (SDL3)
│ ├── src/main.cpp
│ └── CMakeLists.txt
├── examples/ # 示例代码
│ └── example_plugin/
│ └── example_plugin.cpp # 插件开发示例
├── tests/ # 集成测试
│ └── smoke_test.cpp
├── CMakeLists.txt # 根 CMake
└── README.md
```
---
## 公开 API
头文件:
- [dstalk_host.h](dstalk-core/include/dstalk/dstalk_host.h) — 宿主 API、插件生命周期
- [dstalk_services.h](dstalk-core/include/dstalk/dstalk_services.h) — 服务接口 vtable 定义
- [dstalk_types.h](dstalk-core/include/dstalk/dstalk_types.h) — 共享类型
- [dstalk_lsp.h](dstalk-core/include/dstalk/dstalk_lsp.h) — LSP 便捷函数
```c
/* 宿主生命周期 */
int dstalk_init(const char* config_path);
void dstalk_shutdown(void);
/* 插件管理 */
int dstalk_plugin_load(const char* path);
int dstalk_plugin_unload(int plugin_id);
int dstalk_plugin_list(char** output_json);
/* 服务查询 —— 通过名称获取插件注册的 vtable */
void* dstalk_service_query(const char* service_name, int min_version);
/* 事件总线 */
int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata);
int dstalk_event_emit(int event_type, const void* data);
void dstalk_event_unsubscribe(int subscription_id);
/* 配置 */
const char* dstalk_config_get(const char* key);
int dstalk_config_set(const char* key, const char* value);
/* 内存管理 */
void* dstalk_alloc(size_t size);
void dstalk_free(void* ptr);
char* dstalk_strdup(const char* s);
/* LSP 便捷函数 (委托给 lsp 插件) */
int dstalk_lsp_start(const char* server_cmd, const char* language);
void dstalk_lsp_stop(void);
int dstalk_lsp_open(const char* uri, const char* content, const char* language_id);
int dstalk_lsp_close(const char* uri);
int dstalk_lsp_diagnostics(const char* uri, char** output);
int dstalk_lsp_hover(const char* uri, int line, int character, char** output);
int dstalk_lsp_completion(const char* uri, int line, int character, char** output);
```
**调用约定:**
- 所有字符串均为 UTF-8 编码
- 通过 `dstalk_service_query` 获取服务 vtable再通过函数指针调用具体功能
- `dstalk_free` 释放所有 API 返回的堆内存
- 返回 `0` 成功,负数表示错误码
**跨语言调用示例:**
```c
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <stdio.h>
int main(void) {
if (dstalk_init("config.toml") != 0) {
fprintf(stderr, "初始化失败\n");
return 1;
}
// 查询 AI 服务
const char* provider = dstalk_config_get("ai.provider");
if (!provider) provider = "ai.deepseek";
const dstalk_ai_service_t* ai = dstalk_service_query(provider, 1);
if (ai) {
ai->configure(provider, "https://api.deepseek.com/v1", "sk-xxx",
"deepseek-v4-pro", 4096, 0.7);
}
dstalk_shutdown();
return 0;
}
```
---
## FAQ
**Q: 为什么不用 Rust**
A: 团队对 C/C++ 生态更熟悉C++20 的现代特性已能让我们写出安全高效的代码。且 CDLL 需要稳定的 C ABIC/C++ 最直接。
**Q: 支持哪些模型?**
A: 主要支持 DeepSeek V4同时兼容 OpenAI GPT 系列和 Anthropic Claude 系列的 API。通过配置文件切换 API 基地址和密钥即可。
**Q: 为什么同时做 CLI 和 GUI**
A: CLI 适合终端/SSH/CI 环境GUI 适合需要富文本和鼠标交互的场景。两者共享同一核心 DLL功能一致。
**Q: 如何配置 API Key**
A: 首次运行前,手动创建项目目录下的 `config.toml`,按需选择后端:
```toml
# 选择 AI 后端插件: ai.deepseek 或 ai.anthropic
ai.provider = "ai.deepseek"
# DeepSeek
api.base_url = "https://api.deepseek.com/v1"
api.api_key = "sk-xxxxxxxx"
api.model = "deepseek-v4-pro"
# Anthropic Claude (切换 ai.provider 为 "ai.anthropic" 即可)
# api.base_url = "https://api.anthropic.com/v1"
# api.api_key = "sk-ant-xxxxxxxx"
# api.model = "claude-opus-4-20250514"
```
修改 `ai.provider` 字段即可在不同后端间切换,无需改动代码。
---
## 路线图
| 阶段 | 内容 |
|------|------|
| **Phase 1** | 项目骨架、CMake 构建、DLL 导出、CLI 前端主循环 |
| **Phase 2** | HTTPS 网络层、DeepSeek API 对接、基本对话 |
| **Phase 3** | ~~流式输出、多轮会话、文件读写工具、CLI 体验对齐~~ |
| **Phase 4** (当前) | ~~插件化架构重构、多后端 AI、LSP 客户端、SDL3 GUI~~ |
| **Phase 5** | GUI 完善、工具调用Function Calling、插件生态、多语言扩展 |
---
## 贡献指南
1. Fork 仓库并克隆到本地
2. 创建分支: `git checkout -b feature/功能名`
3. 编码: C 代码 K&R 风格C++ 代码 LLVM 风格
4. 确保 `cmake --build build` 通过
5. 提交 PR描述改动内容和动机
### 代码规范
- C: C11 标准,头文件 `#pragma once`
- C++: C++20 标准,优先标准库,必要时引入 Boost
- 内存: C++ 优先 RAIIC 代码显式管理
- 对外接口: `extern "C"` 纯 C 函数,不抛异常
---
## 技术风险与对策
| 风险 | 对策 |
|------|------|
| C++ 开发效率低于脚本语言 | Boost 库弥补;核心 API 稳定后开发速度不会慢于竞品 |
| OpenSSL 跨平台兼容性 | Conan2 锁定版本,统一 Windows/Linux/macOS HTTPS 后端 |
| SDL3 依赖体积较大 | GUI 默认关闭,需要图形前端时再启用 `DSTALK_BUILD_GUI` |
| AI API 协议变更 | 适配层独立模块,变更时只改一处 |
--- ---
@@ -394,4 +92,4 @@ GNU General Public License v3. Copyright (c) 2026 dstalk contributors.
--- ---
[dstalk.top](https://dstalk.top) | [GitHub](https://github.com/dstalk/dstalk) | [Issue 反馈](https://github.com/dstalk/dstalk/issues) [dstalk.top](https://dstalk.top) | [GitHub](https://github.com/dstalk/dstalk)

174
agents/POSTMORTEM.md Normal file
View File

@@ -0,0 +1,174 @@
# dstalk 项目踩坑记录 (Postmortem)
> **性质**: 历史记录,非设计文档。每个条目基于已发生的事故。
> **受众**: 全员。新员工/新会话第一份应读材料。
> **更新规则**: 每次新事故发生后,由 QA 追加一条记录并更新汇总表。
---
## PM-001: clang-cl 增量构建 stale .obj 文件
- **首次发现**: Wave 5 / CEO 验收阶段 / `plugins/lsp/src/lsp_plugin.cpp``plugins/tools/src/tools_plugin.cpp`
- **症状**:
```
# 编译通过,但运行行为与源码不一致;报错行号指向磁盘上已不存在的旧代码
# clang-cl 报告的 warning/error 行内容与当前源文件内容不符
```
- **根因**: clang-cl 增量构建的时间戳/依赖追踪偶尔遗漏源文件的磁盘修改,重复使用过期的 `.obj` 缓存。并非 CMake 依赖图错误——`cmake --build` 认为目标已是最新,跳过重编译。
- **影响范围**: 任何使用 clang-cl 增量编译的 `.cpp` 文件lsp_plugin 和 tools_plugin 至少各中招一次。
- **修复方法**:
```bash
rm -f build/**/<file>.cpp.obj # 删除可疑 .obj 强制重编译
cmake --build build --config Release
```
彻底保险:`cmake --build build --config Release --clean-first`(但耗时)。
- **防御性规则**(同步到 WORKFLOW.md §6:
- **R-STALE-OBJ**: CEO 验收编译前,若运行结果与源码不符,先怀疑 stale obj`rm -f build/**/<suspect>.cpp.obj` 重试。
- **检测方法**:
- 对比 `.cpp` 的 mtime 与 `.cpp.obj` 的 mtimeobj 更旧但 `ninja -n` 显示 "no work to do" 时判定 stale。
- CI 中关键构建PR merge / release使用 `--clean-first`。
---
## PM-002: Boost.JSON header-only 链接错误
- **首次发现**: Wave 3 (胡桐 W3) / Wave 7 (王测 W7) / 5 个插件翻译单元
- `plugins/deepseek/src/deepseek_plugin.cpp`
- `plugins/anthropic/src/anthropic_plugin.cpp`
- `plugins/session/src/session_plugin.cpp`
- `plugins/lsp/src/lsp_plugin.cpp`
- `plugins/tools/src/tools_plugin.cpp`
- **症状**:
```
# MSVC linker (Release /MD):
error LNK2001: unresolved external symbol "class boost::json::value ..."
error LNK2001: unresolved external symbol "class boost::json::object ..."
# 多个 boost::json 符号未解析
```
- **根因**: Boost 1.86 废弃了 `BOOST_JSON_HEADER_ONLY` 宏——即使定义了该宏,编译器也忽略它,导致 header-only 模式不生效。缺失的 out-of-line 实现(`boost::json::value`、`boost::json::object` 等)在链接时找不到符号。
- **影响范围**: 任何 `#include <boost/json.hpp>` 且未同时 include `<boost/json/src.hpp>` 的翻译单元。
- **修复方法**:
```cpp
#include <boost/json.hpp>
#include <boost/json/src.hpp> // 必须!提供 out-of-line 实现
```
每个使用 `<boost/json.hpp>` 的 `.cpp` 文件中,**恰好一次** include `<boost/json/src.hpp>`。
- **防御性规则**:
- **R-BOOST-JSON-SRC**: 任何插件 `#include <boost/json.hpp>` 时必须同时 `#include <boost/json/src.hpp>`;新增插件 CI 检查是否有缺失。
- **检测方法**:
- `grep -rl '<boost/json.hpp>' plugins/ | xargs grep -L '<boost/json/src.hpp>'` — 有前者无后者的文件即缺陷。
- CI Release 构建必须链接成功(/MD 下 Boost.JSON 符号靠 src.hpp 提供,不链接 boost_json 库)。
---
## PM-003: 跨 DLL 堆释放(/MT 必崩,/MD 侥幸)
- **首次发现**: Wave 2.1 / 陈风 W2.1 (安全审计触发 + B3 评审)
- `plugins/file_io/src/file_io_plugin.cpp` — 用 `::malloc` 分配,调用方用 `std::free` 释放
- `plugins/tools/src/tools_plugin.cpp:58` — `std::free(host 分配的指针)`
- `plugins/session/src/session_plugin.cpp:166` — 同上模式
- `tests/smoke_test.cpp` — 三处 `std::free` 应改为 `dstalk_free`
- **症状**:
```
# /MD (动态 CRT): 运行正常(共享 ucrtbase.dll不同 DLL 的 malloc/free 落在同一个堆——侥幸)
# /MT (静态 CRT): 立即崩溃 / heap corruption
HEAP CORRUPTION DETECTED: after Normal block (#XXX) at 0x...
# 或静默内存损坏,延迟在无关位置崩溃
```
- **根因**: Windows 每个 DLL 拥有独立 CRT 堆(/MT或共享同一个 CRT DLL 堆(/MD。插件在自身 CRT 堆上 `malloc` 得到的指针,传给 host 后 host 调用 `free` —— host 的 CRT 尝试释放一个不属于它的堆上的地址 → 未定义行为。当前项目使用 `/MD` 故不立即触发,但这是一个精度定时炸弹(任何人改 CRT 链接方式就会引爆)。
- **影响范围**: 所有插件与 host 之间传递动态分配内存的场景。涵盖 `malloc`/`free`/`strdup`/`new`/`delete`。
- **修复方法**:
```c
// 错误(跨 DLL 堆):
char* buf = (char*)::malloc(256);
// ...
std::free(buf); // 释放者与分配者不同 CRT 堆
// 正确(统一堆):
char* buf = (char*)g_host->alloc(256);
// ...
g_host->free(buf); // 通过 host_api 函数指针回到同一个堆
```
详见 `docs/reference/plugin-abi.md` §2-§3。
- **防御性规则**:
- **R-NO-RAW-ALLOC**: 插件代码严禁直接调用 `malloc`/`free`/`strdup`/`new`/`delete` 处理跨 DLL 边界数据;必须使用 `host->alloc`/`host->free`/`host->strdup`。
- **R-TEST-MT**: CI 至少一个配置使用 `/MT` 构建并跑 smoke test确保跨堆问题在任何 CRT 模式下暴露。
- **检测方法**:
- `grep -nP '\b(std::)?(malloc|free|strdup|realloc)\b' plugins/**/*.cpp` — 任何命中需审计是否是私有堆操作(不跨 DLL 边界)。
- `grep -nP '\bnew\b.*\bdelete\b'` 同审计。
- ASan (`-fsanitize=address`) 在 Windows 下对跨堆释放敏感,可捕获 heap-use-after-free / alloc-dealloc-mismatch。
---
## PM-004: plugin_loader 单插件失败导致全部初始化终止
- **首次发现**: Wave 2.1 / 陈风 W2.1 (side-effect 发现) → Wave 9.8 / 黄岭 W9.8 (正式修复)
- `dstalk-core/src/plugin_loader.cpp` — `initialize_all()` 函数
- **症状**:
```
# 假设加载 A→B→C 三个插件B 的 on_init 返回 -1:
# 旧行为:
[ERROR] Plugin 'B' init failed (code -1)
# A 已初始化成功,但 B 失败后 initialize_all 立即 return -1
# C 完全未被尝试初始化——即使 C 不依赖 B
# 症状: "明明只坏了一个插件,为什么其他插件也不工作?"
```
- **根因**: 旧版 `initialize_all()` 使用 fail-fast 模式 —— 任何一个 `on_init` 返回非零,整个函数立即返回 `-1`,剩余插件全部跳过。没有"部分成功"的概念。
- **影响范围**: 所有依赖于 `initialize_all()` 正常完成的调用方CLI、GUI、测试。一个低优先级插件失败会让整个应用退化。
- **修复方法** (黄岭 W9.8):
- 将 fail-fast 改为 fail-continue单个 `on_init` 失败 → `fprintf(stderr, ...)` + 标记 `failed_names` + `failed_count++` → `continue` 下一个。
- 依赖已失败插件的下游插件自动跳过并 `[WARN]`。
- 返回值语义: `0` = 全部成功;`>0` = 失败的插件数;`<0` = 严重错误(循环依赖 / host_api null
- 代码位置: `plugin_loader.cpp:202-256`。
- **防御性规则**:
- **R-LOADER-CONTINUE**: 插件加载/初始化**永不 fail-fast**;单点故障不得级联阻断无关插件。
- **R-LOADER-RETVAL**: `initialize_all` 返回值 >0 表示部分成功(调用方必须区分 "完全失败" 和 "部分退化")。
- **检测方法**:
- 单元测试:加载 3 个插件,中间一个 mock 为 "always init fail",验证第 1、3 个正常初始化,且返回值为 1。
- smoke test 验证所有 9 个插件加载(当前预期 9/9 通过)。
---
## PM-005: git push --force 未在子代理 prompt 中禁止
- **首次发现**: Wave 1+ / CEO 规则层面 / 无特定代码文件
- **症状**:
```
# 子代理在独立会话中执行 git 操作,可能使用 --force 推送
# 远端历史被改写,其他协作者的本地分支与远端不一致
# 没有 git hook 阻止,也没有 prompt 模板明确禁止
```
- **根因**: CEO 规则明确禁止 `push --force`,但该规则只在主会话中有效。子代理的 prompt 模板(`WORKFLOW.md` §9没有包含此禁忌子代理不知道这条规则。
- **影响范围**: 所有通过 `Agent` 工具派出的子代理。任何子代理执行 `git push` 时都可能误用 `--force`。
- **修复方法**:
1. 子代理 prompt 模板中增加:"禁止 git push --force / --force-with-lease禁止 git reset --hard origin/*"
2. 可选:服务端 git hookGitHub branch protection禁止 force push 到 master。
- **防御性规则**:
- **R-NO-FORCE-PUSH**: 每个子代理任务 prompt 必须包含 "禁止 git push --force / --force-with-lease";如需改写历史仅限本地 `git rebase -i`(未推送的提交)。
- **检测方法**:
- GitHub branch protection rule: master 分支禁止 force push。
- 每次 CEO 验收后检查 `git reflog` 是否有 force push 痕迹。
---
## 防御性规则汇总表 (§6)
| 规则 ID | 事故 | 规则一句话 |
|---------|------|-----------|
| R-STALE-OBJ | PM-001 | 运行结果与源码不符时先怀疑 stale obj`rm -f build/**/<file>.cpp.obj` 重编 |
| R-BOOST-JSON-SRC | PM-002 | `#include <boost/json.hpp>` 的 .cpp 必须同时 `#include <boost/json/src.hpp>` |
| R-NO-RAW-ALLOC | PM-003 | 插件代码严禁 `malloc`/`free`/`strdup`/`new`/`delete` 跨 DLL 边界;统一用 `host->alloc/free/strdup` |
| R-TEST-MT | PM-003 | CI 至少一个配置用 `/MT` 构建并跑 smoke test |
| R-LOADER-CONTINUE | PM-004 | 插件加载/初始化永不 fail-fast单点故障不级联阻断 |
| R-LOADER-RETVAL | PM-004 | `initialize_all` 返回值 >0 = 部分成功,调用方必须区分 |
| R-NO-FORCE-PUSH | PM-005 | 子代理 prompt 必须禁止 `git push --force` / `--force-with-lease` |
| R-MAIL-SCOPE | Mailroom v1 | 子代理不得读写超出自身 inbox 之外的他人邮箱内容,仅可投递新邮件 |
| R-MAIL-NO-DELETE | Mailroom v1 | 接收者不可物理删除邮件,只能移到 `agents/mailroom/archive/W<n>/`;删除仅限 CEO |
---
## 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-27 | 1.0 | W10.4 初始化。记录 PM-001 至 PM-005共 5 条事故 7 条防御性规则。 |

207
agents/PROMPT_TEMPLATE.md Normal file
View File

@@ -0,0 +1,207 @@
# 子代理 Prompt 模板
> **版本**: 1.0 (W10.3 朱晴设计)
> **用途**: CEO 派活给子代理时复制此模板填写。字段标注"必填"的不可省略。
---
## 模板骨架
```
你扮演 dstalk 团队成员 <姓名> (<agent-id>) — <角色>。读 `agents/<agent-id>/profile.md` 了解你的人设。
**W<n>.<m> 任务**<一句话目标>
**背景**<2-4 句,说明为什么做这个、依赖什么前置工作、不做的后果是什么>
**请读**:
- <文件 1> ← 控制在 6 个以内,超过说明任务拆得不够细
- <文件 2>
- ...
**前序成果** (必填):
- `docs/reference/<某文档>` — W<x>.<y> <作者> 产出,<一句话关联>
- `agents/<id>/profile.md` — 前序任务记录,看历史决策
- <如无需依赖前序 Wave写"无"并说明原因>
**任务范围** (必填):
- 必做: <具体、可验证的事情>
- 可做: <锦上添花但不强制的事>
- 不做: <明确排除的事情,即使看似相关也不碰>
**禁忌** (必填):
- 不要碰 `<某目录>/<某文件>` — <原因,如 "W<x>.<y> <人名> in-flight">
- 不要 <某行为> — <原因,如 "与 plugin-abi.md §3.2 冲突">
- <若当前无 in-flight 冲突,写"当前无 in-flight 工作区冲突"——但不能留空>
**交付清单** (必填,每条带验收标准):
1. <文件路径>: <具体改动内容> — 验收: <可观测的通过条件>
2. <文件路径>: <具体改动内容> — 验收: <可观测的通过条件>
3. `agents/<agent-id>/profile.md`: performance_log 追加 W<n>.<m> — 验收: 新条目出现在文件中
- **Finding 关联**如有Fixes: F-<Wave>-<N>
**验证步骤**:
cmake --build build --config Release && ctest --test-dir build -C Release
# 期望: 0 error, 0 warning, 100% smoke test pass
**字数上限**: 250 字(报告正文不含代码块,统一标准)
**profile.md 更新**: 必须追加rating: <由执行者自评 (A/A-/B+/B/C)>
```
---
## 反模式案例
以下错误均来源于 Wave 1-9 实战总结:
### 反模式 1: "禁忌"为空
```
❌ 案例: W5.1 派陈风改 plugin_loader.cpp同时派孙码改 host.cpp
两人都改了 g_diag_callback 的声明方式,合并冲突导致回滚 40 分钟。
根因: CEO 没在 prompt 里写"不要碰 host.cpp 的 g_diag_callback"。
✅ 修复: 禁忌必须显式列出所有 in-flight 子代理的工作区文件。
格式: "不要碰 src/host.cpp — W5.1 孙码 in-flight改 g_diag_callback"
```
### 反模式 2: "请读"列出 30 个文件
```
❌ 案例: W6.1 某次 prompt 列了 30 个文件要子代理读,
子代理上下文窗口超限,读完文件后只剩 2KB 预算用于实际工作,
输出质量断崖式下跌。
✅ 修复: "请读"控制在 6 个以内。超过说明任务粒度太粗,
CEO 应先拆成 2-3 个子任务再派。
```
### 反模式 3: "交付"用模糊词
```
❌ 案例: "改善错误处理"、"优化性能"、"增强可维护性"
— 子代理改了 5 个文件CEO 验收时发现改的不是 CEO 想要的东西,
但 prompt 本身无法作为验收依据。
✅ 修复: 交付清单必须 numbered list + 每个条目有验收条件。
格式: "2. src/host.cpp: 增加 timeout 参数校验 — 验收: 传入 0 返回 -1"
```
### 反模式 4: 没说"前序成果"
```
❌ 案例: W8.1 子代理被要求"增加插件版本检查"
但 W1.1 林深已实现过类似的 compare_version()
子代理重新实现了整个函数,逻辑还比原版少了一个边界检查。
✅ 修复: 前序成果字段必填。如果确实没有可依赖的前序工作,写"无"并说明原因。
```
### 反模式 5: 字数上限不统一
```
❌ 案例: W3 上限 200 字, W5.1 上限 300 字, W9.6 无上限,
子代理每次都要猜 CEO 想要多详细,报告质量波动大。
✅ 修复: 统一 250 字。如需更详细的内容(如大量 diff
让子代理在报告末尾注明"详见 <文件>"即可。
```
### 反模式 6: 缺"任务范围"
```
❌ 案例: W2.1 陈风接到"修复跨 DLL 堆问题",任务只说改 plugin_loader.cpp
但他顺便重构了 plugins/tools/file-io 的内存分配——
结果是好的但风险高,若当时 file-io 有 in-flight 子代理就冲突了。
✅ 修复: 任务范围给"必做/可做/不做"三档。
"可做"是子代理的自由发挥区,"不做"是硬边界。
```
### 反模式 7: 缺少 Finding 关联
```
❌ 案例: W14.3 曹武的修复任务修复了 F-13.5-1 (network_plugin TLS 证书验证)
但 prompt 和报告均未标注 Fixes: F-13.5-1
导致 findings-registry 无法追踪修复进度,发现状态停留在 OPEN。
✅ 修复: 所有修复审计发现的任务必须在 prompt 交付清单中标注
Fixes: F-<Wave>-<N>(对应 findings-registry.md 中的发现 ID
示例W14.3 曹武的修复任务应标注 `Fixes: F-13.5-1`。
```
---
## 正模式示例: W9.4 林深 (回写)
以下基于 W9.4 的实际交付成果回写 prompt展示正确用法
```
你扮演 dstalk 团队成员 林深 (architect-lin) — 架构师。读 `agents/architect-lin/profile.md` 了解你的人设。
**W9.4 任务**:撰写 `docs/reference/plugin-abi.md` Plugin ABI 契约文档
**背景**:跨 DLL 堆纪律在 W2.1 暴露问题后靠口头传递,需要一份规范性文档。
必须覆盖内存所有权、注册契约、生命周期、线程安全、依赖声明,
让插件作者和 host 维护者有统一的"法律文本"可查。
**请读**:
- dstalk-core/include/dstalk/dstalk_host.h
- dstalk-core/include/dstalk/dstalk_types.h
- dstalk-core/src/plugin_loader.cpp
- dstalk-core/src/host.cpp
- dstalk-core/src/service_registry.cpp
- dstalk-core/src/event_bus.cpp
**前序成果** (必填):
- W2.1 陈风: 跨 DLL 堆纪律的代码修复 (plugin_loader.cpp, host.cpp) — 作为 §3 的素材
- W1.1 林深: g_diag_callback atomic 修复 (host.cpp) — 作为 §6.1 的素材
- W2.2 曹武: api_key 安全清零模式 — 作为 §5 安全实践的参考
**任务范围** (必填):
- 必做: 覆盖 7 个契约要点 (DSTALK_API_VERSION / 内存所有权 / 跨DLL堆纪律 /
register_service / on_init-on_shutdown / 回调线程安全 / 依赖声明)
每个要点含代码示例 + 反例 + 设计理由
- 可做: 追加"变更历史"表格,风格参考现有 docs/ 文件
- 不做: 修改任何 .cpp / .hpp 源码文件。本任务只产出文档。
**禁忌** (必填):
- 不要碰 src/ 目录 — 这是纯文档任务
- 不要碰 agents/ 目录 — W9.3 杨帆 in-flight 改 WORKFLOW.md
- 不要改 dstalk_host.h 的 API_VERSION 值 — 这需要单独的 W 提案
**交付清单** (必填,每条带验收标准):
1. `docs/reference/plugin-abi.md`: 新建文件,约 200 行7 个契约要点 — 验收: 文件存在且每个 § 有代码 snippet + 反例
2. `docs/README.md`: reference 区新增 plugin-abi.md 入口 — 验收: git diff 显示新增一行链接
3. `agents/architect-lin/profile.md`: performance_log 追加 W9.4 — 验收: 新条目出现在文件中
**验证步骤**:
cmake --build build --config Release && ctest --test-dir build -C Release
**字数上限**: 250 字
**profile.md 更新**: 必须追加rating: <由执行者自评>
```
---
## CEO 派活前 5 步检查
派活前逐项确认,缺一不可:
| # | 检查项 | 操作 | 写入模板字段 |
|---|--------|------|-------------|
| 1 | **列 in-flight 工作区** | 查当前有哪些子代理在跑、他们各自改了哪些文件/目录 | `禁忌` |
| 2 | **找前序 Wave 产出** | 从 `agents/*/profile.md` 的 performance_log 追溯与本任务关联的之前 Wave 的产出文件 | `前序成果` |
| 3 | **设定任务范围三档** | 把需求拆成"必做/可做/不做",不做 = 硬边界 | `任务范围` |
| 4 | **统一字数上限** | 固定 250 字,不再按任务调整 | `字数上限` |
| 5 | **扫候选执行者 inbox** | 查看 `agents/mailroom/inbox/<agent-id>/` 是否有 pending handoff / blocker有阻塞先处理阻塞 | `前序成果` / `禁忌` |
---
## 参考
- [WORKFLOW.md §9](./WORKFLOW.md) — 原始模板(历史备份)
- [plugin-abi.md](../docs/reference/plugin-abi.md) — 完整任务产出示例
- [architect-lin/profile.md](./architect-lin/profile.md) — W9.4 执行记录

View File

@@ -1,5 +1,7 @@
# dstalk 多 Agents 协作系统 # dstalk 多 Agents 协作系统
> **新会话恢复 CEO 模式?先读 [WORKFLOW.md](WORKFLOW.md)** —— 工作流、波次节奏、6 阶段协作、子代理 prompt 模板都在那里。
## 公司宗旨 ## 公司宗旨
- **第一性原理**:从问题本质出发,不被既有结构和惯性束缚 - **第一性原理**:从问题本质出发,不被既有结构和惯性束缚

46
agents/STATUS.md Normal file
View File

@@ -0,0 +1,46 @@
# dstalk 实时编制状态
> **最后更新**: 2026-06-03
> **数据来源**: 由 `scripts/refresh_status.py` 自动扫描全部 16 个 `agents/*/profile.md` + 5 个 `agents/groups/*.md` 生成。
## 表 1员工状态16 人)
| Agent ID | 姓名 | 角色 | 最近一次贡献 | perf_log | 当前小组 | 状态 |
|---|---|---|---|---|---|---|
| architect-huang | 黄岭 | 架构师 | W13.4 深度审计 lsp_plugin.cpp (749行) | 3 | -- | idle |
| architect-lin | 林深 | 架构师 | W22.6 plugin_loader 新增 validate_dependencies() —— 遍历所有已加载插件 deps[] 做缺失依赖检测 ... | 11 | grp-ai-plugins, grp-quality-core | idle |
| architect-yang | 杨帆 | 架构师 | W15.7 根据 W15.4 审查发现修复 WORKFLOW.md 3 处交叉引用 | 6 | -- | idle |
| designer-zhu | 朱晴 | UX/CLI 设计师 | W19.4 定义 CLI 退出码语义 — 0=正常退出, 1=用户中断(SIGINT/Ctrl+C), 2=致命错误 | 4 | grp-cli-ux | idle |
| devops-hu | 胡桐 | DevOps 工程师 | W22.1 测试覆盖率度量 + CI 门禁 | 12 | grp-build-matrix | idle |
| devops-ma | 马奔 | DevOps 工程师 | W19.5 CI 跨平台构建矩阵 Phase 1 -- YAML 语法验证 + VS 路径修复 | 4 | grp-build-matrix | idle |
| engineer-chen | 陈风 | 工程师 | W19.2 修复 plugin_loader 4 条 MEDIUM 发现 (F-18.3-2/3/4/5) | 6 | -- | idle |
| engineer-li | 李明 | 工程师 | W12.5 使用 scripts/refresh_status.py 重新生成 agents/STATUS.md (46行) | 4 | -- | idle |
| engineer-sun | 孙宇 | 工程师 | W22.4 prompt stdin pipe 打通 -- --prompt 无参数或参数为 - 时从 stdin 读取 | 8 | -- | idle |
| engineer-zhao | 赵码 | 工程师 | W9.6 CLI新增/history[N]命令,含三种边界处理;/status增加history count | 6 | grp-ai-plugins, grp-cli-ux | idle |
| engineer-zhou | 周岩 | 工程师 | W22.5 get_default_session_path() 加 mkdir 保障 + 静态缓存 | 8 | -- | idle |
| qa-liu | 刘静 | 质量工程师 | W19.2 验证 plugin_loader MEDIUM 发现修复 — F-18.3-2 诊断日志/F-18.3-3 路径验证/F-18.3-4 日... | 4 | grp-security-audit | idle |
| qa-wang | 王测 | 质量工程师 | W21.6 anthropic/deepseek plugin 单元测试框架搭建 | 13 | grp-cli-ux, grp-quality-core | idle |
| qa-xu | 徐磊 | 质量工程师 | W13.6 扩展 tests/smoke_test.cpp (430→623 行, +193): 新增 4 个回归保护 case — R1 conte... | 10 | grp-security-audit | idle |
| security-cao | 曹武 | 安全工程师 | W14.3 修复 W13.5 审计发现 — 路径遍历 + 全局状态加锁 + 9 vtable try/catch | 10 | grp-security-audit | idle |
| writer-deng | 邓书 | 技术作家 | W12.6 ABI 文档缺口填补: plugin-abi.md 追加 §8 异常安全(涵盖 service vtable 函数 | 3 | -- | idle |
> **状态判定规则**: 基于 `performance_log` 最后一条的 `rating`——`ongoing` 视为 `working`,其余 (`A/A+/B/completed/done/success/good`) 视为 `idle`。
## 表 2工作组状态5 组)
| group_id | 名称 | lead | members | mission | active_tasks | 状态 |
|---|---|---|---|---|---|---|
| grp-ai-plugins | AI插件小组 | 赵码 | 赵码, 林深 | AI相关插件的功能完善和架构优化 | -- | 待命 |
| grp-build-matrix | 构建矩阵小组 | 马奔 | 马奔, 胡桐 | 完善 CI 跨平台构建矩阵Linux/WindowsClang/MSVC缩短构建时间 | -- | 待命 |
| grp-cli-ux | CLI 体验小组 | 赵码 | 赵码, 朱晴, 王测 | 改进 dstalk-cli 的交互体验、健壮性和可测试性 | B3: CLI 交互增强(信号处理、状态命令、退出码语义、管道输入) | 执行中 |
| grp-quality-core | 核心质量小组 | 王测 | 林深, 王测 | 确保插件化架构的代码质量和测试覆盖 | -- | 待命 |
| grp-security-audit | 安全审计小组 | 曹武 | 曹武, 徐磊, 刘静 | 全面审计 dstalk 的内存安全、API 密钥处理、反序列化路径 | -- | 待命 |
> **成员列来源**: 以 `agents/groups/*.md` 为准(部分成员 profile 未同步更新 `current_groups`)。
## Wave 进度
**已完成高水位**: W22.6(基于 16 份 profile.md 的 performance_log 聚合)
**已发现 Wave 编号**: W1.1, W2.1, W2.2, W5.1, W6.1, W7, W9.3, W9.4, W9.6, W9.10, W10.1, W10.2, W10.3, W10.4, W11, W11.1, W11.2, W11.3, W11.6, W11.7, W12, W12.1, W12.2, W12.4, W12.5, W12.6, W13.1, W13.2, W13.3, W13.4, W13.5, W13.6, W14, W14.1, W14.2, W14.3, W14.4, W14.5, W15.1, W15.2, W15.3, W15.4, W15.5, W15.6, W15.7, W15.8, W15.9, W16.1, W16.2, W16.3, W16.4, W16.5, W17.1, W17.3, W17.4, W18.1, W18.2, W18.3, W18.4, W19, W19.1, W19.2, W19.3, W19.4, W19.5, W20.2, W20.3, W20.4, W20.5, W20.6, W21.1, W21.2, W21.4, W21.5, W21.6, W22.1, W22.2, W22.4, W22.5, W22.6

492
agents/WORKFLOW.md Normal file
View File

@@ -0,0 +1,492 @@
# dstalk 团队工作流CEO 多代理协作模式)
> 本文档供 CEO主会话和所有员工子代理参考。新会话恢复 CEO 模式时**第一件事**就是读这里。
## 1. 编制与并行度
- **团队规模 = CPU 物理核数dstalk = 16 人)**1 CEO + 3 架构师 + 5 工程师 + 3 QA + 2 DevOps + 1 设计师 + 1 作家 + 1 安全
- 每个员工有 `agents/<id>/profile.md`:含 personality / background / strengths / weaknesses / performance_log
- 单波并行子代理数:**69 路**(工作中员工占总人数 50150%,即 824 路)。超过会上下文炸;少于 4 路浪费 CPU
- 子代理调度:`Agent` 工具 + `subagent_type: general-purpose` + `run_in_background: true`,让通知机制驱动,**不要 poll**
## 2. 6 阶段协作流程
每个 Wave 都按这个流程:
1. **propose提案** — CEO 把需求拆成 N 个候选任务(`W<wave>.<seq>` 编号,如 W2.1、W9.4
2. **vote投票** — 派 34 个子代理读代码独立评估、投票同意/反对/优化
3. **optimize优化** — 被反对的提案,提案方根据反对意见迭代
4. **integrate整合** — CEO 汇总成统一可执行计划
5. **execute执行** — 派对应专长的员工子代理实施
6. **CEO inspect验收**`cmake --build build --config Release` + `ctest -R smoke`0 error + 100% pass 才算过
简单任务可跳 2-4 直接 propose→execute→inspect。**有架构争议**的必须走完 6 阶段。
## 3. 两级管理
- **第一级CEO最优先**CEO 决策优先于一切小组自治CEO 验收是最终关卡
- **第二级(小组自治)**`agents/groups/grp-*.md` 定义工作小组,组内事务自决
- 冲突时 **CEO > group lead > 个人**
## 4. 任务分发禁忌(每个 Agent prompt 必带)
派子代理时**必须显式列出**
- **可读的文件**(白名单)
- **禁止碰的文件/目录**(防止与 in-flight 子代理冲突,例如 W7 改 smoke_test 时其他人禁碰 tests/
- **验证步骤**cmake --build + ctest
- **报告格式 + 字数上限**200300 字典型)
- **profile.md 更新要求**(每个任务必须追加 performance_log
不写禁忌 → 多个子代理同时改同一个文件 → 冲突回滚 → 时间浪费。
## 5. CEO 自查与验收节奏
- **每波结束**:跑一次完整 `cmake --build build --config Release` + `ctest -R smoke`
- **预存 bug**:发现编译错时先 `git stash` 试 master 是否干净——区分"子代理引入"和"预存 bug"。预存 bug CEO 自己改不甩锅
- **stale obj 问题**clang-cl 增量构建偶尔不识别源文件修改lsp_plugin/tools_plugin 踩过坑),症状是编译报告的行号源码内容与磁盘不符。解法:`rm -f build/**/<file>.cpp.obj` 强制重编
- **元数据完整性**:每波开始前运行 `python scripts/check_agents_metadata.py` 验证 agents/ 元数据完整性YAML / rating / 引用一致性0 error 才能进入执行阶段
- **CEO 验收通过后立刻 commit + push**(用户硬规则:"别忘了每次通过ceo验收的提交git并推送"
## 6. 关键技术约束(已踩坑,全员必读)
| 约束 | 由来 | 文档 |
|------|------|------|
| 跨 DLL 堆纪律:插件不得用 `std::malloc/std::free`,必须 `host->alloc/host->free/host->strdup` | 陈风 W2.1 | `docs/reference/plugin-abi.md` |
| Boost.JSON header-only每个使用 `<boost/json.hpp>` 的翻译单元必须 `#include <boost/json/src.hpp>` | 王测 W7 | (代码内 include) |
| C ABI + atomic 回调:跨 DLL 函数指针必须 `std::atomic<fn_ptr>` + `memory_order_acquire/release` | 林深 W1.1 | `docs/reference/plugin-abi.md` §6 |
| api_key 安全清零on_shutdown 必须 `volatile char*` 写零循环 + clear | 曹武 W2.2 | `docs/explanation/security-logging.md` |
| CRT`/MD` 动态 CRT`CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL` | 胡桐 W3 | (build 配置) |
| `initialize_all` 容错:单插件失败不阻断其他插件,返回失败计数 | 黄岭 W9.8 | `dstalk-core/src/plugin_loader.cpp` |
## 7. Wave 编号与历史
进入项目时**第一件事**:读 `agents/*/profile.md``performance_log`,从最大 W 号继续下一波。
**已完成的高水位**(截至 2026-05-27W1.1、W2.1、W2.2、W3、W4、W5.1、W6.1、W7、W8.1、W9.1、W9.3、W9.4、W9.6、W9.8。Wave 6 还有 W9.10 in-flight。
下一个新提案应从 **W10.x** 开始。
## 8. 用户消息模式识别CEO 行为指南)
| 用户说 | CEO 行动 |
|--------|----------|
| "团队不能闲着" / "去开会安排工作" | 立刻派下一波,**不要解释 / 不要问"派什么"**——基于 profile.md 弱项 + master 待办自己想 |
| "推送完了" | 该 commit 的 commit 完、push 完,**不要重复推**;继续下一波 |
| "ceo管理所有" / "小组自治" | 重申两级管理,不要把 CEO 决策权下放 |
| 沉默 / "继续" | 等 in-flight 完成 + 验收 + 自动派下一波 |
| "记录" / "保存" / "下次也要这样" | 写到 `agents/WORKFLOW.md`(公共)+ CEO 私有 memory双轨 |
## 9. 子代理 prompt 模板(复制即用)
```
你扮演 dstalk 团队成员 <姓名> (<agent-id>) — <角色>。读 `agents/<agent-id>/profile.md` 了解你的人设。
**W<n>.<m> 任务**<一句话目标>
**背景**<2-4 句,说明为什么做这个、依赖什么前置工作>
请读:
- <文件 1>
- <文件 2>
- ...
**交付**
1. <可执行步骤 1>
2. <可执行步骤 2>
**禁忌**
- 不要碰 <某目录>in-flight 中)
- 不要 <某行为>(与设计原则冲突)
实现完成后必须 cmake --build build --config Release 验证 0 error并跑 ctest -R smoke 验证 100% pass。
完成后更新 `agents/<agent-id>/profile.md` performance_log 追加 W<n>.<m> 项。
**报告格式**(不超过 N 字):
1. 改的代码行号 + 关键 diff
2. 测试输出最后几行
3. profile.md 已更新
```
> **本节模板已迁移到 [agents/PROMPT_TEMPLATE.md](PROMPT_TEMPLATE.md)**,本处保留备份。
## 10. 关联文档
- [README.md](README.md) — 团队花名册 + 公司宗旨
- [groups/](groups/) — 工作小组定义
- [../docs/reference/plugin-abi.md](../docs/reference/plugin-abi.md) — Plugin ABI 契约
- [../docs/explanation/architecture.md](../docs/explanation/architecture.md) — 架构哲学
- [../docs/explanation/plugin-lifecycle.md](../docs/explanation/plugin-lifecycle.md) — 插件生命周期
- [../docs/explanation/security-logging.md](../docs/explanation/security-logging.md) — 日志安全审计
## 11. 协作状态机(强制规则)
状态机定义每个 Wave 从提案到交付(或终止)的合法路径。所有状态转换必须满足明确条件,不存在"CEO 感觉可以就过"的模糊转换。
### 11.1 状态定义
| 状态 | 类型 | 含义 |
|------|------|------|
| PROPOSE | 活跃 | CEO 拆解需求为候选任务,编号 W&lt;n&gt;.&lt;m&gt; |
| VOTE | 活跃 | 3-4 名子代理独立评估提案,投票同意/反对/否决 |
| OPTIMIZE | 活跃 | 被否决的提案根据反对意见逐条修改 |
| INTEGRATE | 活跃 | CEO 汇总投票结果为统一可执行计划 |
| EXECUTE | 活跃 | 指派专长员工实施,每人有明确文件白名单和禁忌清单 |
| INSPECT | 活跃 | CEO 运行完整验收清单§12逐项核对 |
| SUCCESS | 终止 | Wave 完成git commit + git push |
| ABORT | 终止 | CEO 判定任务不再可行,丢弃本 Wave |
| ROLLBACK | 终止 | 不可恢复错误,回退触发改动后重新决策 |
### 11.2 状态转换图
```
+----------+
| PROPOSE |
+----+-----+
|
simple / EXPRESS | complex
+-------------+--------------+
| |
| v
| +----------+
| | VOTE |
| +----+-----+
| |
| +-------------+-------------+
| | >50% reject | pass |
| v v |
| +----------+ +-----------+ |
| | OPTIMIZE | | INTEGRATE | |
| +----------+ +-----+-----+ |
| | |
| v |
| +----------+ |
+--------------------->| EXECUTE |<-------+
+----+-----+
|
+----------+----------+
| done | fatal |
v v |
+----------+ +----------+ |
| INSPECT | | ROLLBACK | |
+----+-----+ +----------+ |
|
+---------+---------+
| pass | fail |
v v |
+----------+ +-------+ |
| SUCCESS | | FAIL | |
+----------+ +-------+ |
```
回边(图中未画以避免杂乱,均以文字列出):
- OPTIMIZE --re-vote--&gt; VOTE
- OPTIMIZE --fundamental rewrite--&gt; PROPOSE
- EXECUTE --EXPRESS escalation--&gt; VOTE
- INSPECT fail --fixable--&gt; EXECUTE
- INSPECT fail --design--&gt; OPTIMIZE
- INSPECT fail --fatal--&gt; ROLLBACK
- INSPECT fail --abandon--&gt; ABORT
全局出口(任意状态):
- ANY --CEO abort--&gt; ABORT
### 11.2.1 EXPRESS 快跳路径
快跳EXPRESS是将 PROPOSE&rarr;VOTE&rarr;OPTIMIZE&rarr;INTEGRATE 压缩为 PROPOSE&rarr;EXECUTE 的合法短路径。快跳适用条件为以下 **全部 7 条** 同时满足:
| # | 条件 | 验证方式 |
|---|------|----------|
| E1 | 改动源文件 &le; 2 个(不含 profile.md | `git diff --stat HEAD` 统计 changed files |
| E2 | 不改动 `dstalk-core/include/` 下的任何公共头文件 | `git diff --name-only HEAD` 与 include/ 交集为空 |
| E3 | 不改动 CMakeLists.txt / cmake/ 目录 / CMakePresets.json | `git diff --name-only HEAD` 与构建文件交集为空 |
| E4 | 不新增公共 API 面:无新 `dstalk_` 前缀函数声明、无新插件接口结构体 | diff 中公共头文件无新增函数声明 |
| E5 | 不涉及跨模块依赖变更:改动文件涉及 &le; 2 个顶层目录(如 `dstalk-core/`、单个 `plugins/<name>/` | `git diff --dirstat HEAD` 目录数 &le; 2 |
| E6 | CEO 在 WORKFLOW.md &sect;7 任务条目中显式标注 `[EXPRESS]` | 人工核对 &sect;7 |
| E7 | 无 OPEN 状态的 CRITICAL 发现(见 §14.3 CRITICAL 阻塞规则) | `grep "CRITICAL.*OPEN" agents/audits/findings-registry.md` 输出为空 |
满足全部 E1-E7 &rarr; CEO 可声明 EXPRESS 快跳,任务直接进入 EXECUTE跳过 VOTE / OPTIMIZE / INTEGRATE对应转换规则 **T17**
**EXPRESS 升级**:若执行者在 EXECUTE 阶段发现任务实际超出 EXPRESS 条件E1-E5 任一条不再成立),须立即报告 CEO。CEO 核实后移除 `[EXPRESS]` 标签并替换为 `[ESCALATED]`,任务从 EXECUTE 退回 VOTE 走完整治理路径,对应转换规则 **T18**
### 11.3 转换条件表
| # | 从 | 到 | 触发条件 | 决策者 |
|---|----|----|----------|--------|
| T1 | PROPOSE | VOTE | 任务涉及跨模块改动 OR 涉及架构决策 OR 涉及新增公共 API OR CEO 指定需评审 | CEO |
| T2 | PROPOSE | EXECUTE | 单文件改动 AND 不涉及公共头文件 AND 不涉及 CMakeLists.txt AND CEO 判定为简单任务 | CEO |
| T3 | VOTE | OPTIMIZE | (反对票数 / 总票数) &gt; 0.5 OR 任一投票者给出"否决"并附具体技术理由 | 自动(计票) |
| T4 | VOTE | INTEGRATE | 所有投票者标记"同意" OR (反对票 &le; 50% AND 无否决票) | 自动(计票) |
| T5 | OPTIMIZE | VOTE | 提案方完成修改,提交修订版提案申请重新投票 | 提案方 |
| T6 | OPTIMIZE | PROPOSE | 修改范围超出原提案 50%,实质成为新提案 | CEO |
| T7 | INTEGRATE | EXECUTE | CEO 输出统一执行计划(任务-执行者映射 + 文件白名单 + 禁止触碰清单 + 验证步骤) | CEO |
| T8 | EXECUTE | INSPECT | 所有指派的执行者子代理均已返回 done 报告(含 cmake build 0 error + ctest 100% pass 自述) | 自动(全员 done |
| T9 | EXECUTE | ROLLBACK | 任一执行者报告以下任一情况且 CEO 判定不可原地修复:段错误 / ABI 破坏 / CMake 无法 configure / 数据文件损坏 | CEO |
| T10 | EXECUTE | ABORT | CEO 判定需求不再成立 OR 外部依赖不可用 | CEO |
| T11 | INSPECT | SUCCESS | §12 验收清单 + §14.4 A1-A4 全部通过cmake configure 0 error AND cmake build 0 error 0 warning(改动文件) AND ctest 100% pass AND 测试目标数 &ge; 上一波 AND profile.md 已更新 AND 无未跟踪临时文件 AND git diff 无无关改动 | CEO |
| T12 | INSPECT | EXECUTE | 验收失败但满足全部:根因可定位到具体文件+行号 AND &le;2 个文件 AND &le;30 行改动 AND 不需重新设计 | CEO |
| T13 | INSPECT | OPTIMIZE | 验收暴露以下任一设计问题:接口不兼容(编译通过但运行时错)/ 性能偏差 &gt;50% / 架构假设被证伪(如单线程假设在多线程场景崩溃) | CEO |
| T14 | INSPECT | ROLLBACK | 验收发现不可逆副作用:文件错误删除或覆盖 / 二进制损坏 / .git 目录状态异常 | CEO |
| T15 | INSPECT | ABORT | CEO 判定继续修复成本 &gt; 重新执行成本(需改 &gt;5 个文件且涉及多个执行者重新协调) | CEO |
| T16 | ANY | ABORT | 用户明确指令中止 OR 触发安全红线(凭证泄露、未加密敏感数据落盘) | CEO |
| T17 | PROPOSE | EXECUTE | EXPRESS 快跳:同时满足 E1-E7见 &sect;11.2.1 EXPRESS 条件表AND CEO 在 &sect;7 任务条目标注 `[EXPRESS]` | CEO |
| T18 | EXECUTE | VOTE | EXPRESS 升级:执行者报告任务实际范围超出 EXPRESS 条件E1-E5 任一条不再成立AND CEO 核实后将 `[EXPRESS]` 改为 `[ESCALATED]` | CEO |
### 11.4 状态进入/退出动作
| 状态 | 进入动作 | 退出动作 |
|------|----------|----------|
| PROPOSE | CEO 在 WORKFLOW.md §7 登记 W&lt;n&gt; 编号 | 提案内容归档到 agents/ 目录 |
| VOTE | CEO 派 3-4 路 vote 子代理prompt 含提案全文和评审标准 | 计票结果写入 WORKFLOW.md §7 |
| EXECUTE | CEO 记录执行者名单 + 文件白名单 + 禁忌清单 | 汇总所有执行者报告 |
| INSPECT | CEO 运行 §12 完整验收清单 | 验收结果和通过/失败项写入 WORKFLOW.md §7 |
| SUCCESS | CEO 执行 git commit + git push | — |
| ROLLBACK | 执行 §13.6 ROLLBACK 协议 6 步 | 回退原因和影响文件写入 WORKFLOW.md §7 |
| ABORT | 记录中止原因和时间 | 本波 W 编号标记 ABORTED写入 WORKFLOW.md §7 |
## 12. 验收清单CEO inspect 硬指标)
以下全部为强制检查项,**缺一不可**。CEO 在 INSPECT 状态必须逐项核对,任一未通过即判定 inspect 失败。
### 12.1 构建检查
| # | 检查项 | 命令/方法 | 通过标准 |
|---|--------|-----------|----------|
| B1 | cmake configure | `cmake --preset release 2&gt;&amp;1` | exit code = 0stderr 无 "error:" |
| B2 | cmake build | `cmake --build build --config Release 2&gt;&amp;1` | exit code = 0stderr 无 "error:" |
| B3 | 编译警告 | B2 输出中每个 warning 所在文件与 `git diff --name-only HEAD` 比对 | 本次改动文件产生 **0 warning**;非改动文件的预存 warning 不阻塞验收但需记录在案 |
### 12.2 测试检查
| # | 检查项 | 命令/方法 | 通过标准 |
|---|--------|-----------|----------|
| T1 | ctest 全量 | `ctest --test-dir build --output-on-failure` | 100% tests passed, 0 tests failed |
| T2 | 测试数量 | `ctest -N --test-dir build` 统计测试目标数 | 测试目标数 &ge; 上一波记录值(覆盖不减少) |
| T3 | smoke 专项 | `ctest -R smoke --test-dir build` | 100% passedsmoke 是回归底线,不可失败) |
### 12.3 交付物检查
| # | 检查项 | 命令/方法 | 通过标准 |
|---|--------|-----------|----------|
| D1 | profile 更新 | 逐个检查本波每个执行者的 `agents/&lt;id&gt;/profile.md` | 每个执行者 performance_log 含本波 W 编号条目 |
| D2 | 无临时文件 | `git status --short` | 无 .tmp / .swp / *~ / .DS_Store / build/ 目录下的未跟踪文件 |
| D3 | diff 纯净性 | `git diff HEAD` 逐文件审核 | 改动仅涉及任务相关文件,无意外修改;任何疑问 diff 块需执行者解释 |
| D4 | 子代理自述 | 检查每个执行者子代理返回的最终消息 | 含 "cmake build 0 error" + "ctest 100% pass" 自述 |
### 12.4 验收结论映射
| 失败项组合 | 严重级别 | 回退目标 |
|------------|----------|----------|
| 仅 B1/B2/B3 失败 | 执行错误 | INSPECT &rarr; EXECUTE修复构建 |
| 仅 T1/T2/T3 失败 | 执行错误 | INSPECT &rarr; EXECUTE修复测试 |
| 仅 D1/D2/D3/D4 失败 | 流程错误 | INSPECT &rarr; EXECUTE补文档/清理文件) |
| B类 + T类 同时失败 | 设计错误 | INSPECT &rarr; OPTIMIZE |
| 失败涉及 &gt;2 个执行者 | 设计错误 | INSPECT &rarr; OPTIMIZE |
| 全部通过 | — | INSPECT &rarr; SUCCESS |
## 13. 失败回退路径(强制处理协议)
以下协议覆盖所有阶段的失败场景。每个场景的处理步骤为**强制规则**,不存在"CEO 视情况灵活处理"的模糊空间。
### 13.1 PROPOSE 阶段失败
- **症状**CEO 无法将需求拆解为可执行任务(需求模糊 / 范围过大 / 与现有架构冲突)
- **处理**CEO 直接重写提案。缩小范围或拆分为多个 Wave。本 Wave 不进入 VOTE。
- **重试上限**:最多重写 2 次。第 3 次仍不可行 &rarr; ABORT记录原因到 WORKFLOW.md §7。
- **责任人**CEO
### 13.2 VOTE 阶段否决
- **症状**&gt;50% 投票者反对 OR 任一投票者给出技术否决
- **处理步骤**
1. CEO 汇总所有反对票的具体理由,归档到 WORKFLOW.md §7
2. 进入 OPTIMIZE由原提案方根据反对意见逐条修改
3. 修改完成后重新进入 VOTE
4. **连续 2 轮 VOTE 均被否决** &rarr; 回到 PROPOSECEO 重新出提案(原方案方向放弃)
5. **连续 3 轮 PROPOSE&rarr;VOTE&rarr;OPTIMIZE 循环仍无共识** &rarr; ABORTCEO 判定该需求当前不可执行
- **责任人**:提案方(第 1 轮修改)+ CEO第 2 轮起裁决)
### 13.3 OPTIMIZE 阶段僵局
- **症状**:提案方无法在合理范围内满足反对意见 OR 修改导致方案退化
- **处理**
1. 修改范围 &gt; 原提案 50% &rarr; 回到 PROPOSE作为新提案重新编号
2. 反对意见本身有争议 &rarr; CEO 仲裁,判定反对是否成立
3. CEO 判定无法调和 &rarr; ABORTCEO 另起替代方案
- **责任人**CEO
### 13.4 EXECUTE 阶段失败
- **症状**:执行者子代理返回失败(编译错误 / 测试失败 / 无法完成任务)
**分级处理表**
| 失败级别 | 条件 | 处理 | 责任人 |
|----------|------|------|--------|
| L1 轻微 | 单执行者失败,根因明确,修复 &le;30 行 | CEO 在原 prompt 追加修复指令,同一执行者重试(仅 1 次) | 原执行者 |
| L2 中等 | L1 重试仍失败 OR 失败涉及 &gt;2 个文件 | CEO 换人执行选同专长其他员工OR CEO 亲自修复 | CEO |
| L3 严重 | 多执行者同时失败 OR 构建系统破坏 OR ABI 破坏 | 进入 ROLLBACK`git checkout -- &lt;触发文件&gt;` + CEO 判定是否缩小范围重新 EXECUTE | CEO |
| L4 致命 | 数据损坏 / 无法定位的段错误 / 安全漏洞暴露 | 进入 ROLLBACK + 指派 security-cao 安全审计 | CEO + security-cao |
### 13.5 INSPECT 阶段失败
**处理协议(按步骤强制执行,不可跳过)**
1. **先回退触发改动**`git checkout -- &lt;验收失败的源文件&gt;`,确保仓库回到可构建状态
2. **验证回退成功**`cmake --build build --config Release` 必须 0 error
3. **定位根因**确认失败属于执行错误execution error还是设计错误design error
4. **按分级表处理**
| 失败类型 | 判定标准 | 回退目标 | 后续动作 |
|----------|----------|----------|----------|
| E1 执行错误 | 编译警告/错误可定位到具体行,修复不涉及接口变更 | EXECUTE | 原执行者修复1 次机会CEO 复核 |
| E2 执行错误(重复) | 同一执行者同类错误第 2 次出现 | EXECUTE | 换人执行(同专长其他员工) |
| D1 设计错误 | 接口不兼容 / 架构假设错误 / 需改公共头文件 | OPTIMIZE | 提案方重新设计,走完整 VOTE&rarr;INTEGRATE&rarr;EXECUTE |
| D2 设计错误(严重) | 需回退 &gt;3 个文件 OR 涉及 ABI 变更 | ROLLBACK | 回退后 CEO 重新 PROPOSE缩小任务范围 |
| F1 致命 | 数据损坏 / 安全漏洞 / .git 异常 | ROLLBACK | 回退 + security-cao 审计 + CEO 决定是否 ABORT |
### 13.6 ROLLBACK 协议(强制步骤)
当触发 ROLLBACK 时,按以下步骤强制执行,**不可跳过任何一步**
1. `git status` &rarr; 记录当前所有改动文件列表
2. `git checkout -- &lt;触发失败的所有文件&gt;` &rarr; 回退触发改动的文件
3. `cmake --build build --config Release` &rarr; 确认回退后构建 0 error
4. `ctest -R smoke --test-dir build` &rarr; 确认回退后测试 100% pass
5. 在 WORKFLOW.md §7 记录:回退原因、触发文件列表、决策(重做 / 缩小范围 / 放弃)
6. CEO 根据 §13.5 分级表选择后续路径(重新 EXECUTE / 重新 PROPOSE / ABORT
### 13.7 ABORT 协议(强制步骤)
当触发 ABORT 时,按以下步骤强制执行:
1. 在 WORKFLOW.md §7 记录:中止原因、时间、影响范围
2. 决定改动处置:保留(`git stash`)或丢弃(`git checkout`
3. 本波 W 编号标记为 ABORTED下一波使用新编号
4. 相关执行者 profile.md performance_log 仍追加条目rating: aborted保留参与记录
## 14. 审计→修复闭环机制
审计audit产出的发现必须转化为可跟踪、可执行、可验收的修复任务。不允许审计报告写完即归档、发现问题无人跟进。
核心闭环:**Audit report → Finding registration → Severity triage → Fix task (Wave) → CEO verify → Close**
### 14.1 发现登记格式
所有审计发现统一登记在 `agents/audits/findings-registry.md`,分为 Open Findings 和 Closed Findings 两个分区。每条发现包含以下字段:
| 字段 | 说明 | 示例 |
|------|------|------|
| ID | `F-<源Wave>-<序号>`,全局唯一 | F-11.7-1 |
| Severity | CRITICAL / HIGH / MEDIUM / LOW | CRITICAL |
| Source | 审计报告文件名(相对于 agents/audits/ | W11.7-destructive-test.md |
| Title | 一句话描述,含关键行号和症状 | `/clear` reports [OK] even when session unavailable — main.cpp:168-172 |
| Status | 见 §14.2 状态定义 | OPEN |
| Assigned To | 负责修复的员工 agent-idOPEN 状态为空) | architect-huang |
| Fix Wave | 修复所在的 Wave 编号FIXED 后填写) | W16.1 |
| Verified By | 验收人 agent-idVERIFIED 后填写) | qa-wang |
发现数量超过 20 条时qa-wang 负责将 Closed 分区中超过 30 天的条目归档到 `agents/audits/findings-archive.md`
### 14.2 发现状态生命周期
| 状态 | 类型 | 含义 | 进入条件 | 退出条件 |
|------|------|------|----------|----------|
| OPEN | 活跃 | 发现已登记,待 triage | 审计报告提交后,由审计人或 QA 组长录入 registry | CEO/QA 组长完成 triage 并指定执行者 |
| ASSIGNED | 活跃 | 已指派修复人,等待执行 | Triage 完成 + CEO 在 PROPOSE 阶段创建对应修复 W 任务 | 执行者提交修复 + 自述 cmake 0 error + ctest 100% pass |
| FIXED | 活跃 | 修复已提交,等待验证 | 执行者完成修复并更新 registry 状态 | CEO INSPECT 通过§12OR QA 组长验证通过 |
| VERIFIED | 活跃 | 修复已验证,即将关闭 | INSPECT 全部通过 OR 回归测试覆盖通过 | 自动进入 CLOSED |
| CLOSED | 终止 | 已关闭 | VERIFIED 后自动关闭 | — |
| WONTFIX | 终止 | 决定不修复 | CEO 明确判定不修复(附理由) | — |
| BLOCKED | 活跃 | 被阻塞 | 依赖的其他发现未修复 OR 外部条件不满足 | 阻塞解除后回到 ASSIGNED |
状态转换图:
```
OPEN ──→ ASSIGNED ──→ FIXED ──→ VERIFIED ──→ CLOSED
│ │ │ │
│ │ │ │
↓ ↓ ↓ ↓
WONTFIX BLOCKED REOPEN REOPEN
(回到 (回到
ASSIGNED) ASSIGNED)
```
回边定义:
- FIXED → ASSIGNED (REOPEN)CEO INSPECT 发现修复不完整或引入新回归,退回执行者
- VERIFIED → ASSIGNED (REOPEN):后续回归测试暴露本发现的修复引入了新问题
- ASSIGNED → BLOCKED → ASSIGNED执行者发现依赖未满足申请阻塞依赖解除后恢复
- OPEN → WONTFIXCEO 判定(如:修复成本远超收益 / 已被后续重构覆盖 / 非 bug 是设计取舍)
全局出口:
- ANY → WONTFIXCEO 在任意状态可强制关闭(需附理由写入 registry Change Log
### 14.3 自动转化规则
从审计发现到修复任务的转化由严重级别驱动:
| Severity | 转化规则 | 时限 | 触发者 |
|----------|----------|------|--------|
| CRITICAL | 下一波 PROPOSE 阶段 MUST 创建对应修复任务W 编号),优先级最高,阻塞其他任务排期 | 当前 Wave + 1 | CEO |
| HIGH | 2 个 Wave 内 MUST 安排修复任务 | 当前 Wave + 2 | CEO / QA 组长 |
| MEDIUM | 每波 PROPOSE 阶段评估,可合并到其他同文件/同模块任务中附带修复 | 不限,但每 5 个 Wave 至少回顾一次 backlog | QA 组长 triage |
| LOW | 进入 backlog在相关源文件被其他任务改动时附带修复opportunistic fix | 不限 | 执行者自行判断 |
CRITICAL 阻塞规则:
- 如果进入 EXECUTE 阶段时仍有 OPEN 状态的 CRITICAL 发现CEO 必须明确决策:(a) 本波优先修 CRITICAL或 (b) 标记 WONTFIX附理由或 (c) 降级为 HIGH附降级理由
- 不允许带着 OPEN CRITICAL 发现进入 SUCCESS
**历史发现时限计算**:对于在本机制建立之前已存在的审计发现(如 F-11.x 系列),时限从 findings-registry.md 初始化日期2026-05-27开始计算而非从原始审计日期计算。即 F-11.1-1 (HIGH) 的修复期限为 2026-05-27 + 2 Wave = W17 前。
### 14.4 CEO 审查协议(新增验收项)
在 §12 验收清单基础上INSPECT 阶段追加以下检查项:
| # | 检查项 | 命令/方法 | 通过标准 |
|---|--------|-----------|----------|
| A1 | 发现登记完整性 | 检查本波新增审计报告:逐一核对 severity ≥ MEDIUM 的发现是否已录入 findings-registry.md | 无遗漏 |
| A2 | CRITICAL 发现清零 | `grep "CRITICAL.*OPEN" agents/audits/findings-registry.md` | 输出为空(所有 CRITICAL 已修复或 WONTFIX |
| A3 | 修复关联标注 | 检查本波 EXECUTE 子代理报告 | 每个修复任务标注了对应的 Finding ID格式`Fixes: F-<Wave>-<N>` |
| A4 | 状态同步 | 逐一核对 registry 中本波涉及的发现状态与实际修复结果一致 | FIXED 状态发现的 cmake + ctest 已通过 |
验收结论扩展:
| 失败项 | 处理 |
|--------|------|
| A1 失败 | 补充录入 → 重新检查 |
| A2 失败 | INSPECT → EXECUTE优先修复 CRITICAL |
| A3 失败 | 补标注 → 重新检查 |
| A4 失败 | 状态回退到 ASSIGNED → EXECUTE |
### 14.5 与 §11 状态机的集成点
| §11 状态 | §14 动作 | 责任人 |
|----------|----------|--------|
| PROPOSE | 1. 读取 findings-registry.md Open 分区 2. 将 CRITICAL/HIGH OPEN 发现转为候选 W 任务 3. 评估 MEDIUM backlog | CEO |
| EXECUTE | 1. 子代理 prompt 中标注 `Fixes: F-<Wave>-<N>` 2. 修复完成后更新 registry 中该发现状态为 FIXED | 执行者 |
| INSPECT | 1. 执行 §14.4 A1-A4 检查 2. 通过的发现 FIXED → VERIFIED → CLOSED 3. 失败的发现退回 ASSIGNEDREOPEN | CEO |
| SUCCESS | 1. 本波 CLOSED 的发现从 Open 分区移到 Closed 分区 2. 记录关闭日期和 Fix Wave | CEO |
| ABORT | 本波 ASSIGNED 的发现回退到 OPEN修复未发生 | CEO |
| (T18) | 本波 ASSIGNED 的 finding 保留 ASSIGNED 状态,不回退 OPEN升级不是中止 | CEO |
### 14.6 审计人职责
提交审计报告时,审计人必须同时完成以下动作:
1. 在审计报告末尾新增 "## Findings Summary" 小节,列出所有发现的 ID、Severity、Title与 registry 格式一致)
2. 将 severity ≥ MEDIUM 的发现录入 `findings-registry.md` Open 分区,状态 OPEN
3. LOW 发现同样录入(保持完整记录),但可在 triage 时直接标记为 backlog
审计人完成登记后通知 CEO 或 QA 组长进行 triage。
**存量审计报告**`agents/audits/` 中已有的审计报告W11.1-W13.6)缺少 Findings Summary 小节。QA 组长应在每个存量报告被引用时补充该小节,优先级为 MEDIUM不阻塞当前工作。
### 14.7 关联文档
- [findings-registry.md](audits/findings-registry.md) — 发现注册表(单一事实来源)
- [PROMPT_TEMPLATE.md](PROMPT_TEMPLATE.md) — 子代理 prompt 模板修复任务使用标准模板Fixes 行添加到交付清单)

View File

@@ -20,5 +20,23 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W11.1 审计 context_plugin.cpp (289行零Wave覆盖)"
detail: |
context_plugin 首次审计, 聚焦跨 DLL 堆合规 / ABI 契约 / 内存泄漏 / 并发安全:
- 堆纪律: 完全合规 (0 处裸 malloc/free/strdup/new/delete), 无需迁移。所有跨边界分配使用 host->alloc/strdup。
- ABI: 基本合规, 但违反 §5.3 (trim_impl 内 std::vector/std::string 可抛异常穿越 C ABI 边界→std::terminate)
- 内存: 正常路径干净; OOM 路径 g_host->strdup 返回值未检查 (L138-141/L219-222), 8 处调用无 null guard
- 并发: g_host 在 on_shutdown 与 trim_impl 间无同步访问, 隐式时序依赖 (评级 C)
- Top3: (1) C++异常穿越ABI边界[严重] (2) strdup返回值未检查+泄漏[高] (3) g_max_tokens设置但无读取点→set_max_tokens是死API[中]
- 综合评级: B (堆纪律A, ABI B, 内存B, 并发C)
审计报告写入 agents/audits/W11.1-context-audit.md
rating: completed
- date: 2026-05-27
event: "W13.4 深度审计 lsp_plugin.cpp (749行)"
detail: |
lsp_plugin 首次全面审计 (9 维度). 堆纪律/字符串返回均合规(A); 异常安全全线失败——7 vtable + reader_loop + handle_message + on_shutdown 零 try/catch(违反§8,F); 致命死锁 L526→L547(持mutex调g_lsp_impl_stop→再次lock,F); server→client request 静默丢弃(B); ServerCapabilities丢失/rootUri硬编码(C); fetch_add溢出UB(B). TOP3: (1)死锁[严重] (2)异常穿越ABI[严重] (3)request丢弃+error丢失[高]. 综合 C.
审计报告: agents/audits/W13.4-lsp-audit.md
rating: completed
current_groups: [] current_groups: []
--- ---

View File

@@ -30,6 +30,27 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "加入核心质量小组(grp-quality-core)与王测协作执行C2" event: "加入核心质量小组(grp-quality-core)与王测协作执行C2"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W1.1 修复完成shutdown_all 新增 DLL 句柄释放FreeLibrary/dlcloseg_diag_callback 改为 std::atomic 消除 UB。host.cpp 和 plugin_loader.cpp 均编译通过0 error 0 warning"
rating: A
- date: 2026-05-27
event: "W9.4 完成:撰写 docs/reference/plugin-abi.md Plugin ABI 契约文档200行涵盖 DSTALK_API_VERSION、内存所有权、跨DLL堆纪律、register_service、on_init/on_shutdown、回调线程安全、依赖声明共7个契约要点。更新 docs/README.md reference 区追加入口"
rating: A
- date: 2026-05-27
event: "W12.2 完成:消除 config_plugin 与 ConfigStore 的 TOML 解析代码重复(提取共享头 toml_parse.h消除双 store 数据孤岛config plugin 委托 host store修复 c_str() 悬垂指针thread_local 缓存。build 0 error4/4 test pass"
rating: completed
- date: 2026-05-27
event: "W14.4 完成:诊断 W12.2 双 store 整合未生效根因——测试加载了 build/tests/plugins/ 下 pre-W12.2 的旧 DLL而非 build/plugins/ 下 post-W12.2 新 DLL。修复host.cpp 新增插件目录 fallback 搜索plugins/ -> ../plugins/),清理 build/tests/ 下陈旧产物。build 0 error4/4 test passR2 由 WARN 变 PASS"
rating: completed
- date: 2026-05-27
event: "W18.1 (协作 王测): 关闭 F-11.1-3/4/5/6 共4条 context_plugin 遗留发现。(3) 删除 g_max_tokens + context_set_max_tokens API更新 dstalk_services.h 移除 vtable 字段trim_impl 硬编码默认值 4096 + max_msg_count 改用 max_tokens 参数;(4) count_tokens_utf8 共享函数新增多字节序列越界保护i+N>=len + 后继字节 & 0xC0 校验);(5) 提取 count_tokens_utf8 消除 count_tokens_one_message/count_tokens_trim 双份重复;(6) c==0xC0||0xC1 独立分支检测过短编码。新增 context_plugin_test.cpp 13 测试块。编译 0 error + ctest 5/5 pass。"
rating: completed
- date: 2026-05-27
event: "W19.3 (协作 王测): plugin_loader 5 条发现修复验证。代码审查确认F-18.3-1 5 个 ABI 调用点仅 initialize_all/initialize_pending 有 try/catch2/5load_plugin/unload_plugin/shutdown_all 仍缺保护F-18.3-2 load_plugin 5 个失败路径全静默返回 -1F-18.3-3 路径仅 null 检查无约束F-18.3-4 fprintf 未替换为 host->logF-18.3-5 next_id_ 非原子。5 条全部未修复,不予关单。编译 0 error + ctest 5/5 pass。"
rating: A
- date: 2026-05-27
event: "W22.6 完成plugin_loader 新增 validate_dependencies() —— 遍历所有已加载插件 deps[] 做缺失依赖检测 + 循环依赖检测topological_sort 异常捕获),返回 0/-1。initialize_all() 头部调用,失败时 WARN log 继续初始化不 crash。plugin_loader.hpp:54-55 声明plugin_loader.cpp:309-345 实现initialize_all L352-356 集成。cmake --build build --config Release 0 errorctest 8/8 pass"
rating: A
current_groups: current_groups:
- grp-quality-core (成员) - grp-quality-core (成员)
- grp-ai-plugins (待命) - grp-ai-plugins (待命)

View File

@@ -19,6 +19,21 @@ weaknesses:
performance_log: performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: completed
- date: 2026-05-27
event: "W10.1: 设计协作状态机 + 验收清单 + 失败回退协议,追加 WORKFLOW.md §11§13"
rating: completed
- date: 2026-05-27
event: "W13.1: 深度审计 anthropic_plugin.cpp (497行)6个C ABI函数零try/catch (§8违反)response_body泄漏 + 全局指针竞态tool_use静默丢弃。综合评级C。报告写入 agents/audits/W13.1-anthropic-audit.md"
rating: completed
- date: 2026-05-27
event: "W15.1: 为 WORKFLOW.md §11 协作状态机设计 EXPRESS 快跳路径。定义 E1-E6 六项客观准入条件,新增 T17(快跳入口) + T18(升级回退) 两条转换规则§11.2 图增加快跳边标注,新增 §11.2.1 完整说明。建议将 EXPRESS 作为正式快跳标签(非新增状态,避免状态爆炸)"
rating: completed
- date: 2026-05-27
event: "W15.4: 交叉审查 §11 EXPRESS 快跳与 §14 审计闭环集成。发现 2 处程序性遗漏EXPRESS 准入缺 §14.3 CRITICAL 阻断检查、T18 升级缺少 ASSIGNED finding 状态处置)和 1 处措辞不一致T11 未引用 §14.4 A1-A4。结论 PASS-WITH-NOTES建议追加 E7 条件 + §14.5 补充 T18 行 + 修正 T11 措辞"
rating: completed
- date: 2026-05-27
event: "W15.7: 根据 W15.4 审查发现修复 WORKFLOW.md 3 处交叉引用。Fix1: §11.2.1 新增 E7 条件(CRITICAL 发现清零) + 正文 6→7 条 + E1-E6→E1-E7 + T17 同步更新。Fix2: §11.3 T11 验收标准追加 §14.4 A1-A4。Fix3: §14.5 集成点表新增 (T18) 行(ASSIGNED finding 不回退 OPEN)。无源代码修改,无 §13 变动"
rating: completed
current_groups: [] current_groups: []
--- ---

View File

@@ -0,0 +1,164 @@
# W11.1 Context Plugin Audit
**Auditor**: 黄岭 (architect-huang)
**Date**: 2026-05-27
**File**: plugins/context/src/context_plugin.cpp (289 行)
**Wave Coverage**: 零 (从未被 Wave 流程审计)
---
## 1. 跨 DLL 堆合规性
状态: **合规**
逐行检查所有 malloc/free/strdup/new/delete 调用点:
| 调用类型 | 搜索结果 | 判定 |
|----------|----------|------|
| `malloc()` | 0 处 | -- |
| `free()` | 0 处 | -- |
| `strdup()` (裸) | 0 处 | -- |
| `new` (显式) | 0 处 | -- |
| `delete` (显式) | 0 处 | -- |
| `g_host->alloc()` | L135, L215 | 正确: host 堆分配 |
| `g_host->strdup()` | L138-141, L219-222 | 正确: host 堆分配 |
| `std::string` / `std::vector` | L90-127 等多处 | 插件内部使用, 不跨边界 |
**结论**: 所有跨 DLL 边界的分配均通过 `g_host->alloc()` / `g_host->strdup()`, 符合 §3.2 硬性规则。内部 C++ 类型 (std::string/std::vector) 仅用于 TrimMessage 局部数据, 不出 DLL 边界。W2.1 堆纪律已隐式合规, 无需迁移。
**注意**: L159/L168 使用 `std::fprintf(stderr, ...)`, 非内存操作, 无堆问题。
---
## 2. ABI 契约符合度 (plugin-abi.md §1-§7)
### §1 DSTALK_API_VERSION
- L280: `DSTALK_API_VERSION`
- 导出函数签名 `dstalk_plugin_info_t* dstalk_plugin_init(void)``dstalk_plugin_init_fn` typedef 匹配 ✅
### §2 内存所有权
- `g_info.name/version/description` 为静态字符串字面量, 生命周期覆盖 init_fn 调用 ✅ (§2.2)
- 输出数组通过 `g_host->alloc` + `g_host->strdup` 分配, 调用方负责 `g_host->free` ✅ (§2.3)
### §3 跨 DLL 堆
- 已在上节验证 ✅
### §4 register_service
-`on_init` 内调用 (L268) ✅ (§4.1)
- 版本号 1 ✅
### §5 on_init/on_shutdown
- 函数签名匹配: `int (*)(const dstalk_host_api_t*)` / `void (*)(void)`
- **违反 §5.3 (异常安全)**: `on_init` / `context_count_tokens` / `context_trim` / `context_set_max_tokens` 均定义为 C 函数指针, 但底层 C++ 实现 (`trim_impl` L114-226) 使用了 `std::vector` / `std::string`, 二者均可抛出 `std::bad_alloc`。无任何 `try/catch` 包裹。若 OOM, 异常穿越 C ABI 边界 → `std::terminate()`, 违反 §5.3 约束。
受影响函数: `context_trim``trim_impl` (L114), `on_init``g_host->log` (可变参数, 低风险)
### §6 线程安全
- 见第 4 节
### §7 依赖声明
- L281: `{"session", nullptr, ...}`, NULL 终止 ✅
- `on_init` 内通过 `host->query_service("session", 1)` 验证依赖可用 (L261-265) ✅
### 评级: 基本合规, 但因 §5.3 异常安全缺失降为 **B**
---
## 3. 内存/资源泄漏分析
### 3.1 服务注册
- `on_init` 注册 "context" 服务 (L268), 静态 vtable `g_context_service` 无动态分配。
- `on_shutdown` (L271-274) 未调用 `unregister_service`**但合规**: 按 §4.4, host 在插件卸载时自动调用 `ServiceRegistry::unregister_service`。无需手动清理。
### 3.2 事件订阅
- 无事件订阅, 无泄漏风险 ✅
### 3.3 trim_impl 内存分配路径
三条返回路径分析:
| 路径 | 位置 | 内存状态 |
|------|------|----------|
| 单条消息超限, 提前返回 | L172-174 | `*out=nullptr`, 无分配, 局部 vector RAII 清理 ✅ |
| 完整副本路径 (current ≤ max) | L133-144 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
| 裁剪后路径 | L213-225 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
**发现**: `g_host->strdup` (L138-141, L219-222) 返回值未被检查。若 strdup 在循环中途失败返回 nullptr:
- 前置已成功的 strdup 分配被**泄漏** (无记录, 无法逐个释放)
- nullptr 被静默存入 message field, 函数返回 0 (成功)
- 调用方解引用 nullptr 的 role/content → 崩溃
**结论**: 正常路径无泄漏, 但 OOM 路径存在资源泄漏 + 空指针隐患。
### 3.4 g_session 死引用
- L261-266: `query_service("session")` 获取并存入 `g_session`
- 全文搜索: `g_session` **从未被读取** (仅在 L272 `on_shutdown` 中置 null)
- 不造成泄漏 (无额外分配), 但 dead code 暗示设计与实现脱节
### 评级: **B** (正常路径干净, OOM 路径脆弱)
---
## 4. 并发安全分析
### 4.1 共享状态清单
| 变量 | 写入点 | 读取点 | 同步 |
|------|--------|--------|------|
| `g_host` | L258 (on_init), L273 (on_shutdown) | L135/215 (trim_impl → alloc), L138-141/219-222 (strdup) | **无** |
| `g_session` | L266 (on_init), L272 (on_shutdown) | 无读取 | **无** |
| `g_max_tokens` | L244 (set_max_tokens) | 无读取 | **无** |
### 4.2 潜在竞争
**Race A: on_shutdown vs. trim_impl 并发** (g_host)
- 线程 T1: `context_trim()``trim_impl()` 读取 `g_host` (非 null)
- 线程 T2: `on_shutdown()` 写入 `g_host = nullptr`
- T1 随后调用 `g_host->alloc()`**空指针解引用**
- 缓解因素: host 应保证 shutdown 前无 in-flight 服务调用。但 ABI 未显式保证, 属未定义行为边界。
**Race B: set_max_tokens vs. 未来的 getter** (g_max_tokens)
- 当前无读取点, 不构成实际竞争。但 `set_max_tokens` 作为公开 API, 未来添加 getter 时需 `std::atomic` 保护。当前为**潜在债务**。
### 评级: **C** (g_host 读写的无同步访问是数据竞争, 虽有隐式时序假设但不可依赖)
---
## 5. 最严重发现 (Top 3)
### 发现 1 — [严重] C++ 异常穿越 ABI 边界 (违反 §5.3)
- **行号**: L114-226 (trim_impl), L257-268 (on_init), L232-240 (vtable 函数)
- **问题**: `trim_impl` 使用 `std::vector`/`std::string` 可抛 `std::bad_alloc`, 但无 `try/catch`。异常通过 C 函数指针边界 → `std::terminate()` → 进程崩溃。违反 plugin-abi §5.3 硬性约束。
- **修复方向**: 在 `trim_impl` 体和 `on_init` 体加 `try { ... } catch (...) { return -1; }`, 或预分配 + nothrow 版本。
### 发现 2 — [高] strdup 返回值未检查, OOM 时静默失败 + 泄漏
- **行号**: L138-141 (完整副本路径), L219-222 (裁剪后路径)
- **问题**: 循环内连续 4 次 `g_host->strdup` 调用均未检查返回值。若某次返回 nullptr: (a) 前置成功的 strdup 分配泄漏 (无释放手段); (b) nullptr 存入 message field, 函数返回 0 (成功); (c) 调用方解引用 null role/content → 段错误。
- **修复方向**: 每个 strdup 后检查返回值, 失败时逆序释放已分配字段, 再 `g_host->free(*out)`, 然后返回 -1。
### 发现 3 — [中] context_set_max_tokens 是死 API (g_max_tokens 未被读取)
- **行号**: L21 (声明), L243-244 (写入)
- **问题**: `context_set_max_tokens()` 写入 `g_max_tokens`, 但全文无任何代码路径读取该变量。`trim_impl` 使用参数 `max_tokens` (调用方直接传入), 不依赖全局状态。因此 `set_max_tokens` 调用对行为零影响——调用方期待的限制永不生效。
- **修复方向**: 要么在 `context_trim` 中读取 `g_max_tokens` 作为默认值 (当调用方传 0 时), 要么删除该 API / 改为返回当前值。
---
## 6. 整体评级
| 维度 | 评级 |
|------|------|
| 跨 DLL 堆合规 | A (完全合规) |
| ABI 契约符合度 | B (核心合规, §5.3 异常安全缺失) |
| 内存/资源泄漏 | B (正常路径干净, OOM 脆弱) |
| 并发安全 | C (g_host 无同步访问, 隐式时序依赖) |
| **综合** | **B** |
**总评**: context_plugin 在堆纪律上意外地干净 (没有需要迁移的裸 malloc/free/strdup/new/delete), 说明编码者即使未读 plugin-abi 也遵循了正确模式。主要风险集中在异常安全 (可导致进程崩溃) 和 OOM 鲁棒性。无安全漏洞、无明确内存泄漏、无崩溃性逻辑错误。
---
## 7. 补充发现 (优先级低)
- **UTF-8 解码无越界保护** (L42-64, L96-104): 多字节序列 (2/3/4 字节) 的 `i += N` 假设后续字节有效, 若输入截断则读越界。对 token 估算影响极小 (仅计数值偏差), 非功能性 bug。
- **token 计数逻辑重复** (L34-68 vs L91-106): `count_tokens_one_message``count_tokens_trim` 有 ~90% 重复, 在 C 字符串和 std::string 上各实现一套。维护风险 (两端需同步修改), 但不影响正确性。
- **0xC0/0xC1 过短编码未识别** (L52, L100): UTF-8 标准中 0xC0/0xC1 是无效起始字节 (过短编码), 但仍计入 `other_chars`。仅影响 token 估算计数, 不影响功能。
- **on_shutdown 不释放 g_info.dependencies** (L281): dependencies 是静态字符串数组, 无需释放, 无问题。

View File

@@ -0,0 +1,108 @@
# W11.2 Config Plugin / ConfigStore 职责与跨 DLL 堆审计
**审计人**: 陈风 (engineer-chen)
**日期**: 2026-05-27
**审计范围**: `plugins/config/src/config_plugin.cpp` (146行) + `dstalk-core/src/config_store.cpp` (83行)
---
## 1. 职责划分
| 文件 | 职责 | 所在层 |
|------|------|--------|
| `config_store.cpp` (dstalk::ConfigStore) | Host 自身的键值配置存储。`dstalk_init` 加载初始配置,通过 `host->config_get/set` 供所有插件调用 | core |
| `config_plugin.cpp` (匿名 ConfigStore) | 注册 "config" 服务 (vtable `dstalk_config_service_t`),提供 `load_file` 运行时调用接口,供其他插件通过 `query_service` 发现 | plugin |
**结论**: 职责有交集但不完全重复——plugin 提供了 core 未暴露的 `load_file` 运行时接口。然而,两个 ConfigStore 的 TOML 解析逻辑74行**逐字符完全相同**,且两个 config 存储在运行时相互独立host 的 config 与 plugin 的 config 是不同的 map存在以下问题
- 同一 key 在两个 store 中可能具有不同值
- `host->config_get("plugin_dir")` 读的是 core store`query_service("config")->get("plugin_dir")` 读的是 plugin store
- 用户不知道该用哪个接口
**建议边界**: core 的 ConfigStore 作为唯一真相源config plugin 不再持有自己的 store改为将 `load_file` / `get` / `set` 委托给 `g_host->config_get` / `g_host->config_set``load_file` 则需要 core 侧新增 `config_load_file` 到 host API 或 config plugin 自行实现(仅保留解析逻辑,写入 host store
---
## 2. 跨 DLL 堆合规
| 检查项 | config_store.cpp | config_plugin.cpp |
|--------|-----------------|-------------------|
| 直接 `malloc`/`free` | 无 | 无 |
| 直接 `strdup` | 无 | 无 |
| `new`/`delete` 跨边界 | 无(仅内部 STL 容器) | 无 |
| `get()` 返回 `const char*` 的所有权 | 指向内部 `std::string`,调用方不得释放 | 同左 |
| `set()` 的值类型 | `const char*` 输入,拷贝到 `std::string` | 同左 |
**结论**: **无跨 DLL 堆违规**。两个文件均完全使用 STL 容器(`std::unordered_map<std::string, std::string>`)管理内存,所有分配/释放均在各自 DLL 的 CRT 堆内完成。返回的 `const char*` 指向内部 string buffer调用方只读不释放符合 ABI 契约 2.1。
但需注意:`config_plugin.cpp:77` 返回的 `c_str()` 指向 plugin DLL 内部的 `std::string`。调用方若持有该指针跨越 plugin unload将导致 use-after-free。这在当前设计中是低风险config plugin 通常不会被卸载),但建议在 ABI 文档中明确说明。
---
## 3. 线程安全
| 文件 | 锁机制 | 评估 |
|------|--------|------|
| config_store.cpp | `mutable std::mutex`get/set 均持锁 | get/set 单独调用安全 |
| config_plugin.cpp | 同上(匿名类内 `std::mutex` | 同左 |
**已知问题**:
- **Dangling pointer (config_store.cpp:72, config_plugin.cpp:77)**: `get()` 在锁内获取 `it->second.c_str()`,锁释放后返回。并发 `set()` 同一 key 会触发 `std::string` 重分配使外部持有的指针悬垂。ABI 文档 6.4 已记录此风险但代码未做防护(如返回 `std::string` 副本或用 `host->strdup` 分配)。
- **load_file 非原子 (config_store.cpp:57-60, config_plugin.cpp:63-66)**: `load_file` 逐行解析,每写入一个 key-value 就释放锁。并发 `get()` 可观察到新旧配置混合的状态。设计上这是合理的(避免持锁做磁盘 I/O但调用方需知晓。
- **ServiceRegistry 与 ConfigStore 之间无锁协调**: plugin 注册 "config" 服务时(`config_plugin.cpp:126`ServiceRegistry 的写锁和 ConfigStore 的 mutex 是独立的。这在当前无问题(注册只存 vtable 指针),但如果未来 config service 的注册/卸载与 store 生命周期联动,需注意锁序。
---
## 4. 服务暴露
`config_plugin.cpp:124-127`:
```cpp
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
return host->register_service("config", 1, &g_service);
}
```
- **正确**: 在 `on_init` 期间注册,符合 ABI 契约 4.1(仅此时可注册)
- **正确**: vtable 为 static 全局变量生命周期覆盖插件整个加载期ABI 4.4
- **正确**: service name "config" 与 `dstalk_services.h:64` 定义一致
- **注意**: 未检查 `register_service` 返回值。如果另一个插件也注册 "config",返回 -2 会被静默忽略
- **去重**: ServiceRegistry 已实现重复注册检测(`service_registry.cpp:12-14`),但插件自身不感知失败
---
## 5. 三个最严重发现
### 发现 1: 完整 TOML 解析器代码重复 (Critical)
- **config_plugin.cpp:16-90** 与 **config_store.cpp:10-83** 的解析逻辑完全相同
- **影响**: 任何 bug 修复需双份维护;已发现 load_file 不处理 section 名前后空格、不支持 inline table 等问题,需在两个文件中分别修复
- **修复方向**: config plugin 消除自己的 ConfigStore改为委托 host store通过 `g_host->config_get/set`),仅保留 `load_file` 且将解析结果写入 host store
### 发现 2: 双配置存储导致数据孤岛 (High)
- **位置**: `host.cpp:176` 创建 `dstalk::ConfigStore`host store`config_plugin.cpp:98` 创建匿名 `ConfigStore`plugin store
- **影响**: `host->config_get("key")``query_service("config")->get("key")` 返回不同数据,用户困惑
- **修复方向**: 合并为唯一 store建议保留 host 侧 `dstalk::ConfigStore`config plugin 只做服务注册包装)
### 发现 3: get() 返回悬垂指针 (High)
- **config_store.cpp:72** / **config_plugin.cpp:77**: `return it->second.c_str()` 在锁释放后,并发 set 同一 key 触发 realloc
- **影响**: 调用方持有的 `const char*` 可能指向已释放内存,导致 crash 或静默数据损坏
- **修复方向**: 选项 A — 返回值拷贝(调用方用 `host->strdup` 在锁内复制);选项 B — 文档明确规定调用方必须立即复制(当前 ABI spec 6.4 方向);选项 C — 用 `std::string` 作为返回值类型(需改 vtable 签名,涉及 API 版本 bump
---
## 6. 整体评级
**评级: C**
**理由**: 无跨 DLL 堆违规(本次审计核心关注点通过),线程安全基础正确但 get() 存在悬垂指针缺陷。最严重的问题是 74 行 TOML 解析器完全重复 + 双存储架构混乱,属于设计层面而非实现 bug但维护代价和用户困惑程度显著。建议优先解决发现 1 和 2合并 store发现 3 在合并时一并修复。
---
## 7. 附加建议
1. **config plugin 的 `on_init` 检查返回值** (config_plugin.cpp:126): `register_service` 返回 -2 时应有日志警告
2. **load_file 前清空已有数据**: 当前 `load_file` 不先 `data_.clear()`新文件与旧数据混合。如果这是预期行为merge 语义),应在注释中说明
3. **config_store.hpp 缺少 `load_file` 文档**: 未说明返回值约定0=成功, -1=文件不存在/解析失败)

View File

@@ -0,0 +1,53 @@
# W11.7 破坏性输入测试报告
**测试人**: 徐磊 (qa-xu)
**日期**: 2026-05-27
**被测二进制**: `build/dstalk-cli/dstalk-cli.exe` (MD5: 803ca2ea)
**源 Commit**: `004a81d` (Wave 7: W10.1-W10.4)
**测试环境**: Windows 11 Pro, 无 API key, 无 config.toml, 无 plugins/
## 测试结果汇总
| # | 场景 | 结果 | 退出码 | 备注 |
|---|------|------|--------|------|
| 1 | 启动+EOF (空pipe) | PASS | 0 | 直接进入 shutdown干净退出 |
| 2 | 空字符串回车 | PASS | 0 | fgets→len==0→continue 正确跳过 |
| 3 | 10000 字符超长行 | PASS | 0 | 截断警告正常触发,剩余字符 drain 正确 |
| 4 | 纯空格/纯Tab | PASS | 0 | 被当 AI promptAI 不可用报错不崩溃 |
| 5 | 未知 /command | PASS | 0 | "未知命令" 提示正常 |
| 6 | /status + /history 无session | PASS | 0 | /status 正常显示默认值;/history 报 "session service unavailable" |
| 7 | 中文+emoji prompt | PASS | 0 | UTF-8 无问题AI 不可用报错不崩溃 |
| 8 | ASCII 控制字符 \x01-\x08 | PASS | 0 | 被当 AI prompt 处理,无崩溃 |
| 9 | /quit 后重启 | PASS | 0,0 | 两次启动无残留状态 |
| 10 | 空 stdin (printf '') | PASS | 0 | fgets 立即返回 NULL干净退出 |
**结论: 10/10 全部 PASS零崩溃零 hang。**
## 发现的 Bug
### BUG-1 [CRITICAL] build/bin/dstalk-cli.exe 是损坏副本
- **MD5**: d8e8c92b (67,072 bytes) vs 正常 803ca2ea (66,048 bytes)
- **症状**: 所有命令(含 /help、/quit)被当 AI prompt 处理;任何输入退出码固定为 3错误信息是英文("AI or session service unavailable")而非正常的中文
- **推测**: CMake install/post-build 步骤拷贝了不同版本源码编译的产物,类似 PM-001 stale obj 模式
- **影响**: 任何人若按文档路径 `build/bin/dstalk.exe` 运行将得到完全不可用版本
### BUG-2 [MEDIUM] /clear 在 session 不可用时谎报成功
- `main.cpp:168-172`: `if (g_session) g_session->clear()` 后无条件 `printf("[OK] 会话已清空")`
- g_session==null 时也打印成功,误导用户
### BUG-3 [LOW] /context 在 session 不可用时静默无输出
- `main.cpp:175-185`: 仅 `if (g_session) {...}` 无 else 分支
- 用户输入 /context 后零反馈,不知所措
### BUG-4 [LOW] /file write 裸命令被当未知命令
- `strncmp(line, "/file write ", 12)` 要求尾部空格,`/file write` (无参) 匹配失败落入"未知命令"
- 应给用法提示而非未知命令
## 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-27 | 1.0 | W11.7 破坏性测试10 场景全 PASS发现 4 个 bug (1 critical) |

View File

@@ -0,0 +1,273 @@
# W13.1 Anthropic Plugin Audit
**Auditor**: 杨帆 (architect-yang)
**Date**: 2026-05-27
**File**: plugins/anthropic/src/anthropic_plugin.cpp (497 行)
**Wave Coverage**: W2.2 (仅 secure_zero 追加,全文从未审计)
---
## 文件总览
| 函数 | 行号 | 类型 | 用途 |
|------|------|------|------|
| `secure_zero` | 34-37 | 静态辅助 | volatile 写零擦除敏感数据 |
| `extract_host_port` | 42-62 | 静态辅助 | URL 解析 host/port/target |
| `build_headers_json` | 67-73 | 静态辅助 | 构建 Anthropic headers JSON (含 api_key) |
| `build_request_json` | 78-124 | 静态辅助 | 构建 Messages API 请求体 |
| `parse_response` | 129-195 | 静态辅助 | 解析非流式 JSON 响应 |
| `parse_sse_data` | 203-238 | 静态辅助 | 解析 SSE data 行 (Anthropic 格式) |
| **`my_configure`** | **243-261** | **vtable** | AI 服务 configure |
| **`my_chat`** | **266-306** | **vtable** | 非流式聊天 |
| **`my_chat_stream`** | **348-428** | **vtable** | 流式聊天 (SSE) |
| `sse_line_callback` | 321-346 | 回调 (C fn ptr) | SSE 行回调,传给 HTTP 服务 |
| **`my_free_result`** | **433-439** | **vtable** | 释放 chat_result 字段 |
| **`on_init`** | **454-468** | **生命周期** | 插件初始化 |
| **`on_shutdown`** | **470-478** | **生命周期** | 插件销毁 |
| `dstalk_plugin_init` | 494-497 | **导出入口** | 返回插件描述符 |
**vtable 绑定**: `g_service` (L444-449) 绑定 configure/chat/chat_stream/free_result。
**全局状态**: `g_host` (L14), `g_http` (L15), `g_config` (L16), `g_cfg` (L29)。
---
## 1. 审计维度: §5 + §8 异常安全
**规则 (§8.1)**: 所有 C ABI 导出函数on_init/on_shutdown/on_event + vtable 函数指针)必须 try/catch 包裹。
### 发现 1.1 — [H] 6 个 C ABI 函数零 try/catch 保护
以下所有通过 vtable 或 `dstalk_plugin_info_t` 暴露给 host 的函数,内部使用 `std::string` / `boost::json` / STL但**无一包裹 try/catch**
| 函数 | 行号 | 使用的可抛类型 | 风险 |
|------|------|---------------|------|
| `my_configure` | L243-261 | `std::string::operator=` (L247-250) | `std::bad_alloc``std::terminate()` |
| `my_chat` | L266-306 | `std::string` x5 (L279-286) + `boost::json` (build 系列) | 同上 |
| `my_chat_stream` | L348-428 | `std::string` x4 (L361-368) + `boost::json` | 同上 |
| `on_init` | L454-468 | `g_host->log` 可变参数 + query_service | 低概率但可能 |
| `on_shutdown` | L470-478 | `std::string::clear()` (noexcept) + `g_host->log` | 极低概率 |
| `sse_line_callback` | L321-346 | `std::string line_str(line)` (L326) | `std::bad_alloc` 抛向调用方 (网络插件) |
**违反 §8.1 条目 1+2+4**: on_init/on_shutdown + vtable 函数指针 + register_service 注册的函数。
**代价**: 任何 OOM 或 STL 异常 → 穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃。
W11.1 对 context_plugin 发现了完全相同的模式 (trim_impl)本插件问题范围更广6 个函数 vs 1 个)。
**对比 deepseek_plugin.cpp**: deepseek 同函数**同样零 try/catch**my_configure L238-256, my_chat L261-301, my_chat_stream L337-417, on_init L443-457, sse_line_callback L317-335说明此反模式是项目级共性问题。
### 发现 1.2 — [M] sse_line_callback 无保护但可能被网络插件兜底
L326 `std::string line_str(line)` 可抛 `std::bad_alloc`。回调通过 C 函数指针 `dstalk_stream_cb` 传入网络插件的 `post_stream`。根据 security-logging.md 对 network_plugin 的描述L280-282 有 `catch (std::exception& e)`),网络插件的 do_post_stream 可能兜底捕获。但依赖调用方的异常保护是脆弱假设——若网络插件变更或不同版本,此防护消失。
---
## 2. 审计维度: §9 字符串返回值生命周期
**规则 (§9)**: vtable 中返回 `const char*` 的函数必须符合模式 A拥有权转移host 堆分配 + 调用方 host->free或模式 B静态生命周期
### 发现 2.1 — [L] 临时 std::string + c_str() + strdup 的脆弱模式
L405-406:
```c
r.error = g_host->strdup(("HTTP " + std::to_string(status_code)).c_str());
```
临时 `std::string` 在完整表达式结束前有效,`strdup` 在其析构前完成复制。**当前安全**,但重构时若分离 `c_str()``strdup` 调用会导致悬垂指针。Deepseek 同位置 (L393-395) 使用相同模式。
### 合规确认
| 检查项 | 状态 | 证据 |
|--------|------|------|
| chat_result 字段全部经 strdup 分配 | PASS | L144, L146, L150, L168, L176, L179, L186, L191, L275, L296, L357, L396-397, L403, L405-406, L418, L424 |
| free_result 全部经 host->free 释放 | PASS | L436-438 |
| 无锁外 c_str() 返回 | PASS | 插件无 mutex/lock |
| 无栈上 buffer 返回 | PASS | 全部 strdup 复制 |
| 无 `std::string{...}.c_str()` 裸返回 | PASS | 临时对象均在 strdup 前存活 |
---
## 3. 审计维度: §2 + §3 跨 DLL 堆纪律
**规则 (§3.2)**: 严禁直接调用 malloc/free/strdup/new/delete 处理跨 DLL 边界数据。
### 发现 3.1 — [H] response_body 泄漏my_chat 错误路径未释放
L291-297 (`my_chat`):
```c
int ret = g_http->post_json(..., &response_body, &status_code);
if (ret != 0) {
r.error = g_host->strdup("http request failed");
return r; // ← response_body 未释放!
}
```
成功路径 (L302-304) 正确释放了 `response_body`,但 `ret != 0` 错误路径泄漏。
**对比**: `my_chat_stream` (L408, L414) 在**所有路径**正确释放 `response_body``my_chat` 应与 `my_chat_stream` 保持一致。
**deepseek_plugin.cpp 同位置** (L286-293) 有相同泄漏——此为项目级模式 bug。
### 合规确认
| 检查项 | 状态 | 证据 |
|--------|------|------|
| 无裸 malloc/free | PASS | 全文 0 处 |
| 无裸 strdup | PASS | 全文 0 处 |
| 无裸 new/delete | PASS | 全文 0 处 |
| 跨边界分配均经 host->strdup | PASS | 全部 chat_result 字段 |
| 跨边界释放均经 host->free | PASS | L302-304, L408, L414, L436-438 |
| 插件内部 std::string 不出边界 | PASS | 仅用于局部逻辑 |
---
## 4. 审计维度: §6 atomic 回调 + 线程安全
**规则 (§6.1)**: 诊断回调用 `std::atomic` + acquire/release。服务注册表/事件总线均有内部锁。
### 发现 4.1 — [H] 全局指针无同步保护 (g_host / g_http / g_config)
| 变量 | 写点 (on_shutdown) | 读点 (服务函数) | 同步机制 |
|------|-------------------|-----------------|----------|
| `g_host` | L477 `= nullptr` | L255, L274, L296, L357, L436 等 | **无** |
| `g_http` | L475 `= nullptr` | L274, L356 | **无** |
| `g_config` | L476 `= nullptr` | (未读取,死变量) | **无** |
**Race 场景**: 线程 T1 在 `my_chat()` L274 读取 `g_http`(非 null线程 T2 在 `on_shutdown()` L475 写入 `nullptr`。T1 随后 L291 调用 `g_http->post_json(...)`**空指针解引用**
**缓解因素**: host 应保证 shutdown 前无 in-flight 调用,但 ABI 未显式保证。与 W11.1 发现的 context_plugin Race A 问题同根。
### 发现 4.2 — [L] g_config 死变量
L16 声明 `g_config`L458 赋值L476 置 null。全文无任何读取点。不造成泄漏但暗示设计与实现脱节。
### 发现 4.3 — [PASS] 流式回调路径无需 atomic
`StreamContext` (L312-318) 在 `my_chat_stream` 栈上创建 (L370),回调 `sse_line_callback``post_stream` 同步调用期间使用,无多线程竞争。
---
## 5. 审计维度: api_key 安全
### 发现 5.1 — [PASS] on_shutdown 正确 secure_zero
L473-474:
```c
secure_zero(g_cfg.api_key.data(), g_cfg.api_key.size());
g_cfg.api_key.clear();
```
W2.2 修复已验证生效:先 volatile 零覆盖,再 clear 释放。`secure_zero` (L34-37) 使用 `volatile char*` 写入循环,是标准可移植的编译器优化防护手段。
### 发现 5.2 — [PASS] 日志不泄漏 api_key
W9.3 已审计确认:`my_configure` L255-258 的日志调用有意排除 `api_key`,仅输出 model/base_url/max_tokens/temperature。`build_headers_json` (L67-73) 构建的 headers JSON 仅通过内存传递至 HTTP 请求,不进入日志管道。
### 发现 5.3 — [L] 堆内存残留
`build_headers_json` 返回的 `std::string``x-api-key: sk-...`,在 `my_chat`/`my_chat_stream` 栈上存活至函数返回。RAII 析构释放内存但不擦除。攻击者读取进程堆可发现 api_key 残留。这是所有内存中处理凭证的固有限制非本插件特有secure_zero 仅覆盖 `g_cfg.api_key` 主副本。
---
## 6. 审计维度: 错误处理路径
### 发现 6.1 — [L] my_chat 对 post_json 错误仅返回通用消息
L291-297: `ret != 0` 时仅返回 "http request failed",未利用 `status_code` 区分超时/SSL/DNS 错误。`my_chat_stream` (L402-406) 有更细粒度的错误消息(区分 `status_code <= 0` 为 "transport error")。
### 发现 6.2 — [PASS] 空响应/空 body 不崩溃
- `parse_response` L136/L158: `body ? body : "{}"` 保护 null 输入
- L177-179: content 数组为空 → "empty response"
- `my_chat_stream` L416-418: `accumulated.empty() && !saw_data_line` → "no content received"
- `parse_sse_data` L234: catch(...) 静默忽略 JSON 解析失败
### 发现 6.3 — [PASS] HTTP 错误码正确传播
`parse_response` L134-154: 非 2xx 状态码提取 error.message 或退化为 "HTTP NNN"。
`my_chat_stream` L388-411: 同模式,附加 `status_code <= 0` 的传输错误处理。
---
## 7. 审计维度: 资源生命周期
### 发现 7.1 — [PASS] 流式中断资源正确释放
- `StreamContext` (L312-318) 栈分配RAII 保证析构
- `response_body``my_chat_stream` L408 + L414 双路径释放
- `user_cb` 返回 0 时L340`sse_line_callback` 返回 0`post_stream` 应据此停止——无孤儿连接
- 流结束后 `ctx.accumulated` 正确取出赋值 L424
### 发现 7.2 — [L] my_chat_stream 忽略 post_stream 返回值
L379-383: `int ret = g_http->post_stream(...)` 的结果未被检查。`my_chat` 检查了 `ret`L295`my_chat_stream` 仅在 status_code 上做判断。若 `ret != 0``status_code == 0`(连接完全失败),会落入 L402 `status_code <= 0` → "transport error" 分支——实际可工作,但不一致。
---
## 8. 补充发现
### 8.1 — [L] Anthropic 特有: tool_use 块被静默忽略
`parse_response` L163-173 仅提取 `type == "text"` 的 content block。Anthropic API 在 tool-use 场景下返回 `type == "tool_use"` 的块,其内容不会被提取到 `r.content``r.tool_calls_json` 始终被设为 `nullptr` (L153, L171, L182, L187, L193)。调用方期待 tool_calls 时将收到空 content + 空 tool_calls——静默数据丢失。不过 `dstalk_ai_service_t.chat` 签名设有 `tools_json` 入参但未在 Anthropic 请求体中设置(对比 deepseek L129 有 `root["tools"] = json::parse(tools_json)`),因此当前工具调用完全不可用。
### 8.2 — [L] system 消息合并到顶层字段
L92-97: Anthropic API 要求 system 为顶层字段(非 messages 数组元素),插件正确实现了此分离。但多段 system 消息用 `"\n\n"` 拼接 (L95),跨消息语义边界可能模糊。
---
## TOP 3 严重问题
### 发现 1 — [H] 6 个 C ABI 函数零 try/catch (§8 违反正面清单)
**行号**: L243 (my_configure), L266 (my_chat), L348 (my_chat_stream), L321 (sse_line_callback), L454 (on_init), L470 (on_shutdown)
**问题**: 全部使用 std::string/boost::json 但无异常保护。任何 std::bad_alloc → std::terminate()。
**修复**: 每个函数外层加 try { ... } catch (const std::exception& e) { g_host->log(ERROR, ...); return 错误码; } catch (...) { return 错误码; }
### 发现 2 — [H] response_body 泄漏 + 全局指针无同步
**行号**: L295-297 (泄漏), L14-L16 vs L475-L477 (竞态)
**问题**: (a) my_chat 在 ret!=0 时未释放 response_bodymy_chat_stream 正确);(b) g_host/g_http/g_config 无原子/互斥保护on_shutdown 与 服务函数并发可空指针解引用。
**修复**: (a) 在 L296 前加 `if (response_body) g_host->free(response_body);`(b) g_host/g_http 用 `std::atomic` 或文档化单线程约定。
### 发现 3 — [M] Anthropic tool_use 响应静默丢弃
**行号**: L129-195 (parse_response), L78-124 (build_request_json)
**问题**: (a) build_request_json 未将 tools_json 参数写入请求体(对比 deepseek L128-130(b) parse_response 仅提取 text blocktool_use 块被忽略tool_calls_json 始终 nullptr。用户调用工具时得到空响应无错误提示。
---
## 整体评级: C
| 维度 | 评级 | 理由 |
|------|------|------|
| §8 异常安全 | **F** | 6 个函数零 try/catch——mandatory 规则完全未满足 |
| §2/§3 跨 DLL 堆 | **A** | 无裸 malloc/free/strdup全部经 host API |
| §9 字符串生命周期 | **B+** | 全部 strdup 合规,一处临时+strdup 脆弱模式 |
| §6 线程安全 | **C** | 全局指针无同步,与 W11.1 同根问题 |
| api_key 安全 | **A** | secure_zero 生效,日志无泄漏 |
| 错误处理 | **C+** | 空响应不崩溃,但 response_body 泄漏+返回值忽略 |
| 资源生命周期 | **B+** | 流式路径 RAII 干净,仅 my_chat 泄漏点 |
| **综合** | **C** | 堆纪律意外干净,但异常安全零覆盖 + 全局指针竞态 + response_body 泄漏 将评级拉至 C |
**总评**: anthropic_plugin 在跨 DLL 堆纪律方面表现好(与 context_plugin 一致),说明编码者遵循了正确的内存管理模式。但 **§8 异常安全是强制性要求,当前零覆盖在最坏情况下直接导致进程崩溃**。response_body 泄漏和全局指针竞态进一步降低可靠性。tool_use 静默丢弃虽非安全/稳定性问题,但构成功能缺陷。评级 C建议在下一 Wave 优先修复发现 1 和发现 2。
---
## 审计元数据
- **审计标准**: plugin-abi.md v1.1 (含 §8 异常安全 + §9 字符串生命周期W12.6 追加)
- **参考审计风格**: agents/audits/W11.1-context-audit.md
- **安全日志参考**: docs/explanation/security-logging.md (W9.3)
- **对比文件**: plugins/deepseek/src/deepseek_plugin.cpp (仅参考,不审计)
- **不修改文件**: anthropic_plugin.cpp (审计只读)
## Findings Summary
| ID | Severity | Title | Fix Wave |
|----|----------|-------|----------|
| F-13.1-1 | HIGH | 6 C ABI functions zero try/catch protection (§8): my_configure (L243), my_chat (L266), my_chat_stream (L348), sse_line_callback (L321), on_init (L454), on_shutdown (L470) -- any std::bad_alloc → std::terminate() | W14 |
| F-13.1-2 | HIGH | response_body leak in my_chat error path (L295-297): ret!=0 returns without freeing response_body (my_chat_stream correctly frees it) | -- |
| F-13.1-3 | HIGH | g_host/g_http/g_config global pointers no sync protection (L14-16 vs L475-L477): on_shutdown nullptr write races with service function reads | -- |
| F-13.1-4 | MEDIUM | sse_line_callback no exception protection (L326 std::string alloc via C fn ptr): relies on network plugin's try/catch as fragile assumption | W14 |
| F-13.1-5 | LOW | temporary std::string + c_str() + strdup fragile pattern (L405-406): safe today but refactoring risk if c_str/strdup calls separated | -- |
| F-13.1-6 | LOW | g_config dead variable (L16): written in on_init (L458) and on_shutdown (L476), never read | -- |
| F-13.1-7 | LOW | heap memory residual for api_key after RAII destruction: build_headers_json returns std::string with x-api-key on stack, not zeroed on free | -- |
| F-13.1-8 | LOW | my_chat post_json error returns only generic "http request failed" (L295-297): does not distinguish timeout/SSL/DNS | -- |
| F-13.1-9 | LOW | my_chat_stream ignores post_stream return value (L379-383): only checks status_code, not ret | -- |
| F-13.1-10 | LOW | Anthropic tool_use blocks silently ignored (L163-173): parse_response only extracts type=="text", tool_use blocks lost; tool_calls_json always nullptr | -- |
| F-13.1-11 | LOW | system messages merged with "\n\n" (L95): may blur cross-message semantic boundaries | -- |

View File

@@ -0,0 +1,242 @@
# W13.2 DeepSeek Plugin Audit
**Auditor**: 孙宇 (engineer-sun)
**Date**: 2026-05-27
**File**: plugins/deepseek/src/deepseek_plugin.cpp (486 行)
**Reference**: plugin-abi.md §8 §9; anthropic_plugin.cpp (对比); W9.3 security-logging.md
---
## 文件总览
| 属性 | 值 |
|------|-----|
| 总行数 | 486 |
| 函数数 | 15 (含 2 个生命周期 + 1 个导出入口) |
| C ABI 暴露入口 | 7 (on_init, on_shutdown, configure, chat, chat_stream, free_result, dstalk_plugin_init) |
| 依赖 | dstalk_host.h, dstalk_services.h, boost::json |
| 注册服务名 | "ai.deepseek" v1 |
---
## 维度 1: §5 + §8 异常安全 (C ABI 全部入口)
### 违反入口清单
所有通过 C ABI 暴露的函数均**未包裹 try/catch**:
| 函数 | 行号 | 可抛 C++ 操作 | 触发条件 |
|------|------|---------------|----------|
| `my_configure` | 238-256 | `std::string::operator=`, `g_host->log` | bad_alloc, log 内部异常 |
| `my_chat` | 261-301 | `std::string`, `extract_host_port`, `build_request_json``json::parse(tools_json)`, `append_history``json::parse(tool_calls_json)` | bad_alloc, 恶意/畸形 tools_json, 畸形 tool_calls_json |
| `my_chat_stream` | 337-417 | 同上 | 同上 |
| `on_init` | 443-457 | `query_service`, `register_service`, `log` | bad_alloc (低概率) |
| `on_shutdown` | 459-467 | `secure_zero`, `std::string::clear` | 实际不会抛异常 |
### 关键发现 1 — [严重] C++ 异常可穿越 ABI 边界导致 std::terminate()
- **行号**: L91 (`append_history`), L129 (`build_request_json`), L261-301 (`my_chat`), L337-417 (`my_chat_stream`)
- **触发路径**: `my_chat(history, ..., tools_json)``build_request_json` → L129 `json::parse(tools_json)` — 若 tools_json 格式错误,`json::parse` 抛出 `std::system_error`。该异常在无 try/catch 的 `my_chat` 中穿越 C ABI 边界 → `std::terminate()` → 进程崩溃。
- **同样路径**: L91 `json::parse(m.tool_calls_json)``append_history` 中 — 若历史消息包含畸形 tool_calls_json同样触发。
- **对比 anthropic**: anthropic 插件完全相同的漏洞 (W13.1 杨帆审计中),属结构性缺陷。
- **违反**: plugin-abi.md §8.2 "每个使用 C++ STL 类型的 ABI 函数外层必须包裹 try/catch"。
### 发现 2 — [中] parse_response 有 try/catch 但 build_request_json 无
- `parse_response` (L166-202) 内部有 `try/catch(std::exception&)/catch(...)`,自身安全。
- 但其调用方 `my_chat` (L261) 在调用 `build_request_json` (L278) 时无保护,而 `build_request_json` 在 L129 `json::parse(tools_json)` 可抛异常。
- **不对称保护**: 响应解析安全,请求构建不安全。
---
## 维度 2: §9 字符串返回值生命周期
**评级: A** — 无违规。
| 检查点 | 行号 | 方法 | 判定 |
|--------|------|------|------|
| `dstalk_chat_result_t.content` | L174, L413 | `g_host->strdup(content.c_str())` | 模式 A (拥有权转移) |
| `dstalk_chat_result_t.error` | L151, L155, L158, L187, L194 等 | `g_host->strdup(...)` | 模式 A |
| `dstalk_chat_result_t.tool_calls_json` | L178 | `g_host->strdup(tc.c_str())` | 模式 A |
| `my_free_result` | L422-428 | `g_host->free()` 逐个字段 | 正确释放 |
| `dstalk_plugin_info_t` name/version/description | L472-476 | 静态字符串字面量 | 模式 B (静态生命周期) |
- L151 临时 `std::string``.c_str()` 传给 `strdup`: 因临时对象在完整表达式结束时析构strdup 在此之前完成复制,安全。✅
- L291 `r.error = g_host->strdup("http request failed")` — 字面量 → 堆副本,正确。✅
---
## 维度 3: §2 跨 DLL 堆纪律
**评级: A** — 零违规。
| 搜索项 | 结果 |
|--------|------|
| 裸 `malloc` / `free` | 0 |
| 裸 `strdup` (非 host->) | 0 |
| 裸 `new` / `delete` | 0 |
| `g_host->alloc` / `g_host->free` / `g_host->strdup` | 所有跨边界分配均通过此路径 |
| `std::string` 跨边界 | 否 — 仅内部使用,不出 DLL |
内部 `std::string` (PluginConfig, extract_host_port, build_* 等) 均不穿越 DLL 边界。✅
---
## 维度 4: §6 原子回调 / 并发安全
**评级: C** — 与 W11.1 context_plugin 相同模式。
| 变量 | 写入点 | 读取点 | 同步 |
|------|--------|--------|------|
| `g_host` | L445 (on_init), L466 (on_shutdown) | L150, L155, L158, L250-253, L291, L298, L413, L425-427 | **无** |
| `g_http` | L446 (on_init), L464 (on_shutdown) | L269, L286, L345, L367 | **无** |
| `g_config` | L447 (on_init), L465 (on_shutdown) | 无读取 | **无** |
**Race**: `on_shutdown``g_host = nullptr` (L466) 与 `my_chat` 读取 `g_host` (L291) 无同步。若 host 在 shutdown 期间不保证无 in-flight 调用,则为数据竞争。虽与 codebase 其余部分一致§6.5 PluginLoader 无内部互斥),但仍是技术债务。
---
## 维度 5: SSE 解析鲁棒性 (核心专项)
### 发现 3 — [中] SSE [DONE] sentinel 精确匹配过脆
- **行号**: L208-216 (`parse_sse_line`)
- **代码**: `if (data == "[DONE]")` — 精确字符串比较。
- **问题**: 若 server 返回 `data: [DONE] ` (尾随空格) 或 `data: [DONE]` (双空格)sentinel 不匹配 → `parse_sse_line` 返回 false → `sse_line_callback` 返回 1 (continue) → 流永不终止。
- **缓解**: OpenAI/DeepSeek 官方实现始终使用精确 `data: [DONE]`,故此问题仅在第三方兼容 server 或未来协议变更时出现。
- **对比 anthropic**: anthropic 使用 `type == "message_stop"` JSON 字段检测,对空格变化免疫。
### SSE 解析鲁棒性逐项分析
| 场景 | 行号 | 行为 | 判定 |
|------|------|------|------|
| `data:` 未以 `"data: "` 开头 | L210 | `rfind` 返回非 0 → false, 行被跳过 | 安全 (line 回调继续) |
| JSON 解析失败 (畸形 JSON) | L229-231 | `catch(...)` 吞异常, 返回 false | 安全 ✅ |
| choices 数组为空 | L222 | `choices.empty()` → false, 行跳过 | 安全 ✅ |
| choices[0] 无 "delta" key | L223 | `obj["delta"]` 插入 null, `.as_object()` 抛异常 → catch | 安全 ✅ |
| delta 无 "content" key | L224 | `delta.contains("content")` → false | 安全 ✅ |
| data payload 为空 (line="data: ") | L212 | `substr(6)` → "", `== "[DONE]"` false, `json::parse("")` 抛异常 → catch | 安全 ✅ |
| 多字节 UTF-8 在行边界截断 | -- | 依赖 HTTP 层行缓冲; `std::string` 字节不可知, 不 crash | 低风险 |
| `[DONE]` 带尾随空格 | L213 | 精确匹配失败 → 流不终止 | **中风险** |
| server 发送非 SSE 行 (event:, id:, retry:) | L210 | `rfind("data: ", 0) != 0` → false, 跳过 | 安全 ✅ |
**综合评价**: `catch(...)` 提供了良好的兜底保护, 不会因畸形 server 输出 crash。唯一实质性缺陷是 `[DONE]` 检测脆弱性。
---
## 维度 6: JSON 字段缺失/类型错误容错
### 非流式 parse_response (L137-203)
| 场景 | 行号 | 行为 | 判定 |
|------|------|------|------|
| body 为 nullptr | L146, L167 | `body ? body : "{}"` → "{}" | 安全 ✅ |
| HTTP 非 2xx + body JSON 无 "error" key | L148 | `obj.contains("error")` → false, 走 L157 generic error | 安全 ✅ |
| choices 缺失或非数组 | L169 | `obj["choices"].as_array()`→ 抛异常 → catch | 安全 ✅ |
| choices 为空数组 | L170 | `choices.empty()` → "empty response" | 安全 ✅ |
| choices[0] 无 "message" | L171 | `.as_object()` 抛异常 → catch | 安全 ✅ |
| message 无 "content" | L173 | `value_to<std::string>` 抛异常 → catch | 安全 ✅ |
| error.message 缺失/非字符串 | L150-151 | `value_to<std::string>` 抛异常 → 外层 catch | 安全 ✅ |
**评级: A** — 双重 catch (std::exception& + ...) + 合理的 fallback 逻辑。
---
## 维度 7: api_key 安全
**评级: B+** — 基本安全,有改进空间。
| 检查点 | 行号 | 判定 |
|--------|------|------|
| 日志不输出 api_key | L250-253 | ✅ (W9.3 已确认) |
| build_headers_json 结果不进入日志 | L67-72 | ✅ |
| secure_zero 清理 api_key | L462 | ✅ (on_shutdown 调用) |
| 内存中明文存储 | L29 | ⚠️ 全局 std::string 整个会话周期明文存在; 仅 shutdown 时清零 |
| Authorization header 构造 | L70 | `"Bearer " + api_key` → 临时 string, 无额外泄漏 |
**改进方向**: 每次 HTTP 请求后立即 secure_zero 临时 buffer减少明文驻留时间。但此为深度防御非必须。
---
## 维度 8: 与 anthropic 代码重复度
### 重复度量化
| 类别 | deepseek 行数 | anthropic 对应 | 重复度 |
|------|--------------|----------------|--------|
| 头文件/namespace | 9 | 9 | 100% |
| 全局指针 (g_host/g_http/g_config) | 3 | 3 | 100% |
| PluginConfig + g_cfg | 9 | 9 | 100% |
| secure_zero | 4 | 4 | 100% |
| extract_host_port | 21 | 21 | 100% |
| configure | 19 | 19 | ~95% (仅 log tag 不同) |
| chat (主流程) | 41 | 41 | ~90% (endpoint path 不同) |
| StreamContext + sse_line_callback | 28 | 28 | ~85% |
| chat_stream (主流程) | 80 | 80 | ~85% |
| free_result | 7 | 7 | 100% |
| g_service vtable | 6 | 6 | 100% 结构 |
| on_init | 15 | 15 | ~95% (service name 不同) |
| on_shutdown | 9 | 9 | ~95% (log tag 不同) |
| g_info + dstalk_plugin_init | 19 | 19 | ~95% |
| **可重构行合计** | **~270** | | **~55%** |
### 唯一代码
deepseek 真正独有的代码 (~130 行):
- `build_headers_json` (L67-72): 接收裸 api_key 并组装 Bearer token
- `append_history` (L77-97): tool 消息处理 (tool_call_id, tool_calls_json)
- `build_request_json` (L102-133): OpenAI chat completions 格式
- `parse_response` (L137-203): choices[] 数组格式解析
- `parse_sse_line` (L208-233): OpenAI SSE delta 格式
### 结论
**重复度 ~55%**。以下组件可抽取为共享基类/模板:
1. `extract_host_port` (21 行, 100% 相同)
2. `secure_zero` (4 行, 100% 相同)
3. `PluginConfig` + `my_configure` 模式 (~28 行, 95% 相同)
4. `my_chat` / `my_chat_stream` 主流程骨架 (~120 行, 85% 相同)
5. `free_result` / `g_service` / `on_init` / `on_shutdown` / `g_info` / `dstalk_plugin_init` (~56 行, 95% 相同)
**可重构面**: ~230 行可提取为 `ai_plugin_base` 共享库, 各 AI 插件仅需实现 JSON 构建/解析 (~130 行)。
---
## TOP 3 严重问题
1. **[严重] L91, L129, L261-L301, L337-L417 — C++ 异常穿越 C ABI 边界 (§8)**
`my_chat`/`my_chat_stream` 调用 `json::parse(tools_json)``json::parse(tool_calls_json)` 无 try/catch。畸形 JSON → 异常沿 C 函数指针返回 host → `std::terminate()` → 进程崩溃。与 anthropic 同源缺陷。
2. **[中] L208-L216 — SSE [DONE] sentinel 精确匹配脆弱**
`data == "[DONE]"` 精确比较。尾随空格或格式变化 → sentinel 不识别 → 流不终止 → 调用方 hang。
3. **[中] L14-16, L459-L466 — g_host/g_http/g_config 无同步读写**
`on_shutdown` 与 service 函数并发访问同组全局指针, 无 atomic/mutex。虽有隐式时序假设, 但属正式数据竞争 (与 codebase 其余部分一致)。
---
## 整体评级
| 维度 | 评级 |
|------|------|
| §5+§8 异常安全 | **D** — 所有 ABI 入口无保护, 畸形输入可 crash 进程 |
| §9 字符串生命周期 | **A** — 全部模式 A/B, 零违规 |
| §2 跨 DLL 堆纪律 | **A** — 零违规 |
| §6 并发安全 | **C** — 与 codebase 一致的数据竞争 |
| SSE 解析鲁棒性 | **B** — catch(...) 兜底好, [DONE] 检测脆 |
| JSON 容错 | **A** — 双重 catch + 合理 fallback |
| api_key 安全 | **B+** — secure_zero 到位, 明文驻留可优化 |
| 与 anthropic 重复 | **D** (维护面) — ~55% 重复, ~230 行可抽取 |
| **综合** | **C+** |
**总评**: SSE 解析因为有 `catch(...)` 全面兜底, 比预期更鲁棒。核心风险在于**所有 ABI 入口函数无 try/catch** — 一旦传入畸形 tools_json 或 tool_calls_json, JSON 解析异常直接导致进程 `std::terminate()`。这是可稳定复现的 crash 路径, 非理论威胁。与 anthropic 的 ~55% 重复度表明存在显著"可重构面", 建议后续 Wave 考虑抽取 `ai_plugin_base` 共享层。
## Findings Summary
| ID | Severity | Title | Fix Wave |
|----|----------|-------|----------|
| F-13.2-1 | HIGH | C++ exceptions cross C ABI boundary (§8): json::parse(tools_json) in build_request_json (L129) and json::parse(tool_calls_json) in append_history (L91) can throw → std::terminate() | W14 |
| F-13.2-2 | MEDIUM | Asymmetric exception protection: parse_response has internal try/catch but build_request_json does not (L129 json::parse unprotected); caller my_chat/my_chat_stream also lack wrapping | W14 |
| F-13.2-3 | MEDIUM | SSE [DONE] sentinel exact match too brittle (L213): trailing spaces or format deviation prevent match → stream never terminates → caller hang | -- |
| F-13.2-4 | MEDIUM | g_host/g_http/g_config global pointers no sync read/write (L14-16, L459-L466): on_shutdown null-write races with service function reads | -- |

View File

@@ -0,0 +1,176 @@
# W13.3 Network Plugin Audit
**Auditor**: 王测 (qa-wang)
**Date**: 2026-05-27
**File**: plugins/network/src/network_plugin.cpp (322 行)
**Scope**: plugin-abi.md §2 §3 §5 §8 §9, TLS, timeout, concurrency, resources
---
## 1. 跨 DLL 堆纪律 (§2 §3)
**评级: A (完全合规)**
逐行检查:
- L259: `g_host->strdup(result_body.c_str())` — 正确使用 host 堆分配,符 §3.2 模式 A。调用方 `dstalk_free` 释放。
- L40-80 `parse_headers_json`: 内部 `std::string` / `unordered_map`,不跨 DLL 边界。
- L118-123 Lambda capture: 内部 `std::string`,不跨边界。
- 全文零处裸 `malloc`/`free`/`strdup`/`new`/`delete`
唯一跨边界分配 L259 使用 `g_host->strdup`,正确。
## 2. 异常安全 ABI (§5 §8)
**评级: B (基础合规,缺 catch(...))**
- `on_init` (L296-303): 无线程局部 STL 操作,不会抛异常。安全。
- `do_post_stream` (L140-254): 整个 I/O 逻辑包裹在 `try { ... } catch (std::exception& e)` (L251)。所有 `std::string`/`unordered_map`/`std::function` 使用均在保护范围内。
- **缺失**: L251 仅捕获 `std::exception&`,无 `catch(...)` 兜底。符 §8.2 推荐但不符最佳实践§8.2 正例含 `catch(...)`)。
- vtable 函数 `http_post_json` / `http_post_stream` 直接调用 `do_post_stream`,间接享受到保护。不直接暴露异常。
- 若出现非 `std::exception` 派生的异常SEH / 自定义异常),将穿越 ABI → `std::terminate()`
## 3. 字符串返回值生命周期 (§9)
**评级: A**
- L248: `result_body = parser.get().body()` — 复制到本地 `std::string`
- L252-253: `result_body = e.what()``e.what()` 的 C 字符串在 `e` 析构前已复制到 `std::string`,安全。
- L259: `*response_body = g_host->strdup(result_body.c_str())` — 在函数返回前用 host 堆复制,符 §9.2 模式 A拥有权转移。调用方负责释放。
- 无悬垂指针 / 锁外返回 c_str() / 临时对象 c_str() 等 §9.3 反模式。
## 4. TLS 证书验证
**评级: F (完全未启用)**
```cpp
// L87-93 — HttpClientCtx 构造函数
ssl::context ssl_ctx{ssl::context::tlsv12_client};
HttpClientCtx() {
ssl_ctx.set_default_verify_paths(); // 设置了 CA 路径...
// ★ 但从未调用 set_verify_mode()
}
```
- **`set_verify_mode(ssl::verify_peer)` 未被调用**: 默认模式为 `ssl::verify_none`,接受**任何**证书(自签名、过期、域名不匹配)。
- **无 hostname 验证**: 即使启用 `verify_peer`OpenSSL 本身不做 hostname 匹配。需额外调用 `SSL_set1_host()` 或使用 `boost::asio::ssl::host_name_verification`。代码未做。
- **SNI 已设置** (L148): `SSL_set_tlsext_host_name` 调用存在,但仅影响 SNI 扩展,不含验证语义。
- **`set_default_verify_paths` 形同虚设**: CA 路径已配置,但因验证模式未开启,永不使用。
**影响**: 所有 AI API 调用 (含 `api_key` 的 Authorization header) 可被中间人任意拦截。CVSS 7.4 (High)。
## 5. 超时 (connect / read / write)
**评级: C (DNS 无超时可永久 hang)**
| 阶段 | 位置 | 超时 | 状态 |
|------|------|------|------|
| DNS 解析 | L142 `resolver.resolve()` | 无 | **缺失** — DNS 无响应则永久阻塞 |
| TCP 连接 | L154-156 | `connect_timeout` (默认 30s) | OK |
| SSL 握手 | L160-163 | `connect_timeout` (默认 30s) | OK |
| HTTP 写入 | L180-183 | `request_timeout` (默认 120s) | OK |
| HTTP 读头 | L188-191 | `request_timeout` (默认 120s) | OK |
| 流式读体 | L218-219 | `request_timeout` (默认 120s) | OK (每轮循环重设) |
| 非流式读体 | L241-242 | `request_timeout` (默认 120s) | OK (每轮循环重设) |
| SSL 关闭 | L250 `stream.shutdown()` | 无 | 低风险ec 参数,不抛异常) |
**关键缺失**: L142 的同步 `resolver.resolve()` 无法设置 socket 级超时stream 尚未创建)。需改用 `async_resolve` 或在独立线程中执行 resolver。
**可配置性**: L129-135 从 config service 读取 `http.connect_timeout` / `http.request_timeout`,有兜底默认值 (30s/120s)。
## 6. 错误码语义与重试
**评级: B**
- L261: `(result_code >= 200 && result_code < 300) ? 0 : -1` — 2xx 成功,其余返回 -1。
- 调用方可通过 `*status_code` 区分: -1 = 连接/超时/DNS 错误4xx/5xx = HTTP 错误码。语义正确。
- **无重试逻辑**: 无任何 retry。对低层 HTTP 客户端这是合理设计,重试应由上层 (AI 插件) 实现。
- 异常路径 (L251-254): `result_code = -1`, `result_body = e.what()` — 异常消息通过 response_body 传递给调用方,设计合理。
- L221 / L244: `if (ec) break;` — 流/非流 read_some 错误静默退出循环,错误原因不记录日志(全文零 `g_host->log` 调用)。
## 7. 并发安全
**评级: A**
- 每次 `do_post_stream` 调用创建新 `HttpClientCtx` (L125),内含独立 `io_context`。零共享状态。
- `g_host` (L33): `on_init` 写入一次,后续只读。`on_shutdown` 不置空L305-307 为空函数体)。
- `g_config_svc` (L34): 同上。
- 无全局 socket / stream / resolver 共享。每个请求自包含。
- 多线程并发调用 `http_post_json` 安全。
## 8. 资源清理
**评级: A**
- `HttpClientCtx` (L125): 栈上对象,内含 RAII `io_context` + `ssl::context`。析构自动清理。
- `ssl_stream` (L144), `resolver` (L141), `flat_buffer` (L145): 全部栈上 RAII。
- 早期退出路径: `goto done` (L150, L214) — 栈展开正确析构所有对象。
- 异常路径: `catch(std::exception&)` (L251) — 栈展开触发析构。
- L249 `cancel()` + L250 `stream.shutdown(ec)` — 正常路径优雅关闭。
**零泄漏路径**,包括异常和提前退出。无动态分配(除 `g_host->strdup` 的返回值由调用方管理)。
## 9. 缓冲区限制 / DoS
**评级: A**
- L187: `parser.body_limit(16 * 1024 * 1024)` — 16MB 响应体硬上限。防止恶意服务器返回无限大数据导致 OOM。
- `parse_headers_json` 无长度限制 (L40-80): 理论上调用方可传入超大 JSON但 headers_json 来源为同进程内其他插件(可信),非网络输入。低风险。
---
## TOP 3 严重问题
### 发现 1 — [严重] TLS 证书验证完全禁用 (L87-93)
**问题**: 从未调用 `ssl_ctx.set_verify_mode(ssl::verify_peer)`,默认接受任何证书。无 hostname 验证。所有 API key 和通信内容可被 MITM 拦截。
**修复方向**: 添加 `ssl_ctx.set_verify_mode(ssl::verify_peer)` + `ssl_ctx.set_verify_callback(ssl::host_name_verification(host))` 或在 handshake 后调用 `SSL_get_peer_certificate` + `X509_check_host`
### 发现 2 — [高] DNS 解析无超时 (L142)
**问题**: `resolver.resolve(host, port)` 同步调用,无法通过 `expires_after` 设置超时socket 尚不存在)。若 DNS 服务器不响应 → 线程永久阻塞 → 调用方 hang。
**修复方向**: 使用 `async_resolve` 并将 io_context 的 `run()``run_for()` 与超时结合,或在独立 future/promise 线程中执行 resolution 并使用 wait_for。
### 发现 3 — [中] 异常处理缺 catch(...) 兜底 (L251)
**问题**: 仅捕获 `std::exception&`,无 `catch(...)`。非标准异常 → 穿越 C ABI → `std::terminate()`。虽然 Boost.Beast/Asio 的已知异常均派生自 `std::exception`,但 §8.2 要求双级 catch 全覆盖。
**修复方向**: 在 `catch (std::exception&)` 之后追加 `catch (...) { result_code = -1; result_body = "unknown exception"; }`
---
## TLS 验证状态
**未启用**`ssl_ctx.set_verify_mode` 从未调用,默认 `verify_none` 接受一切证书。hostname 验证(`SSL_set1_host` / `boost::asio::ssl::host_name_verification`)也未实现。`set_default_verify_paths` 的调用形同虚设。
**影响评估**: 生产不可用。任何可访问网络路径的中间人可解密全部通信,包括 API 密钥、对话内容。在公共 WiFi 或不可信网络环境下,这是可直接利用的凭证泄露漏洞。
---
## 整体评级: C
| 维度 | 评级 |
|------|------|
| 跨 DLL 堆纪律 | A |
| 异常安全 ABI | B |
| 字符串返回生命周期 | A |
| TLS 证书验证 | F |
| 超时 | C |
| 错误码语义 | B |
| 并发安全 | A |
| 资源清理 | A |
| 缓冲区限制 | A |
| **综合** | **C** |
**总评**: RAII、堆纪律、字符串生命周期、并发安全均高质量。但 TLS 证书验证完全禁用 (F) 是致命安全缺陷DNS 无超时可无限 hang。两个问题 (TLS + DNS) 使该插件在任何生产环境中不可用。修复后预期可达 A 级。
---
## Findings Summary
| ID | Severity | Title |
|----|----------|-------|
| F-13.3-1 | CRITICAL | TLS 证书验证完全禁用:`set_verify_mode(ssl::verify_peer)` 未调用,默认 `verify_none` 接受任何证书,无 hostname 验证 (L87-93) |
| F-13.3-2 | HIGH | DNS 解析无超时:`resolver.resolve(host, port)` 同步调用socket 未创建无法设超时DNS 无响应则线程永久阻塞 (L142) |
| F-13.3-3 | MEDIUM | 异常处理缺 `catch(...)` 兜底:仅捕获 `std::exception&`,非标准异常 (SEH/自定义) 穿越 C ABI → `std::terminate()` (L251) |

View File

@@ -0,0 +1,269 @@
# W13.4 LSP Plugin 深度审计
**Auditor**: 黄岭 (architect-huang)
**Date**: 2026-05-27
**File**: plugins/lsp/src/lsp_plugin.cpp (749 行)
**Wave Coverage**: W6.1 仅审计 reader_loop (L415-459),其余 700+ 行首次审计
---
## 0. 文件总览
749 行。模块分布:
- Process 子进程 (start/stop/write/read_line/read_bytes): ~220 行 (L54-274)
- Service dispatch 实现 (start/stop/open/close/diagnostics/hover/completion): ~280 行 (L461-698)
- JSON-RPC 框架 (frame_message/parse_content_length/send_request/send_notification/handle_message): ~110 行 (L299-413)
- reader_loop 状态机 (W6.1 已修): ~45 行 (L415-459)
- State/Lifecycle/Descriptor: ~50 行 (L276-297 + L700-749)
- Headers/Globals: ~44 行 (L1-53)
## 1. §5 + §8 异常安全——穿越 ABI 边界
状态: **严重违反** FAIL
vtable 全部 7 个函数均以 C 函数指针暴露给 host (`dstalk_lsp_service_t`),但底层 C++ 实现大量使用 `std::string` / `json::object` / `json::parse` / `std::unique_lock` 等可抛异常类型,**零 try/catch 保护**:
| 函数 | 行号 | 可抛异常操作 |
|------|------|-------------|
| `g_lsp_impl_start` | L467-537 | json::object, std::string, std::thread, std::unique_lock |
| `g_lsp_impl_stop` | L539-566 | json::object, std::unique_lock |
| `g_lsp_impl_open_document` | L568-584 | json::object |
| `g_lsp_impl_close_document` | L586-598 | json::object |
| `g_lsp_impl_get_diagnostics` | L600-612 | std::lock_guard, std::string |
| `g_lsp_impl_get_hover` | L614-655 | json::object, json::parse, std::unique_lock, std::string |
| `g_lsp_impl_get_completion` | L657-698 | 同上 |
此外 `reader_loop` (L419-459)、`handle_message` (L375-413)、`on_shutdown` (L724-730) 均无保护。
**违反代价**: OOM 或任何 `boost::json` 内部异常穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃零恢复机会。plugin-abi §8 标记此为强制规则 (§8.1 明确覆盖 service vtable 函数指针),当前代码全线违规。
对比 W11.1: context_plugin 的 trim_impl (L114-226) 同样违规,但当时仅 1 个 vtable 函数受影响。lsp_plugin 受影响面是其 7 倍。
评级: **F**
---
## 2. §9 字符串返回值生命周期
状态: **合规** PASS
三个返回 `char** json_out` 的函数:
- `g_lsp_impl_get_diagnostics` (L600-612): `g_host->strdup(it->second.c_str())``g_host->strdup("[]")`,均在 mutex 锁内复制。Mode A (§9.2),调用方 `host->free` 释放。合规。
- `g_lsp_impl_get_hover` (L614-655): `g_host->strdup(json::serialize(resp["result"]).c_str())`temporary 在 strdup 复制后销毁,指针独立。合规。
- `g_lsp_impl_get_completion` (L657-698): 同 get_hover。合规。
`dstalk_plugin_info_t` 的 name/version/description 均为 static 字面量 → Mode B。合规。
评级: **A**
---
## 3. §2 跨 DLL 堆纪律
状态: **合规** PASS
逐行检查所有 malloc/free/strdup/new/delete:
| 模式 | 结果 |
|------|------|
| 裸 `malloc`/`free`/`new`/`delete` | 0 处 |
| 裸 `strdup` | 1 处 (L143, POSIX 子进程内, fork 后 exec 前, 不跨 DLL) |
| `g_host->strdup` | 4 处 (L607/609/653/696), 全部正确 |
| `g_host->alloc`/`g_host->free` | 0 处 |
唯一裸 `strdup` 位于 `fork()` 后的子进程 (L127-151),该进程随即调用 `execvp()` 替换地址空间,或 `_exit(127)` 退出。不涉及跨 DLL 边界。无合规风险。
评级: **A**
---
## 4. 进程生命周期
状态: **多个问题** WARN
### 4.1 Win32: TerminateProcess 无条件调用 (L174-176)
```cpp
WaitForSingleObject(hProcess, 2000); // 返回值被忽略
TerminateProcess(hProcess, 1); // 无条件调用
```
即使进程在 2 秒内正常退出,`TerminateProcess` 仍被调用。虽对已退出进程无害(返回 ERROR_ACCESS_DENIED但语义错误且日志缺失。
### 4.2 POSIX: SIGKILL 后 waitpid 可能永久阻塞 (L191-192)
```cpp
kill(pid, SIGKILL);
waitpid(pid, &status, 0); // 阻塞等待,无超时
```
若子进程处于 D 状态(不可中断睡眠),`waitpid(0)` 永不返回 → 调用线程永久挂起。
### 4.3 POSIX 子进程: strdup 未检查 null (L143-144)
`strdup(cmd)` 在 fork 后调用,若返回 NULL → `strtok(NULL, ...)` → 段错误。虽在 exec 前,但仍是未处理失败路径。
### 4.4 start() 中不必要调用 stop() (L69)
`Process::start` 开头调用 `stop()`,后者在 Win32 上有 2 秒 `WaitForSingleObject`。若首次启动,句柄均为 INVALID无实际操作。但语义不清`stop()` 应独立于 `start()` 调用。
评级: **C**
---
## 5. JSON-RPC 协议合规
状态: **部分缺失** WARN
### 5.1 Content-Length 解析 (L323-341)
W6.1 已修。大小写不敏感、空白跳过、异常安全。合规。
### 5.2 消息分发缺口 (L384-412)
当前仅处理两种消息:
- Response: `id && !method` (L384)
- Notification: `method && !id` (L391)
**缺失**: Server→Client Request (`id && method`) 被静默忽略。LSP 规范中 server 可发送:
- `window/showMessageRequest` (需客户端响应)
- `workspace/applyEdit` (需客户端响应)
- `client/registerCapability`
- `workspace/configuration`
server 请求得不到响应可能导致 LS 功能降级或阻塞。
### 5.3 Error 对象未处理 (L651, L694)
`get_hover` / `get_completion` 仅检查 `resp.contains("result")`,不检查 `"error"`。若 server 返回 `{"jsonrpc":"2.0","id":N,"error":{"code":-32601,"message":"..."}}`,函数返回 -1 但 **丢弃错误详情**。调用方无法区分超时和 server 错误。
### 5.4 通知/响应的 ID 匹配 (L386, L388)
`as_int64()``static_cast<int>`: 若 server 回传 >INT_MAX 的 id 值则截断。不过当前 id 由本端控制,仅 server→client request 场景可能触发。
评级: **B**
---
## 6. 状态机 / 线程同步
状态: **死锁 + 数据竞争** FAIL
### 6.1 致命死锁: start 超时路径 (L519-L526 → L547)
```
L519: std::unique_lock<std::mutex> lock(g_lsp.mutex); // 获取 mutex
L520: g_lsp.cv.wait_for(lock, 10s, ...); // 超时返回lock 仍持有
L526: g_lsp_impl_stop(); // → 进入 g_lsp_impl_stop
L547: std::unique_lock<std::mutex> lock(g_lsp.mutex); // 再次 lock 同一非递归 mutex → 死锁!
```
`std::mutex` 非递归,同线程二次 lock 为未定义行为Windows/Linux 均死锁。initialize 超时 (10s) 后触发,调用线程永久挂起。
注意: `get_hover`/`get_completion` 超时时仅返回 -1不调 stop无此问题。
### 6.2 写管道无同步 (L357, L368)
`send_request``send_notification` 均调用 `proc.write()` 而无 mutex 保护。若多线程并发调用 LSP 服务 → JSON 帧在管道上交错 → 协议解析失败。
### 6.3 读管道关闭的竞态 (L558-562 vs L228)
`g_lsp_impl_stop` 的时序: L558 `running=false` → L559 `proc.stop()` (关闭 hStdOut) → L562 `join`。reader 线程可能正阻塞于 `ReadFile(hStdOut)` (L228),句柄关闭触发 FALSE 返回 → `read_line` 返回 false → loop 退出 → join 成功。此竞态**被利用**来唤醒 reader属有意设计。可工作但脆弱。
评级: **F**
---
## 7. 请求 ID 管理
状态: **潜在 UB** WARN
- L284: `std::atomic<int> next_id{1};`
- L348: `int id = g_lsp.next_id.fetch_add(1);`
- L484: `g_lsp.next_id = 1;` (重启用)
问题:
1. **fetch_add 溢出**: `std::atomic<int>::fetch_add` 在有符号整型溢出时是未定义行为 (C++ 标准 [atomics.types.int]/8)。虽 2^31 次请求极难达到,但长生命期 LS 进程不排除。
2. **重启安全**: L484 重置为 1 时旧 pending 已在 L551 清除,安全。
3. **窄化转换**: L386 `as_int64()``int` 截断(见 §5.4)。
评级: **B**
---
## 8. 资源清理
状态: **基本正确** OK
`on_shutdown` (L724-730): 检查 running → 调 `g_lsp_impl_stop` → 置 `g_host=nullptr`。路径正确。
`g_lsp_impl_stop` (L539-566):
1. shutdown request + 2s wait
2. exit notification
3. `running = false`
4. `proc.stop()` (terminate/kill 子进程)
5. `reader_thread.join()`
6. `diagnostics.clear()`
时序正确。pending_responses 在 L551 清除后,任何在 `cv.wait_for` 阻塞的调用者因 `!g_lsp.running` 被唤醒并返回 -1。无泄漏。
`g_host``on_shutdown` 与 service 函数间无同步 (同 W11.1 的 Race A)。缓解: host 保证 shutdown 前无 in-flight 调用。
评级: **B**
---
## 9. 能力协商
状态: **不完整** WARN
- `initialize` 请求中声明 hover/completion/diagnostic 能力 (L491-503)。合规。
- `initialized` 通知在 initialize 响应后发送 (L533)。符合 LSP spec。
- **ServerCapabilities 被丢弃** (L529): initialize 响应仅取出后删除server 上报的能力集完全未被读取。若 server 不支持 hover插件不会知道仍会发送 hover 请求。
- **rootUri 硬编码 nullptr** (L511): 多数 LS 需要 rootUri 提供项目根目录以建立索引。缺少 rootUri 可导致 go-to-definition、workspace symbols 等完全不工作。
评级: **C**
---
## TOP 3 严重问题
### 1 — [严重] initialize 超时死锁 (L519-526 → L547)
`g_lsp_impl_start` 持有 `g_lsp.mutex` (L519) 时调用 `g_lsp_impl_stop()` (L526),后者在 L547 再次 `unique_lock` 同一非递归 mutex → **自死锁,线程永久挂起**。任何 initialize 超时场景server 10s 不响应)触发。
### 2 — [严重] 全 vtable 无异常保护,违反 §8 (L467-698 + L724-730)
7 个 service vtable 函数 + `reader_loop` + `handle_message` + `on_shutdown` 均大量使用 `std::string` / `json::object` / `json::parse`,零 `try/catch`。OOM 或 boost::json 异常穿越 C 函数指针边界 → `std::terminate()` 进程崩溃。受影响面是 context_plugin (W11.1) 的 7 倍。
### 3 — [高] Server→Client Request 被静默丢弃 + error 信息丢失 (L384-412 + L651/L694)
`handle_message` 仅分发 response/notification`id && method` 的 server request 直接丢弃不响应。server 可能因此阻塞(如 `window/showMessageRequest` 等待用户选择)。同时 error response 的 `error` 字段被忽略 (L651/L694),调用方无法区分超时与 server 错误。
---
## 整体评级
| 维度 | 评级 |
|------|------|
| §5+§8 异常安全 (ABI) | F (全 vtable 裸奔) |
| §9 字符串返回 | A (完全合规) |
| §2 跨 DLL 堆纪律 | A (0 处违规) |
| 进程生命周期 | C (死锁 + waitpid 阻塞 + strdup 未检查) |
| JSON-RPC 协议合规 | B (分发缺口 + error 丢弃) |
| 状态机 / 线程同步 | F (死锁 + pipe write 竞态) |
| 请求 ID 管理 | B (fetch_add 溢出 UB) |
| 资源清理 | B (路径正确,缺同步) |
| 能力协商 | C (ServerCapabilities 丢弃rootUri 硬编码 null) |
| **综合** | **C** |
**总评**: lsp_plugin 在堆纪律和字符串生命周期上完全合规(比 context_plugin 更干净),但 C++ 异常安全全线崩溃7 个 vtable + 多个内部函数无保护)。最致命的是 initialize 超时自死锁——触发条件明确且结果确定永久挂起。JSON-RPC 协议处理不完整 (server request 丢弃) 在真实 LS 场景下会显著降级功能。这些问题集中在 280 行 dispatch 代码中,修复范围可控(约 300 行需加 try/catch 包裹 + 修复死锁 + 补充分发逻辑)。
---
## 补充发现 (优先级低)
- **`#include <boost/json/src.hpp>` (L12)**: 将整个 Boost.JSON 实现编译进插件 DLL增加编译时间且可能与其他插件/库的 Boost.JSON 符号冲突。应改为仅 `#include <boost/json.hpp>`,通过 `target_link_libraries` 链接 boost::boost (CMakeLists.txt L16 已链接)。
- **trim (L303-311) 与 context_plugin 重复**: 相同逻辑出现于 context_plugin 的 trim两处独立维护。建议提取到 dstalk 核心库。
- **MultiByteToWideChar 缓冲区硬编码 4096 (L95)**: 命令行超长时静默截断,进程可能无法正确启动。
- **JSON-RPC "id" 同时接受 null**: LSP 规范允许 `"id": null` 表示通知。当前用 `msg.contains("id")` 仅检查 key 存在,未检查 null 值。若 server 误发 null-id response会被当作有效响应。暂非实际问题主流 LS 实现均守约)。
- **initialize 超时后 g_lsp_impl_stop 仍发 shutdown/exit 给可能未初始化的 LS**: 若 server 启动但未完成 initialize 握手,收到 shutdown 行为未定义。但在实践中,多数 LS 仍能处理。
- **Process::read_bytes L249 `buf.resize(count+1)``buf[count]='\0'`**: 刻意添加 null terminator`std::string` 不要求 null 终止,但后续 `json::parse(buf)` 需要。为性能优化(避免额外拷贝),可接受。

View File

@@ -0,0 +1,230 @@
# W13.5 Session + Tools Plugin 联合审计
**审计人**: 曹武 (security-cao)
**日期**: 2026-05-27
**文件**: plugins/session/src/session_plugin.cpp (264 行) + plugins/tools/src/tools_plugin.cpp (251 行)
**标尺**: plugin-abi.md §2 §5 §8 §9; security-logging.md
**前置工作**: W2.2 (api_key secure_zero, 仅 deepseek/anthropic) — 已注明合规; W9.3 (日志凭证审计, session 已审日志路径) — 已注明合规
---
## 1. Session 文件总览
session_plugin.cpp 实现 dstalk_session_service_t vtable (6 个函数): add / clear / save / load / history / token_count。
内部状态:
- `g_history`: std::vector<InternalMessage> — 完整消息历史 (role + content + tool_call_id + tool_calls_json)
- `g_cached_history`: std::vector<dstalk_message_t> — history() 返回的 C 数组缓存 (g_host->strdup 分配)
- `g_host` / `g_file_io`: 服务指针
依赖: file_io (save/load 持久化), Boost.JSON (序列化)。
---
## 2. Tools 文件总览
tools_plugin.cpp 实现 dstalk_tools_service_t vtable (4 个函数): register_tool / unregister_tool / get_tools_json / execute。
内置两个工具:
- `file_read`: 读取任意路径文件内容, 返回 JSON `{"content":"..."}`.
- `file_write`: 写入任意路径, 返回 JSON `{"success":true}`.
内部状态:
- `g_tools`: std::vector<ToolDef> — 已注册工具列表
- `g_host` / `g_file_io`: 服务指针
依赖: file_io, Boost.JSON。
---
## 3. 按维度发现
### 维度 1: Session 敏感数据持有与清零 [session]
**W2.2 覆盖范围**: api_key 的 secure_zero 仅实现在 deepseek_plugin.cpp 和 anthropic_plugin.cpp 的 anonymous namespace 内。session 插件**不在** W2.2 范围内 — 已注明合规。
**发现 1.1 — [高] 会话历史明文持有可能含 token 的数据** (session L33-40, L127-135)
- g_history 存储完整的 InternalMessage (含 tool_calls_json)。AI 模型回复中的 tool_calls 可包含 API 返回的敏感参数。
- on_shutdown (L242-249) 仅调用 g_history.clear() → std::string 析构 → 内存归还堆, **未 secure_zero 擦除**
- g_cached_history 中的字符串通过 g_host->free 释放 (L104-107), 同样未擦除。
- 与 W2.2 的 secure_zero 处置形成落差: api_key 被安全擦除, 但可能包含等效敏感度的会话数据未受同等保护。
**发现 1.2 — [中] 会话落盘明文无保护** (session L141-157)
- session_save 将完整消息 JSON 逐行写入 path 指定文件。content 字段包含用户输入 + AI 回复原文, tool_calls_json 包含工具调用参数。
- 无加密、无访问控制、无审计日志。
- path 参数无校验 — 依赖 file_io 服务实现 (若 file_io 无 sandbox, 可写任意路径)。
- 评级: 若 g_history 中包含 token 或隐私数据, 则构成明文存储敏感信息。
### 维度 2: Session 历史持久化安全 [session]
- 见维度 1 发现 1.2。补充: session_load (L159-202) 同样无路径校验, 可加载攻击者构造的 JSON 文件注入恶意历史记录 (如伪造 tool_call_id 导致下游误操作)。
- 加载时的 JSON 解析异常仅返回 -1 未记日志 (L194-195), 不利于入侵检测。
### 维度 3: Tools 路径校验与注入防护 [tools]
**发现 3.1 — [严重] 路径遍历: file_read / file_write 无路径消毒** (tools L50, L85)
- builtin_file_read (L39-67): `g_file_io->read(path.c_str(), &content)` — path 直接来自用户 JSON 输入, 未经任何规范化/白名单/拒绝遍历序列 (../) 检查。
- builtin_file_write (L69-98): 同。
- 攻击场景: `{"path":"../../../etc/passwd"}``{"path":"C:/Windows/System32/drivers/etc/hosts"}` — 取决于 file_io 底层实现。
- CVSS 初步评分: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N 需确认调用方可控性) — 若 AI 模型被 prompt injection 诱导调用 file_read/file_write, 攻击者可读写任意文件。
- **未发现命令注入**: 全文搜索无 system() / popen() / exec() / ShellExecute() / CreateProcess() 调用。仅通过 file_io 服务间接操作文件系统, 非 shell 执行。
### 维度 4: Tools dispatch 权限校验 [tools]
**发现 4.1 — [中] tools_execute 无调用方身份/权限检查** (tools L160-190)
- 任何持有 tools service vtable 指针的代码均可调用 execute(name, args) 执行任意已注册工具。
- 无 capabilities / ACL / rate limiting / audit logging。
- 如果未来注册 shell_exec 或 network 工具, 风险急剧升级。当前仅 file_read/write 但仍可被恶意插件滥用。
### 维度 5: §5 + §8 异常安全 (全部 C ABI 入口) [session + tools 共有]
**W11.1 审计已知模式**: context_plugin 因同样问题被评为 B 级 (trim_impl 无 try/catch)。
**发现 5.1 — [高] Session: 5/8 个 C ABI 函数无异常保护**
| 函数 | 行号 | 风险操作 | try/catch |
|------|------|----------|-----------|
| session_add | L127-135 | std::string 构造, vector::push_back | **无** |
| session_save | L141-157 | std::string, json::object, json::serialize | **无** |
| session_load | L159-202 | std::string, std::vector, string::substr | 仅 L178-196 (json::parse 部分) |
| session_history → rebuild_cached_history | L101-121 | std::vector, g_host->strdup | **无** |
| on_shutdown | L242-249 | → rebuild_cached_history | **无** |
| session_clear | L137 | g_history.clear() | noexcept — **安全** |
| session_token_count | L210 | 纯计算 | **低风险** |
| on_init | L227-240 | host->log, register_service | host 侧有保护, 插件侧**无** |
**发现 5.2 — [高] Tools: 5/8 个 C ABI 函数无异常保护**
| 函数 | 行号 | 风险操作 | try/catch |
|------|------|----------|-----------|
| tools_register_tool | L106-121 | std::string, vector::push_back | **无** |
| tools_unregister_tool | L123-130 | std::string, vector::erase | **无** |
| tools_get_tools_json | L132-157 | json::parse, json::serialize, std::string | **无** |
| on_init | L203-230 | → tools_register_tool x2 | **间接暴露** |
| tools_execute | L160-190 | handler 调用 | **部分** (L180-189) |
| builtin_file_read | L39-67 | json::parse, std::string | **有** (L63-66) |
| builtin_file_write | L69-98 | json::parse, std::string | **有** (L94-97) |
| on_shutdown | L232-236 | clear + nullptr | noexcept — **安全** |
**违反条款**: plugin-abi §8.1 (适用范围第2项: service vtable 函数) + §8.2 (实施要求)。
**后果**: OOM 或 json::parse 异常时 → std::terminate() → 进程崩溃, 无恢复机会。
**发现 5.3 — [低] 异常消息信息泄露** (tools L64, L95)
- builtin_file_read/write 的 catch 块将 `e.what()` 直接拼入返回给调用方的 JSON 字符串。
- `e.what()` 可包含文件路径、系统错误描述等内部信息。
- 虽算不上安全漏洞 (调用方本就控制 path 参数), 但违反最小信息暴露原则。
### 维度 6: §9 字符串返回 [session + tools 共有]
**发现 6.1 — [中] session_history 返回指针在下一次调用时悬垂** (session L101-121, L204-208)
- `session_history()` 调用 `rebuild_cached_history()`, 后者先 `g_host->free` 所有旧 g_cached_history 字符串 (L104-107) 再重建。
- 调用方若持有上一次 history() 返回的 `const dstalk_message_t*`, 在下一次 history() 调用后其中的 role/content 指针已悬垂 (已 free)。
- 模式类似 dstalk_chat_result_t (§2.1), 但 **无文档警告** "must copy before next history()"。
- 违反 §9.2 模式 A 文档要求 ("caller must free" 等效说明缺失)。
**发现 6.2 — [低] Tools 返回字符串模式正确但缺文档** (tools L62, L93, L157)
- builtin_file_read/write, tools_get_tools_json, tools_execute 错误路径全部使用 g_host->strdup 返回 — Mode A, 正确。
- 但函数无注释说明 "caller must free with host->free", 违反 §9.2 文档要求。
### 维度 7: §2 跨 DLL 堆纪律 [session + tools 共有]
**两个文件均合规** ✅ (同 W11.1 审计结论 — 编码者即使未读 plugin-abi 也遵循了正确模式):
- 所有跨边界分配: g_host->strdup (session L115-118; tools L62, L93, L157)
- 所有跨边界释放: g_host->free (session L104-107, L167)
- 内部类型: std::string / std::vector 仅插件 DLL 内使用, 不出边界
- 无裸 malloc / free / strdup / new / delete 处理 host 传入/传出数据
**W2.1**: 两个文件均未搜到裸 free 调用。任何 W2.1 的 free 迁移已在上层重构中消化 (旧 deepseek_api.cpp 等已删除)。已注明合规。
### 维度 8: 并发安全 [session + tools 共有]
**发现 8.1 — [严重] 两个插件所有全局状态均无同步保护**
Session 竞态表:
| 变量 | 写入方 | 读取方 | 同步 |
|------|--------|--------|------|
| g_history | add, clear, load | save, token_count, rebuild_cached_history | **无** |
| g_cached_history | rebuild_cached_history (via history) | history | **无** |
| g_host | on_init, on_shutdown | rebuild_cached_history, save, load | **无** |
| g_file_io | on_init, on_shutdown | save, load | **无** |
Tools 竞态表:
| 变量 | 写入方 | 读取方 | 同步 |
|------|--------|--------|------|
| g_tools | register_tool, unregister_tool | get_tools_json, execute | **无** |
| g_host | on_init, on_shutdown | get_tools_json, execute, file_read, file_write | **无** |
| g_file_io | on_init, on_shutdown | file_read, file_write | **无** |
**典型竞态场景**:
- Race A (session): T1 add() push_back to g_history, T2 save() iterates → 迭代器失效 / 数据损坏。
- Race B (session): T1 history() reads g_cached_history, T2 history() frees + rebuilds → UAF。
- Race C (tools): T1 execute() iterates g_tools, T2 unregister_tool() erases → 迭代器失效。
- Race D (共有): T1 any-service reads g_host/g_file_io, T2 on_shutdown() writes nullptr → NPD (空指针解引用)。
Race D 已于 W11.1 审计确认 (context 的同模式问题评为 C 级)。session + tools 的竞态面更大 (更多全局可变状态)。
---
## 4. TOP 3 严重问题
### TOP 1 — [严重] 路径遍历: file_read/file_write 无路径消毒 (tools L50, L85)
- 攻击者控制 args_json → 读写任意文件系统路径。无 sandbox、无白名单、无规范化。
- 若 AI 模型被 prompt injection 诱导调用工具, 可泄露系统配置/源码/凭证文件。
- CVSS: 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (假设调用方可控)
- 修复方向: 在 builtin_file_read/write 中添加路径规范化 (realpath/Canonicalize), 限制在预设工作目录内, 拒绝 ".." 段。
### TOP 2 — [严重] 全局状态无同步 → 多线程竞态 (session + tools, 全文)
- g_history / g_tools / g_host / g_file_io 读写均无 mutex。
- 后果: 迭代器失效 (crash)、use-after-free (crash/任意代码)、空指针解引用 (crash)。
- 影响面: 两个插件共 8 个 vtable 函数 + 2 个 on_shutdown = 10 个竞态入口。
- CVSS: 7.5 (High) — 多线程使用时 crash 概率高, 可能远程触发 (通过 AI chat 间接调用 session/tools)。
- 修复方向: 每个插件引入 std::shared_mutex (或 std::mutex), 写操作持 unique_lock, 读操作持 shared_lock。on_shutdown 需额外原子/锁确保服务调用排空。
### TOP 3 — [高] C++ 异常穿越 ABI 边界 (session L127/L141/L204/L242 + tools L106/L132/L203)
- session 5 个函数 + tools 4 个函数使用 std::string/std::vector/json::parse 但无 try/catch 包裹。
- 违反 plugin-abi §8 硬性规则 → OOM 或解析异常时 std::terminate() → 进程崩溃。
- W11.1 已在 context 中识别同模式问题 (评为 B 级, trim_impl 无保护)。session/tools 影响面更广 (9 个 vtable 函数 vs context 的 3 个)。
- CVSS: 5.9 (Medium) — 触发需 OOM 或恶意 JSON 输入, 后果为 DoS (进程崩溃)。
- 修复方向: 按 §8.2 标准模式, 每个使用 C++ 类型的 C ABI 函数体包裹 `try { ... } catch (const std::exception& e) { log; return -1/nullptr; } catch (...) { log; return -1/nullptr; }`
---
## 5. 关键问题回答
| 问题 | 结论 |
|------|------|
| 是否发现凭证泄露? | **潜在风险**: session_save 明文落盘含 tool_calls_json, 若工具调用参数含 token 则泄露。当前未确认可利用实例, 但防御缺失。 |
| 是否发现命令注入? | **未发现**: 两文件均无 system()/popen()/exec() 调用。tools 仅通过 file_io 服务间接操作文件, 非 shell 执行。 |
| 是否发现路径遍历? | **确认**: tools builtin_file_read/write 无路径消毒 (L50, L85), 攻击者可读写任意文件。 |
---
## 6. 整体评级
### Session: D+ (62/100)
| 维度 | 评级 | 说明 |
|------|------|------|
| §2 跨 DLL 堆 | A | 完全合规, host->strdup/free |
| §5/§8 异常安全 | D | 5/8 C ABI 函数无 try/catch |
| §9 字符串返回 | C | 模式正确但缺文档; history() 悬垂风险 |
| 敏感数据处理 | C | 明文落盘 + 无 secure_zero |
| 并发安全 | D | 零同步, 4 个静态全局无保护 |
| **综合** | **D+** | 堆纪律干净但异常安全/并发/数据保护三项不及格 |
### Tools: D (60/100)
| 维度 | 评级 | 说明 |
|------|------|------|
| §2 跨 DLL 堆 | A | 完全合规, host->strdup/free |
| §5/§8 异常安全 | C | 内置工具有保护, 注册/查询/on_init 无 |
| §9 字符串返回 | B | Mode A 正确, 缺文档 |
| 路径校验 | F | file_read/write 任意路径, 无消毒 |
| 访问控制 | F | execute 无调用方身份/权限检查 |
| 并发安全 | D | 零同步, 3 个静态全局无保护 |
| **综合** | **D** | 路径遍历为硬伤; 并发/异常安全与 session 同病 |
---
## 7. 低优先级发现
- **session_load 异常吞没无日志** (session L194-195): 解析失败仅返回 -1 不记日志。
- **tools 异常消息泄露** (tools L64, L95): e.what() 返回给调用方, 含内部路径/系统错误。
- **两个插件的 on_init 均未检查 register_service 返回值**: 若服务名冲突返回 -2 被静默忽略。
- **W9.3 已审结论维持**: session 仅 on_init L233 调用 host->log(ERROR, 静态字符串) — 无凭证日志泄露。tools 无任何 host->log 调用。两个文件在日志维度上安全。

View File

@@ -0,0 +1,225 @@
# W18.3 Plugin Loader Security Audit
**Auditors**: 曹武 (security-cao), 徐磊 (qa-xu)
**Date**: 2026-05-27
**File**: dstalk-core/src/plugin_loader.cpp + plugin_loader.hpp (385 lines total)
**Wave Coverage**: 零 (从未被 Wave 流程审计)
**Reference**: plugin-abi.md §3 §5 §6 §8
---
## 1. ABI 安全与异常安全 (§5.3, §8)
**评级: F (多个 C ABI 边界无保护)**
`PluginLoader` 调用插件的 C 函数指针 (`on_init`, `on_shutdown`, `init_fn`) 全路径零 try/catch 保护:
| 调用点 | 位置 | 函数指针签名 | 保护 |
|--------|------|-------------|------|
| `init_fn()` | load_plugin L59 | `dstalk_plugin_init_fn``dstalk_plugin_info_t*(*)(void)` | **无** |
| `on_init(host_api)` | initialize_all L237 | `int (*)(const dstalk_host_api_t*)` | **无** |
| `on_init(host_api)` | initialize_pending L272 | 同上 | **无** |
| `on_shutdown()` | unload_plugin L108-109 | `void (*)(void)` | **无** |
| `on_shutdown()` | shutdown_all L306-307 | 同上 | **无** |
L250-255 的 catch 块仅保护 `topological_sort()`——`on_init` 调用在 try 块**外部**。L237 和 L272 两处 `on_init` 调用均在 try 块覆盖范围之外。若某个插件的 C++ 实现抛出 `std::bad_alloc` 或任何其他异常,异常沿 C 函数指针返回 → `std::terminate()` → 进程崩溃。
这是 F-11.1-1 的 loader 侧对偶问题F-11.1-1 要求插件侧包裹 try/catch但 loader 侧也需要防御性保护——某个插件未严格遵守 §8 规范时host 不应因此而崩溃。
**影响**: 任意一个未做异常防护的插件在 OOM 或 STL 异常时即可拖垮整个 host 进程。防御深度缺失。
---
## 2. 堆纪律 (§3)
**评级: A (完全合规)**
逐调用点检查:
| 调用类型 | 搜索结果 | 判定 |
|----------|----------|------|
| `malloc` / `free` | 0 处 | -- |
| `strdup` (裸) | 0 处 | -- |
| `new` / `delete` (显式) | 0 处 | -- |
| `std::string` / `std::vector` | L83-85, L93, L123-142 | Host 内部使用, 不跨边界 |
| `boost::json` | L125-142 | Host 堆, 不跨边界 |
PluginLoader 是 host 侧组件——所有 `std::string`/`std::vector`/`json::object` 分配均在 host CRT 堆内, 不存在跨 DLL 堆风险。插件返回的 name/version/description 在 L83-85 通过 `std::string` 构造器复制到 host 侧, 符合 §2.2 契约。
---
## 3. 并发安全 (§6.5)
**评级: C (文档声明单线程但无强制)**
| 变量 | 写入点 | 读取点 | 同步 |
|------|--------|--------|------|
| `next_id_` (L54) | L80 `next_id_++` | L80 (读-改-写) | **无** |
| `plugins_` map | L96 (insert), L119 (erase), L323 (clear) | L102, L126, L149, L213, L263, L302, L328 | **无** |
| `PluginInfo::initialized` | L89, L217-246, L271-278, L306-309 | L108, L217, L271, L306 | **无** |
§6.5 明确声明 "PluginLoader 无内部互斥...load/unload 不应在多线程中并发调用"。但 host.cpp 中:
- `dstalk_init` / `dstalk_shutdown` 持有 `g_init_mutex`
- `dstalk_plugin_load` / `dstalk_plugin_unload` **不持有** `g_init_mutex`
这意味着在 `dstalk_init` 持有锁期间调用 `dstalk_plugin_load` 会死锁(不可重入 mutex), 但两个 `dstalk_plugin_load` 并发调用则无保护。虽然实际使用中可能不会并发, 但 zero enforcement 是防御深度缺失。
---
## 4. 输入验证
**评级: C (仅 null 检查, 无路径内容校验)**
`load_plugin(const char* path)` (L26):
- L28: `if (!path) return -1` — null 检查 OK
- L32-35: path 直接传给 `LoadLibraryA` / `dlopen`**无路径内容验证**
`dstalk_plugin_load` 是公开 C API, 任何调用方可传入任意路径。无以下验证:
- 路径是否为绝对路径 (相对路径触发 DLL 搜索顺序劫持风险)
- 路径是否在预期插件目录内
- 文件扩展名是否合法
- 文件是否存在 (由 OS 层报错, 但不记录)
---
## 5. 路径安全与 DLL 完整性
**评级: D (公开 API 无防护, 无来源验证)**
`dstalk_plugin_load` 调用链: 用户输入 → `host.cpp:240``load_plugin(path)``LoadLibraryA(path)` / `dlopen(path)`**零中间验证**
两个调用来源分析:
| 来源 | 路径构造 | 安全评估 |
|------|---------|----------|
| `load_plugins_from_directory()` (host.cpp:150) | `fs::directory_iterator` → 绝对路径 + 扩展名白名单 | OK |
| `dstalk_plugin_load()` 公开 API (host.cpp:240) | 调用方直接传入 | **无防护** |
缺失的防护层:
1. **路径规范化和目录约束**: 无 `fs::canonical` 解析, 无 allowed-dir 前缀检查
2. **扩展名校验**: 公开 API 路径不做 `.dll`/`.so`/`.dylib` 检查
3. **DLL 来源验证**: 无数字签名校验 (WinVerifyTrust), 无哈希白名单, 无证书链验证
4. **符号链接/硬链接**: 无检测, 攻击者可创建指向任意 .so 的符号链接
5. **Windows DLL 搜索顺序**: 相对路径触发搜索顺序劫持 (已知攻击向量)
注意: 相对路径在 `load_plugins_from_directory` 中不会出现 (fs::path 迭代产生绝对路径), 但 `dstalk_plugin_load` 公开 API 无此保证。
---
## 6. 符号解析
**评级: C (解析失败静默, 无诊断信息)**
L42-47 `GetProcAddress` / `dlsym`:
- 返回 nullptr 时正确卸载 DLL 并返回 -1 ✅
- **未调用 `GetLastError()` / `dlerror()`**, 失败原因不可知
- `(dstalk_plugin_init_fn)` 强制转型: 无签名验证机制。若插件导出同名但签名不同的函数 → 调用时 UB (栈损坏/寄存器错乱)
- `dependencies` 数组 (L92-94) 仅按名称字符串匹配 (`topological_sort` L164), 无版本号约束。同名但不同版本的插件会产生隐蔽的初始化顺序错误
---
## 7. 错误处理
**评级: D (零错误日志, 无错误码区分)**
`load_plugin` 有 5 个独立失败点, 全部返回 -1 **且不记录任何日志**:
| 失败点 | 行号 | 是否有日志 |
|--------|------|-----------|
| path 为 null | L28-29 | N/A (入口守卫) |
| LoadLibrary/dlopen 失败 | L37-39 | **无** |
| GetProcAddress/dlsym 失败 | L49-56 | **无** |
| init_fn() 返回 null | L60-67 | **无** |
| API 版本不匹配 | L70-77 | **无** |
调用方 (host.cpp:240) 也仅检查 `id >= 0`, 不记录失败原因。生产环境中排查 "为什么插件加载失败" 需要附加调试器。
`initialize_all` 中的 `fprintf(stderr, ...)` (L229, L239-240) 绕过了 host 日志基础设施——`host_api` 参数在手却不用 `host->log()`。在 GUI/服务进程中 stderr 可能被丢弃。
---
## 8. 日志安全
**评级: B (格式化安全, 终端注入风险低)**
- `fprintf(stderr, "[WARN] Plugin '%s' skipped...", plugin.name.c_str())` — 使用 `%s` 格式说明符, 无格式化字符串注入风险 ✅
- `plugin.name.c_str()` 来自 `info->name` — 插件作者控制。理论上可注入 ANSI 转义序列 (VT100 控制字符) 到 stderr, 扰乱终端显示。CVSS 低 (仅影响日志可读性)。
- `[ERROR]` 消息包含 `result` 错误码 (L239-240), 但 result 来自插件的 `on_init` 返回值——恶意插件可伪造错误码混淆日志。
- 成功加载无日志 (对比: host.cpp L153-154 记录了成功加载, 但 `load_plugin` 内部无)
---
## 9. 资源清理
**评级: B (正常路径正确, 异常路径有遗漏)**
正常路径:
- `load_plugin` 失败时正确调用 `FreeLibrary`/`dlclose` 释放已加载的 DLL ✅ (L51-55, L62-65, L71-75)
- `shutdown_all` L313-322 逐个 `FreeLibrary`/`dlclose` 所有 handle ✅
- `~PluginLoader` 调用 `shutdown_all()`
缺陷:
- `PluginInfo` 无拷贝/移动控制: 含原始指针 `void* handle``dstalk_plugin_info_t* info`。若被拷贝 (当前 `std::move` 仅发生在 L96), 源对象析构后 handle/info 双悬垂。缺少 `=delete` 拷贝构造/赋值。
- `shutdown_all` L306: 若 `on_shutdown()` 抛异常 (即使违反规范), 当前无保护——异常穿透 `shutdown_all` → 跳过后续插件的 shutdown + skip 所有 FreeLibrary → 句柄泄漏。`~PluginLoader` 也会因异常析构导致 terminate。虽然有 L294 `catch(...)` 降级路径, 但仅覆盖排序失败, 不覆盖 shutdown 回调。
---
## TOP 3 严重发现
### 发现 1 — [HIGH] 5 处 C ABI 调用点 zero try/catch 保护 (违反 §5.3, §8)
**位置**: load_plugin L59, initialize_all L237, initialize_pending L272, unload_plugin L108-109, shutdown_all L306-307
**问题**: PluginLoader 调用插件的 `on_init`/`on_shutdown`/`dstalk_plugin_init` 五个 C ABI 入口均无 try/catch 保护。若任意插件的 C++ 实现抛出异常 (std::bad_alloc 或其他 STL 异常), 异常穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃。L250-255 的 catch 块仅覆盖 `topological_sort()`, `on_init` 调用在 try 块外部。
**修复方向**: 在每个 C 函数指针调用点加 `try { ... } catch (const std::exception& e) { log; return -1; } catch (...) { log; return -1; }``on_shutdown` 的 void 返回类型需加 `catch(...) { /* log only */ }` 防止析构期二次异常。
### 发现 2 — [MEDIUM] load_plugin 5 个失败点全静默返回 -1, 无日志无错误码区分
**位置**: load_plugin L28-77 (全部 6 个 return -1 路径)
**问题**: LoadLibrary/dlopen 失败、符号找不到、init_fn 返回 null、API 版本不匹配——全部返回 -1 且一条日志不写。`GetProcAddress`/`dlsym` 失败时不调用 `GetLastError()`/`dlerror()` 诊断。生产环境中问题完全不可排查。
**修复方向**: 每个失败路径加 `host->log(DSTALK_LOG_ERROR, "load_plugin: %s: <reason>", path)`, 可区分错误码 (-2 file not found, -3 not a valid DLL, -4 symbol missing, -5 init failed, -6 version mismatch)。
### 发现 3 — [MEDIUM] 公开 API 路径零验证, DLL 加载无来源完整性检查
**位置**: load_plugin L32-35 (path → LoadLibraryA/dlopen 直传), host.cpp L240 (dstalk_plugin_load 公开入口)
**问题**: `dstalk_plugin_load` 公开 C API 接受任意路径, 不作规范化、目录约束、扩展名校验、签名验证。相对路径触发 Windows DLL 搜索顺序劫持。`load_plugins_from_directory` 的自动加载路径虽安全 (绝对路径+扩展名白名单), 但公开 API 独立于此防护。
**修复方向**: `load_plugin` 入口调用 `fs::canonical` 规范化路径, 校验扩展名 (.dll/.so/.dylib), 校验前缀在 allowed-dir 内。可选项: WinVerifyTrust (Windows) 或 ELF 签名验证。
---
## 整体评级
| 维度 | 评级 |
|------|------|
| ABI 安全与异常安全 | **F** |
| 堆纪律 | A |
| 并发安全 | C |
| 输入验证 | C |
| 路径安全与 DLL 完整性 | D |
| 符号解析 | C |
| 错误处理 | D |
| 日志安全 | B |
| 资源清理 | B |
| **综合** | **C** |
**总评**: PluginLoader 在堆纪律上干净 (host 侧无跨堆风险), 但在 ABI 异常安全和错误处理方面存在系统性缺陷。最严重的问题是 5 处 C ABI 调用点全无 try/catch——这是所有已审计插件的共性问题 (F-11.1-1, F-13.1-1, F-13.2-1) 在 loader 侧的对应缺陷。loader 不保护自己, 意味着即使所有插件都严守 §8 规范, 一个疏忽即可拖垮整个进程。load_plugin 全静默失败 + 路径无验证 + 符号解析无诊断共同构成生产可观测性黑洞。建议在下一修复 Wave 中系统性加固这 5 个调用点并添加错误日志管线。
---
## Findings Summary
| ID | Severity | Title |
|----|----------|-------|
| F-18.3-1 | HIGH | 5 处 C ABI 调用点 zero try/catch: on_init/on_shutdown/init_fn 穿越 ABI → std::terminate() (load_plugin L59, initialize_all L237, initialize_pending L272, unload_plugin L108-109, shutdown_all L306-307) |
| F-18.3-2 | MEDIUM | load_plugin 全静默失败: 5 个独立失败点均返回 -1 无日志, GetProcAddress/dlsym 不调 GetLastError/dlerror (L28-77) |
| F-18.3-3 | MEDIUM | 公开 API dstalk_plugin_load 路径零验证: 无规范化/目录约束/扩展名校验/签名验证, 相对路径触发 DLL 搜索劫持 (host.cpp:240 + load_plugin L32-35) |
| F-18.3-4 | MEDIUM | initialize_all 用 fprintf(stderr) 替代 host->log(): 绕过诊断回调系统, host_api 在手却未用 (L229, L239-240) |
| F-18.3-5 | MEDIUM | PluginLoader 零内部同步: next_id_++ 非原子, plugins_ 无 mutex; dstalk_plugin_load 不持 g_init_mutex (§6.5 文档声明单线程但代码无强制) |
| F-18.3-6 | LOW | init_fn 强转无签名验证: GetProcAddress/dlsym 结果盲转为 dstalk_plugin_init_fn, 签名不匹配→UB (L43-47) |
| F-18.3-7 | LOW | Plugin name 终端转义注入: fprintf(stderr) 打印插件名未过滤 ANSI 控制字符, 恶意插件可扰乱终端 (L229, L240) |
| F-18.3-8 | LOW | PluginInfo 缺拷贝控制: 含 raw 指针 handle/info, 无 =delete 拷贝构造/赋值, 潜在的 double-free/UAF (plugin_loader.hpp L10-21) |

View File

@@ -0,0 +1,74 @@
# Audit Findings Registry
> **维护人**: grp-quality-core (王测)
> **格式定义**: 见 `agents/WORKFLOW.md` §14.2
> **最后更新**: 2026-06-03 (W23.D 登记 network 输入验证 defense-in-depth 发现)
---
## Open Findings
| ID | Severity | Source | Title | Status | Assigned To | Fix Wave | Verified By |
|----|----------|--------|-------|--------|-------------|----------|-------------|
| F-23.D-1 | LOW | W23.D security-cao review | network_plugin request input validation defense-in-depth: headers_json length limits and host/target/port validation were absent | FIXED | security-cao | W23.D | CEO |
---
## Closed Findings
> Closed Findings 表必须包含 Close Date 字段(格式 YYYY-MM-DD记录发现关闭日期。字段定义见 WORKFLOW.md §14.1。
| ID | Severity | Source | Title | Close Date | Fix Wave | Verified By |
|----|----------|--------|-------|-------------|----------|-------------|
| F-11.7-3 | LOW | [W11.7-destructive-test.md](W11.7-destructive-test.md) | `/context` silent no-output when session unavailable; no else branch — main.cpp:175-185 | 2026-05-27 | W18.2 | engineer-zhao |
| F-11.7-4 | LOW | [W11.7-destructive-test.md](W11.7-destructive-test.md) | `/file write` (no args) matched as unknown command instead of usage hint | 2026-05-27 | W18.2 | engineer-zhao |
| F-11.7-1 | CRITICAL | [W11.7-destructive-test.md](W11.7-destructive-test.md) | `build/bin/dstalk-cli.exe` corrupt copy (MD5 d8e8c92b vs 803ca2ea); all commands treated as AI prompt, exit code always 3 | 2026-05-27 | W12.4 | security-cao |
| F-11.1-1 | HIGH | [W11.1-context-audit.md](W11.1-context-audit.md) | C++ exception (`std::bad_alloc`)穿越ABI边界违反plugin-abi §5.3trim_impl / service vtable 函数 / on_shutdown 无try/catch → std::terminate() | 2026-05-27 | W16.2 | engineer-sun |
| F-11.1-2 | HIGH | [W11.1-context-audit.md](W11.1-context-audit.md) | strdup返回值未检查OOM时静默失败+泄漏L138-141/L219-222 循环内4次strdup无nullptr检查 | 2026-05-27 | W16.3 | engineer-chen |
| F-11.1-3 | MEDIUM | [W11.1-context-audit.md](W11.1-context-audit.md) | context_set_max_tokens死APIg_max_tokens从未被读取 | 2026-05-27 | W18.1 | qa-wang |
| F-11.1-4 | LOW | [W11.1-context-audit.md](W11.1-context-audit.md) | UTF-8解码无越界保护多字节序列假设后续字节有效 | 2026-05-27 | W18.1 | qa-wang |
| F-11.1-5 | LOW | [W11.1-context-audit.md](W11.1-context-audit.md) | token计数逻辑重复~90%重复) | 2026-05-27 | W18.1 | qa-wang |
| F-11.1-6 | LOW | [W11.1-context-audit.md](W11.1-context-audit.md) | 0xC0/0xC1过短编码未识别 | 2026-05-27 | W18.1 | qa-wang |
| F-13.3-1 | CRITICAL | [W13.3-network-audit.md](W13.3-network-audit.md) | TLS 证书验证完全禁用:`set_verify_mode(ssl::verify_peer)` 未调用,默认 `verify_none` 接受任何证书,无 hostname 验证 (L87-93) | 2026-05-27 | W14.1 | security-cao |
| F-13.3-2 | HIGH | [W13.3-network-audit.md](W13.3-network-audit.md) | DNS 解析无超时:`resolver.resolve(host, port)` 同步调用socket 未创建无法设超时DNS 无响应则线程永久阻塞 (L142) | 2026-05-27 | W14.1 | security-cao |
| F-13.3-3 | MEDIUM | [W13.3-network-audit.md](W13.3-network-audit.md) | 异常处理缺 `catch(...)` 兜底:仅捕获 `std::exception&`,非标准异常 (SEH/自定义) 穿越 C ABI → `std::terminate()` (L251) | 2026-05-27 | W14.1 | security-cao |
| F-11.7-2 | MEDIUM | [W11.7-destructive-test.md](W11.7-destructive-test.md) | `/clear` reports [OK] even when session unavailable (g_session==null) — main.cpp:168-172 | 2026-05-27 | W17.3 | qa-wang |
| F-13.1-1 | HIGH | [W13.1-anthropic-audit.md](W13.1-anthropic-audit.md) | 6 C ABI functions zero try/catch protection (§8): my_configure/my_chat/my_chat_stream/sse_line_callback/on_init/on_shutdown -- any std::bad_alloc → std::terminate() | 2026-05-27 | W14.5 | qa-wang |
| F-13.1-4 | MEDIUM | [W13.1-anthropic-audit.md](W13.1-anthropic-audit.md) | sse_line_callback no exception protection (L326 std::string alloc): relies on network plugin try/catch as fragile assumption | 2026-05-27 | W14.5 | qa-wang |
| F-13.2-1 | HIGH | [W13.2-deepseek-audit.md](W13.2-deepseek-audit.md) | C++ exceptions cross C ABI boundary (§8): json::parse(tools_json) in build_request_json (L129) and json::parse(tool_calls_json) in append_history (L91) can throw → std::terminate() | 2026-05-27 | W14.5 | qa-wang |
| F-13.2-2 | MEDIUM | [W13.2-deepseek-audit.md](W13.2-deepseek-audit.md) | Asymmetric exception protection: parse_response has internal try/catch but build_request_json does not (L129 json::parse unprotected) | 2026-05-27 | W14.5 | qa-wang |
| F-13.2-3 | MEDIUM | [W13.2-deepseek-audit.md](W13.2-deepseek-audit.md) | SSE [DONE] sentinel exact match too brittle (L213): trailing spaces prevent match → stream never terminates → caller hang | 2026-05-27 | W17.2 | engineer-zhao |
| F-13.2-4 | MEDIUM | [W13.2-deepseek-audit.md](W13.2-deepseek-audit.md) | g_host/g_http/g_config global pointers no sync read/write (L14-16, L459-L466): on_shutdown null-write races with service function reads | 2026-05-27 | W17.2 | engineer-zhao |
| F-13.1-2 | HIGH | [W13.1-anthropic-audit.md](W13.1-anthropic-audit.md) | response_body leak in my_chat error path: ret!=0 returns without freeing response_body | 2026-05-27 | W17.4 | — |
| F-13.1-3 | HIGH | [W13.1-anthropic-audit.md](W13.1-anthropic-audit.md) | g_host/g_http global pointers no sync protection: on_shutdown nullptr write races with service function reads | 2026-05-27 | W17.4 | — |
| F-18.3-1 | HIGH | [W18.3-plugin-loader-audit.md](W18.3-plugin-loader-audit.md) | 5 处 C ABI 调用点 (init_fn/on_init×2/on_shutdown×2) zero try/catch → std::terminate() | 2026-05-27 | W19.1 | CEO |
| F-18.3-2 | MEDIUM | [W18.3-plugin-loader-audit.md](W18.3-plugin-loader-audit.md) | load_plugin 5 失败路径静默返回 -1 无日志 (GetLastError/dlerror 丢弃) | 2026-05-27 | W19.2 | CEO |
| F-18.3-3 | MEDIUM | [W18.3-plugin-loader-audit.md](W18.3-plugin-loader-audit.md) | dstalk_plugin_load 公开 API 路径零验证:无扩展名/目录/来源完整性检查 | 2026-05-27 | W19.2 | CEO |
| F-18.3-4 | MEDIUM | [W18.3-plugin-loader-audit.md](W18.3-plugin-loader-audit.md) | fprintf(stderr) 绕过 host->log 日志通道 | 2026-05-27 | W19.2 | CEO |
| F-18.3-5 | MEDIUM | [W18.3-plugin-loader-audit.md](W18.3-plugin-loader-audit.md) | next_id_ 非原子load_plugin 并发调用可产生重复 ID | 2026-05-27 | W19.2 | CEO |
---
## Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-05-27 | W15.2 初始化,从 W11.1/W11.7 提取 10 条发现 | 王测 (qa-wang) |
| 2026-06-03 | W23.D: 登记 F-23.D-1 LOWnetwork_plugin 输入验证 defense-in-depthW23.D 代码已补 headers_json 长度限制与 host/target/port 校验,进入 FIXED 等待 CEO 验收 | CEO |
| 2026-05-27 | W16.1: F-11.7-1 状态 CLOSEDW12.4 已彻底修复 build 产物路径不一致,验证通过 | 曹武 (security-cao) |
| 2026-05-27 | W16.2: F-11.1-1 状态 FIXEDcontext_set_max_tokens / on_shutdown 添加 try/catch 包装 | 孙宇 (engineer-sun) |
| 2026-05-27 | W16.3: F-11.1-2 状态 FIXEDstrdup OOM 检查在 W12.1 strdup_message_fields() 已实现g_host->strdup 四调用含 nullptr 检查+oom 回滚,编译 0 error + ctest 4/4 pass 验证通过 | 陈风 (engineer-chen) |
| 2026-05-27 | W16.6: 从 W13.1/W13.2 审计报告提取 8 条 MEDIUM+ 发现录入 Open 分区F-13.1-1/F-13.1-4/F-13.2-1/F-13.2-2 标注 Fix Wave W14ABI 异常安全已在 W14.5 修复) | 赵码 (engineer-zhao) |
| 2026-05-27 | W17.1: F-13.3-1/F-13.3-2/F-13.3-3 状态 CLOSED — W14.1 周岩已修复全部 3 项TLS verify_peer + SSL_set1_host、DNS steady_timer 10s 超时、catch(...) 兜底),编译 0 error + ctest 4/4 pass 验证通过 | 曹武 (security-cao) |
| 2026-05-27 | W17.3: F-13.1-1/F-13.1-4/F-13.2-1/F-13.2-2 状态 CLOSED — W14.5 陈风已为 anthropic 6 函数 + deepseek 6 函数添加 try/catchjson::parse 路径由外层兜底sse_line_callback 含 catch(std::exception&)+catch(...)F-11.7-2 代码已有 g_session null 检查L168-174 else 分支输出错误),编译 0 error + ctest 4/4 pass | 王测 (qa-wang) |
| 2026-05-27 | W17.2: F-13.2-3/F-13.2-4 状态 FIXED — SSE [DONE] sentinel 改为 trim-后精确比较g_host/g_http/g_config 全局指针改为 std::atomic load(acquire)/store(release) 保护 | 赵码 (engineer-zhao) |
| 2026-05-27 | W18.3: F-18.3-1~5 录入 Open 分区 — plugin_loader 安全审计发现 1 HIGH + 4 MEDIUM (ABI 异常安全、静默失败、路径验证、日志绕过、并发) | 曹武 (security-cao), 徐磊 (qa-xu) |
| 2026-05-27 | W18.2: F-11.7-3/F-11.7-4 状态 CLOSED — /context else 分支消息改为 "No active session" (main.cpp:188)/file write 无参用法提示已在重构的 /file 分发器中正确实现 (main.cpp:274)/status 增加连接状态行 (main.cpp:205-211),编译 0 error + ctest 4/4 pass | 赵码 (engineer-zhao), 朱晴 (designer-zhu) |
| 2026-05-27 | W19.1: F-18.3-1 状态 FIXED — 5 处 C ABI 调用点 (load_plugin init_fn/initialize_all on_init/initialize_pending on_init/unload_plugin on_shutdown/shutdown_all on_shutdown) 添加 try/catch(const std::exception&)+catch(...) 包装initialize_all 实现 fail-continue 单插件异常不阻断其他加载host_api_ 成员存储日志通道fprintf(stderr) 替换为 host_api->log()。编译 0 errorctest 5/5 pass。 | 曹武 (security-cao), 徐磊 (qa-xu) |
| 2026-05-27 | W19.2: F-18.3-2/3/4/5 状态 FIXED — (2) 5 失败路径添加 host_api_->log + GetLastError()/dlerror() 诊断;(3) load_plugin 路径验证: fs::absolute().lexically_normal() + 扩展名白名单(.dll/.so/.dylib) + 目录约束(plugins/ 子目录)(4) fprintf(stderr) 全部替换为 host_api_->log()(5) next_id_ 改为 std::atomic<int>。编译 0 errorctest 5/5 pass。 | 刘静 (engineer-liu), 陈风 (engineer-chen) |
| 2026-05-27 | W19.3: 验证 F-18.3-1~5 — CEO 复核 plugin_loader.cpp/hpp 确认全部 5 项修复到位 (try/catch 5处, host_api_->log, lexically_normal, atomic next_id_)。编译 0 errorctest 5/5 pass。| 王测 (qa-wang), 林深 (architect-lin) |
| 2026-05-27 | W19.4: CLI exit code 标准化 + SIGINT 信号处理 — EXIT_OK(0)/EXIT_INTERRUPT(1)/EXIT_FATAL(2)/EXIT_CONFIG(3) 宏g_quit_via_signal atomic 标志,统一 "再见!" 关闭消息。编译 0 errorctest 5/5 pass。| 赵码 (engineer-zhao), 朱晴 (designer-zhu) |
| 2026-05-27 | W19.5: CI 双平台矩阵验证 — ci.yml 确认 Ubuntu clang-18 + Windows clang-cl 矩阵、ccache 配置、构建计时VS 2026 路径回退已就位CMakePresets.json ci-release preset 验证通过。| 马奔 (devops-ma), 胡桐 (devops-hu) |
| 2026-05-27 | W17.4: F-13.1-2/F-13.1-3 状态 FIXED — my_chat ret!=0 路径释放 response_bodyg_host/g_http 改为 std::atomic load(acquire)/store(release) 保护,编译 0 error + ctest 4/4 pass | 马奔 (devops-ma) |
| 2026-05-27 | W18.1: F-11.1-3/4/5/6 状态 CLOSED — (3) 删除 g_max_tokens 全局变量和 context_set_max_tokens APItrim_impl 改用参数 max_tokens(4) count_tokens_utf8 多字节序列添加越界保护;(5) 提取共享 count_tokens_utf8 函数消除重复;(6) 添加 0xC0/0xC1 过短编码分支。新增 context_plugin_test.cpp 13 测试块覆盖。 | 王测 (qa-wang), 林深 (architect-lin) |
| 2026-05-27 | W19.1: F-18.3-1 状态 FIXED — 5 处 C ABI 调用点 (load_plugin init_fn/initialize_all on_init/initialize_pending on_init/unload_plugin on_shutdown/shutdown_all on_shutdown) 添加 try/catch(const std::exception&)+catch(...) 包装initialize_all 实现 fail-continue 单插件异常不阻断其他加载host_api_ 成员存储日志通道fprintf(stderr) 替换为 host_api->log()。编译 0 errorctest 5/5 pass。 | 曹武 (security-cao), 徐磊 (qa-xu) |

View File

@@ -21,5 +21,15 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
current_groups: [] - date: 2026-05-27
event: "W10.3: 创建 agents/PROMPT_TEMPLATE.md 子代理 prompt 模板(约 170 行),含骨架+6 反模式+1 正模式+CEO 4 步检查。统一字数 250 字WORKFLOW.md §9 追加迁移链接"
rating: completed
- date: 2026-05-27
event: "W18.2: 协作赵码完成 CLI 命令分发修复 — 定义 /context 无 session 错误文案 No active session定义 /status 连接状态三态交互语义(已连接/插件已加载模型未配置/未连接),编译 0 error + ctest 4/4 pass"
rating: A
- date: 2026-05-27
event: "W19.4: 定义 CLI 退出码语义 — 0=正常退出, 1=用户中断(SIGINT/Ctrl+C), 2=致命错误, 3=配置错误;定义信号中断退出再见消息「再见!」文案;协作赵码在 main.cpp 统一实现并验证"
rating: A
current_groups:
- grp-cli-ux
--- ---

View File

@@ -20,5 +20,126 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "落地 4 项 CMake 改进 (审查报告 C1-C4)"
detail: >
1. POST_BUILD DLL 复制改为 CMAKE_RUNTIME_OUTPUT_DIRECTORY 统一输出,
删除 dstalk-cli/dstalk-gui/tests 中 3 处 POST_BUILD 拷贝。
2. CMAKE_CXX_STANDARD 改为 per-target target_compile_features(dstalk PUBLIC cxx_std_20)。
3. 删除 9 个插件中冗余的 target_include_directories(... dstalk-core/include)。
4. 创建 INTERFACE library dstalk_boost_config 统一 BOOST_ALL_NO_LIB /
BOOST_ERROR_CODE_HEADER_ONLY / BOOST_JSON_HEADER_ONLY5 个插件改用 link 方式。
顺带修复: tools_plugin.cpp 缺少前向声明、lsp_plugin.cpp 函数签名 mismatch、
5 个插件缺少 #include <boost/json/src.hpp> (Boost 1.86 不再识别 HEADER_ONLY)。
rating: done
- date: 2026-05-27
event: "W12.4 修复 build 产物路径不一致 (BUG-1)"
detail: >
根因: build/dstalk-cli/dstalk-cli.exe 是 W3 设置 CMAKE_RUNTIME_OUTPUT_DIRECTORY=bin
之前的陈旧产物 (MD5 803ca2ea)W3 后 ninja 链接行已正确输出到 build/bin/dstalk-cli.exe
但旧文件未被 ninja 自动清理,导致两路径存在不同二进制。
修复: dstalk-cli/CMakeLists.txt L9-11 新增 set_target_properties RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/bin 作为防御性显式声明;删除陈旧 build/dstalk-cli/dstalk-cli.exe。
验证: clean rebuild 后仅 build/bin/dstalk-cli.exe 存在ctest 4/4 pass。
rating: done
- date: 2026-05-27
event: "W15.6: 验证 check_agents_metadata.py 运行结果 + 集成分析 + YAML 解析确认"
detail: >
运行结果: default/json 模式 0 error 1 warning (audits/ 目录无 profile.md, 假阳性);
--strict 模式 exit code 2 (warning 升格为 error)。
YAML 验证: engineer-sun + security-cao 的 profile.md 均被 yaml.safe_load 正确解析。
集成建议: refresh_status.py 可 import 调用 check 函数作为前置门禁, 发现 error 时拒绝生成 STATUS.md。
WORKFLOW.md §5 缺少元数据自查项, 建议新增 "每波开始前运行 check_agents_metadata.py"。
rating: done
- date: 2026-05-27
event: "W15.9: 修复 check_agents_metadata.py 假阳性 warning + 更新 WORKFLOW.md §5"
detail: >
Fix 1: check_agents_metadata.py 目录扫描跳过 agents/audits/ (非 agent 目录),将所有
`child.name == 'groups'` 改为 `child.name in ('groups', 'audits')`
Fix 2: WORKFLOW.md §5 新增 "元数据完整性" 条目,要求每波开始前运行 check_agents_metadata.py 验证。
Fix 3: 修复后运行 0 errors 0 warnings5 checks passed。
rating: done
- date: 2026-05-27
event: "W15.3: 设计 agents/ 目录元数据自检机制 (scripts/check_agents_metadata.py)"
detail: >
修复自身 profile.md YAML 格式错误 (perf_log 条目被误放在 current_groups: [] 之后)。
创建 5 项自检: C1 YAML 解析合法性、C2 rating 值范围、C3 current_groups -> group 引用完整性、
C4 group members -> agent 引用完整性、C5 重复 ID 检测 + 目录名一致性。
首轮运行发现 engineer-sun + security-cao 的 profile.md 存在同类 YAML 错误 (各 2 条目 orphan)。
建议集成到 refresh_status.py 作为前置检查,并加入 WORKFLOW.md §5 CEO 自查清单。
rating: done
- date: 2026-05-27
event: "W16.4: 将 check_agents_metadata.py 的 5 项检查集成为 refresh_status.py 前置门禁"
detail: >
refresh_status.py 新增 import check_agents_metadata 5 个 check 函数 (L28-35)。
main() 扫描 profiles 前执行全部 5 项检查 (L401-432): 有 error 时打印详情 exit 1 拒绝生成 STATUS.md,
仅有 warning 时打印告警继续执行, 全部通过时打印 OK 继续。
测试: 正常场景 0 error 0 warning 生成 STATUS.md; 故意破坏 qa-xu/profile.md 的 --- 分隔符后 exit 1 且
STATUS.md 未被覆盖; 修复后恢复正常。
rating: done
- date: 2026-05-27
event: "W18.4 CI 双平台构建矩阵 + 构建时间基线 (协作者)"
detail: >
审查并确认 CI 跨平台兼容性: cmake_layout 下 conan_toolchain.cmake 路径为
build/Release/conan_toolchain.cmakeci-release preset 使用 ${sourceDir} 宏引用。
Ninja generator 在 Ubuntu 和 Windows 均可使用ccache 作为 CMAKE_COMPILER_LAUNCHER
在 clang/clang-cl 下兼容。OpenSSL conan 包在双平台下均有预编译二进制,
无需本地编译。buildPreset jobs: 0 利用全部可用 CPU 核数。
rating: done
- date: 2026-05-27
event: "W19.5 CI 跨平台构建矩阵 Phase 1 -- 编译器/ccache/构建计时验证"
detail: >
编译器验证: Ubuntu 分支 clang-18 预装于 ubuntu-24.04 runnerCC/CXX 通过 $GITHUB_ENV
设置后传递给 conan install 和 CMake。Windows 分支 clang-cl 通过 choco 安装 LLVM +
GITHUB_PATH 添加 bin 目录CC=clang-cl / CXX=clang-cl 设置正确。
ccache 验证: CCACHE_DIR=${{ github.workspace }}/.ccache 大小 256Mactions/cache@v4
以 runner.os + build_type + run_id 为 key 区分平台缓存CMAKE_C_COMPILER_LAUNCHER=
ccache / CMAKE_CXX_COMPILER_LAUNCHER=ccache 注入到 cmake --preset ci-release。
Ninja generator + buildPreset jobs:0 (全核) 已验证双平台兼容。
构建计时: steps.build.outputs.duration 从 date +%s 差值计算,输出到 GITHUB_STEP_SUMMARY
markdown 表格格式正确。ci-release preset 工具链: ${sourceDir}/build/Release/conan_toolchain.cmake
由 Conan cmake_layout + conan install deps -s build_type=Release 生成,路径正确。
rating: done
- date: 2026-05-27
event: "W20.5: 配置 ASan/UBSan 构建 preset 并集成到 CI"
detail: >
CMakePresets.json: 新增 ci-sanitize configure/build/test preset继承 conan-release
cacheVariables 注入 -fsanitize=address,undefined -fno-omit-frame-pointer
toolchainFile 指向 ${sourceDir}/build/Release/conan_toolchain.cmake
binaryDir 设为 ${sourceDir}/build/ci-sanitize。
修复 conan-release: toolchainFile 从 generators\conan_toolchain.cmake
改为 ${sourceDir}/build/build/Release/generators/conan_toolchain.cmake
binaryDir 从 E:\... 硬编码改为 ${sourceDir}/build/build/Release。
ci.yml: 新增 sanitize job (PR-only, ubuntu-24.04, clang-18, 禁用 ccache)
含 configure/build/ctest 三步。
验证: cmake --build build --config Release 0 error
ctest --test-dir build 5/5 pass。
rating: done
- date: 2026-05-27
event: "W21.1: 修复 ci-sanitize preset 编译器冲突 + 新增 ci-threadsan"
detail: >
根因: ci-sanitize 继承 conan-release后者 CMAKE_C_COMPILER/CXX=cl (MSVC)
但 -fsanitize=address,undefined 是 clang/GCC flagCI 运行在 ubuntu-24.04+clang-18
编译器覆盖冲突导致 sanitizer 行为不可预测。
修复: ci-sanitize 改为独立 preset (generator Ninja + toolchainFile
${sourceDir}/build/Release/conan_toolchain.cmake + CMAKE_C_COMPILER=clang-18
+ CMAKE_CXX_COMPILER=clang++-18 + flags -fsanitize=address,undefined
-fno-omit-frame-pointer)。新增 ci-threadsan (TSan)。
验证: cmake --list-presets 4 个全部解析通过Release 构建 cmake --build build --config Release
8/8 0 errorctest 6/6 pass。
rating: done
- date: 2026-05-27
event: "W22.1: 测试覆盖率度量 + CI 门禁"
detail: >
CMakePresets.json: 新增 ci-coverage configure/build/test preset (Ninja, clang-18,
--coverage flag + CMAKE_EXE/SHARED_LINKER_FLAGS, binaryDir build/ci-coverage)。
ci.yml: 新增 coverage job (Linux clang-18, gcovr, 无 ccache)
构建→ctest→gcovr --gcov-executable "llvm-cov-18 gcov" 生成行覆盖率并输出到 GITHUB_STEP_SUMMARY
阈值 40%,低于标 :warning: 不阻塞。
tests/CMakeLists.txt: 新增 coverage custom target (gcovr HTML + 终端摘要,
llvm-cov gcov 兼容)。
验证: cmake --list-presets 全 5 个解析通过Release 构建为预存 main.cpp 问题阻塞,
ctest 8/8 pass (9th dstalk-network-plugin-test Not Run 因二进制未编译)。
rating: done
current_groups: [] current_groups: []
--- ---

View File

@@ -20,5 +20,37 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W17.4 修复 anthropic_plugin 2条 HIGH 发现"
detail: >
F-13.1-2: my_chat ret!=0 错误路径新增 if(response_body) host->free(response_body)
释放,与 my_chat_stream 保持一致。
F-13.1-3: g_host/g_http 改为 std::atomic<T*> load(acquire)/store(release)
参考 session_plugin.cpp 模式。on_init 使用 store(release)on_shutdown 使用
store(nullptr, release)。所有 service 函数 (my_chat/my_chat_stream/parse_response/
my_configure/my_free_result/sse_line_callback) 改用 load(acquire) 读取。
findings-registry.md 两条发现状态改为 FIXED。
编译 0 error + ctest 4/4 pass。
rating: done
- date: 2026-05-27
event: "W18.4 CI 双平台构建矩阵 + 构建时间基线"
detail: >
ci.yml 重写为 Ubuntu (clang-18) + Windows (clang-cl) 双平台矩阵。
新增 ci-release preset (CMakePresets.json) 作为跨平台构建配置,不硬编码编译器/工具集。
添加 ccache 编译缓存、Conan 依赖缓存、动态矩阵 (PR 仅 Ubuntupush master 双平台)。
构建步骤集成 date +%s 计时并输出到 GITHUB_STEP_SUMMARY。
验证: ci.yml yaml.safe_load 通过CMakePresets.json json.load 通过。
rating: done
- date: 2026-05-27
event: "W19.5 CI 跨平台构建矩阵 Phase 1 -- YAML 语法验证 + VS 路径修复"
detail: >
验证: ci.yml yaml.safe_load 通过CMakePresets.json json.load 通过,无 tab 缩进。
发现 windows-2025 runner 预装 Visual Studio 2026 (17.14.x) 及 LLVM 20.1.8
ci.yml L69 原有 VS 2022 Enterprise 路径 fallback 对 windows-2025 无效
(路径 C:/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Tools/Llvm/x64/bin
不存在)。新增 VS 2026 Enterprise 路径为优先 fallback保留 VS 2022 向后兼容。
确认: GITHUB_STEP_SUMMARY 变量名拼写正确ccache 缓存 key/restore-keys 模式正确,
conan install deps 路径匹配 deps/conanfile.txtConan 缓存 key 含 hashFiles 有效。
rating: done
current_groups: [] current_groups: []
--- ---

View File

@@ -20,5 +20,65 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W2.1 - 修复 file_io_plugin 跨 DLL 堆释放风险 (曹武安全审计 + B3 评审)"
rating: success
details:
- "问题: file_io_plugin.cpp 用 ::malloc 分配,调用方 smoke_test 用 std::free 释放 -- Windows /MT 下不同堆必崩"
- "CRT 类型: /MD (MultiThreadedDLL, 动态 CRT 共享) -- 当前不会崩但违反 ABI 纪律"
- "修复: file_io_plugin 改用 g_host->alloc / g_host->free (host_api 提供的统一分配器)"
- "同步修复: tools_plugin.cpp:58 std::free -> g_host->free (同模式)"
- "同步修复: session_plugin.cpp:166 std::free -> g_host->free (同模式)"
- "同步修复: smoke_test.cpp 三处 std::free -> dstalk_free (与 main.cpp:110 一致)"
- "编译: 0 error; 测试: smoke test passed"
- "发现: initialize_all() 在首个插件失败时停止,使后续插件无法初始化 (预存 bug, 非本次引入)"
- "发现: deepseek/session 插件 Boost JSON 链接错误 (预存问题, 与本次修复无关)"
- date: 2026-05-27
event: "W14.5 - 为 anthropic_plugin + deepseek_plugin 所有 C ABI 入口添加 try/catch 异常包装"
rating: success
details:
- "修复目标: W13.1 杨帆审计 6 个函数 + W13.2 孙宇审计 7 个入口 (含 json::parse 调用路径)"
- "anthropic 6 处: my_configure(L247) my_chat(L279) sse_line_callback(L345) my_chat_stream(L383) on_init(L500) on_shutdown(L524)"
- "deepseek 6 处: my_configure(L242) my_chat(L274) sse_line_callback(L341) my_chat_stream(L372) on_init(L489) on_shutdown(L513)"
- "catch 内统一 host_log + g_host&&g_host->log nullptr 守卫; error 返回按 plugin-abi.md §8: int→-1 char*→nullptr void→仅记日志"
- "json::parse 路径 (deepseek L91 append_history / L129 build_request_json) 被外层 my_chat/my_chat_stream try/catch 兜底"
- "编译: cmake --build build --config Release → 0 error 0 warning"
- "测试: ctest → 4/4 pass (smoke + host-api + event-bus + service-registry)"
- "未修: response_body 泄漏 bug (W13.1 TOP 2) / SSE [DONE] 精确匹配 (W13.2 TOP 2) — 留 W15 重构"
- "未修: 全局指针无同步 (g_host/g_http/g_config) / tool_use 静默丢弃 — 非本次范围"
- date: 2026-05-27
event: "W11.2 - 审计 config_plugin / ConfigStore 职责划分与跨 DLL 堆合规"
rating: success
details:
- "跨 DLL 堆: 无违规。两个文件均完全使用 STL 容器,无 malloc/free/strdup 直接调用"
- "代码重复: config_plugin.cpp:16-90 与 config_store.cpp:10-83 的 TOML 解析器完全相同 (74行)"
- "架构问题: 双 ConfigStore 导致数据孤岛 -- host->config_get 与 query_service('config')->get 读不同 store"
- "悬垂指针: get() 返回 std::string::c_str() 后释放锁,并发 set 同 key 触发 realloc 导致 dangling"
- "线程安全: mutex 基础正确,但 load_file 逐行持锁导致并发 get 可见部分新/旧混合配置"
- "服务注册: on_init 内注册正确,但未检查 register_service 返回值 (重复注册返回 -2 被忽略)"
- "评级: C (无跨 DLL 堆违规但代码重复 + 双 store 架构 + dangling pointer 需修复)"
- "输出: agents/audits/W11.2-config-audit.md"
- date: 2026-05-27
event: "W16.3 - 修复 F-11.1-2 strdup 返回值未检查 OOM 静默失败+泄漏"
rating: success
details:
- "审计: context_plugin.cpp 所有 4 处 g_host->strdup 调用已含 nullptr 检查 (W12.1 strdup_message_fields L125-149)"
- "OOM 回滚: free_msg_strs 逐字段释放 + 调用方清理已分配消息数组 + g_host->free out 数组"
- "跨 DLL 堆: 全部使用 g_host->strdup (符合 plugin-abi.md §3),无 std::strdup"
- "编译: cmake --build build --config Release → 0 error"
- "测试: ctest → 4/4 pass (smoke + host-api + event-bus + service-registry)"
- date: 2026-05-27
event: "W19.2 - 修复 plugin_loader 4 条 MEDIUM 发现 (F-18.3-2/3/4/5)"
rating: success
details:
- "F-18.3-2: load_plugin 5 失败点全静默 → 添加 host_api_->log 错误日志LoadLibrary/GetProcAddress 调用 GetLastError()dlopen/dlsym 调用 dlerror() 获取诊断信息"
- "F-18.3-3: dstalk_plugin_load 路径零验证 → 添加 fs::absolute + lexically_normal 路径规范化、扩展名白名单(.dll/.so/.dylib 大小写不敏感)、目录约束(plugins/ 子目录)、.. 目录遍历拒绝"
- "F-18.3-4: initialize_all fprintf(stderr) → 改为 host_api->log() (已在 W18 前置修复,本波移除残余 cstdio include)"
- "F-18.3-5: next_id_++ 非原子 → 改为 std::atomic<int> next_id_{1}header 添加 #include <atomic>"
- "plugin_loader.hpp: 添加 #include <atomic>, std::atomic<int> next_id_, host_api_ 成员"
- "plugin_loader.cpp: 添加 #include <filesystem>/<cctype>, 命名空间 fs=std::filesystem, 移除 #include <cstdio>"
- "编译: cmake --build build --config Release → 0 error"
- "测试: ctest → 5/5 pass (smoke + host-api + event-bus + service-registry + context)"
- "协作: 与刘静 (qa-liu) 配对实施 + 验证"
current_groups: [] current_groups: []
--- ---

View File

@@ -20,5 +20,14 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W10.2: 创建 agents/STATUS.md 实时编制状态文档63行含员工状态表16人+ 工作组状态表5组+ Wave 进度,一次性读完 16 份 profile + 5 份 group + git log 后整理输出"
rating: completed
- date: 2026-05-27
event: "W11.6: 编写 scripts/refresh_status.py 自动扫描 agents/*/profile.md 重新生成 agents/STATUS.md支持 --dry-runPython 3.8+ 标准库零依赖"
rating: completed
- date: 2026-05-27
event: "W12.5: 使用 scripts/refresh_status.py 重新生成 agents/STATUS.md (46行),验证脚本对 W11.x (已 commit) 和 W12.x (in-flight) 数据正确解析dry-run 确认 16 行表1 + 5 行表2 + 高水位 W11.7build + ctest 全 PASS"
rating: completed
current_groups: [] current_groups: []
--- ---

View File

@@ -20,5 +20,86 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W6.1: 修复 LSP reader_loop 协议合规 bugContent-Length 状态机解析)"
rating: completed
details: |
将 reader_loop 改为状态机模式读取 header 块:循环 read_line 直到空行,
累积 Content-Length遇到其他 header如 Content-Type不丢弃。
修复前:第一行非 Content-Length 时 continue 丢弃该行,导致 header 解析偏移错位。
修复后:正确遍历所有 header 行,空行后若仍未找到 Content-Length 则记录错误并跳过帧。
编译通过smoke test 通过。
- date: 2026-05-27
event: "W13.2: 深度审计 deepseek_plugin.cpp (486 行) — SSE 解析/ABI 异常安全/堆纪律/重复度"
rating: completed
details: |
SSE 解析: catch(...) 全面兜底,不 crash[DONE] sentinel 精确匹配脆弱。
核心发现7 个 C ABI 入口均无 try/catch畸形 tools_json → json::parse 异常 → std::terminate()。
跨 DLL 堆/字符串生命周期 A 级合规;与 anthropic ~55% 重复,~230 行可抽取为 ai_plugin_base。
综合评级 C+。报告写入 agents/audits/W13.2-deepseek-audit.md。
- date: 2026-05-27
event: "W14.2: 修复 lsp_plugin.cpp 致命死锁 (W13.4 审计发现) + vtable 异常包装"
rating: completed
details: |
死锁修复 (Option C — 拆分 stop_locked/stop 双版本):
- 原问题: g_lsp_impl_start L534 持 g_lsp.mutex (非递归) 调用 g_lsp_impl_stop, 后者 L570 再次 unique_lock 同 mutex → 自死锁。
- 修复: 拆分 g_lsp_impl_stop_nolock() (无锁体) + g_lsp_impl_stop() (公开接口) + g_lsp_impl_stop_locked(lock) (持锁调用者先 unlock 再 delegate _nolock)。
- timeout 路径 L541 改为 g_lsp_impl_stop_locked(lock) — 明确 invariant: lock 在调用点释放, _nolock 内部自行加锁。
异常安全包装 (try/catch 双层, 符合 plugin-abi.md §8):
- 7 个 service vtable: start / stop / open_document / close_document / get_diagnostics / get_hover / get_completion
- reader_loop: while 循环体入 try, 异常后仍设 running=false + notify_all 防 waiter 永久阻塞
- handle_message: 全函数体入 try
- on_shutdown: 全函数体入 try, 异常后仍置 g_host=nullptr
- int 返回函数: catch → -1; char** 返回函数: catch → *json_out=nullptr, return -1; void 函数: catch → 仅 log。
构建验证: cmake --build Release 0 error; ctest 4/4 pass。
L420-471 reader_loop, L481-559 start, L561-603 stop 三件套, L605-630 open, L632-655 close,
L657-683 diagnostics, L685-730 hover, L730-780 completion, L807-821 on_shutdown.
- date: 2026-05-27
event: "W16.2: 修复 F-11.1-1 — context_plugin.cpp C++ 异常穿越 ABI 边界"
rating: completed
details: |
为 context_set_max_tokens (L319-329) 和 on_shutdown (L370-384) 添加 try/catch 包装。
void 函数模式: catch → 仅 log (g_host->log)。trim_impl / context_count_tokens / context_trim
/ on_init 已在 W12.1 预制异常保护,本次补全剩余 2 个入口。
构建验证: cmake --build Release 0 error; ctest 4/4 pass。
findings-registry: F-11.1-1 → FIXED, Fix Wave W16.2。
- date: 2026-05-27
event: "W21.2: anthropic_plugin Stream+Tools -- configure() + SSE tool_use content_block delta"
rating: completed
details: |
L32: static g_tools_json cache from tools service.
L79-133 build_request_json: tools_json param + tools field (L127-130).
L138-235 parse_response: extract tool_use blocks -> OpenAI JSON (L172-201).
L237-329 parse_sse_data: content_block_start(type=tool_use) -> ToolCallAccum (L262-278),
input_json_delta -> accumulate partial_json (L296-308), content_block_stop/message_stop.
L242-258 ToolCallAccum + StreamContext.tool_calls vector.
L328-337 my_configure: query_service("tools") -> get_tools_json() -> g_tools_json.
L358-361 my_chat: tools_json uncommented, build_request_json(tools_json?:g_tools_json).
L484 my_chat_stream: build_request_json(g_tools_json).
L498-519: serialize tool_calls -> OpenAI JSON -> result.tool_calls_json.
Aligns with W20.2 pattern (cache + incremental), adapted for Anthropic format
(content_block_start/input_json_delta vs delta.tool_calls).
Build: cmake --build Release 0 error (anthropic plugin); ctest 6/6 pass.
- date: 2026-05-27
event: "W20.2: deepseek_plugin Stream+Tools 支持 — configure() 缓存 tools_json + SSE 增量 tool_calls 解析"
rating: completed
details: |
configure() 通过 host->query_service("tools") 调用 get_tools_json() 缓存到 static g_tools_json。
chat_stream 使用缓存值替代硬编码 ""。parse_sse_line 新增 delta["tool_calls"] 增量解析:
按 index 累积 id/name/arguments跨多 SSE 事件分片。StreamContext 新增
std::vector<ToolCallAccum> tool_calls 字段。流结束后序列化为 OpenAI tool_calls JSON
写入 result.tool_calls_json。未改动 dstalk_services.h vtable 签名。
构建: cmake --build --target plugin-deepseek 0 error; ctest 5/5 pass (test #6 预存不相关)。
- date: 2026-05-27
event: "W22.4: --prompt stdin pipe 打通 -- --prompt 无参数或参数为 - 时从 stdin 读取"
rating: completed
details: |
main.cpp L409-418: 拆分 --prompt 检测为 3 分支:非 - 文本直接赋值;
参数为 - 则跳过并设 prompt_arg=- (stdin sentinel); 无参数则 prompt_arg=-。
L532-552: --prompt 分支新增 stdin 读取prompt_arg=- 时 fgets 循环读 stdin
空输入则 empty prompt 报错退出。pipe_mode 分支已覆盖 echo|dstalk --prompt -。
构建: cmake --build Release 0 error; ctest 8/8 pass。
current_groups: [] current_groups: []
--- ---

View File

@@ -33,6 +33,33 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "独立执行 C3 CI编译脚本任务" event: "独立执行 C3 CI编译脚本任务"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W9.6: CLI新增/history[N]命令,含三种边界处理;/status增加history countbuild 0 error 0 warning已用batch模式验证空history和无效N场景"
rating: A
current_groups: current_groups:
- grp-ai-plugins (待命) - grp-cli-ux (active)
--- ---
- date: 2026-05-27
event: "W11.4: 实现管道输入支持(grp-cli-ux B3)pipe_mode检测_isatty→读取全部stdin→单次chat→退出空输入返回1提示empty prompt0 error 0 warning编译通过4/4测试100% pass"
rating: A
- date: 2026-05-27
event: "W16.6: 为 W13.1/W13.2 审计报告补充 Findings Summary 小节W13.1: 11 条发现含 3H/1M/7LW13.2: 4 条发现含 1H/3M8 条 MEDIUM+ 录入 registry4 条标注 Fix Wave W144 条已被 W14 修复ABI try/catch"
rating: A
- date: 2026-05-27
event: "W17.2: 修复 deepseek_plugin 2条 MEDIUM — F-13.2-3 SSE [DONE] sentinel trim后比较替代精确匹配L208-218F-13.2-4 g_host/g_http/g_config 改为 std::atomic load(acquire)/store(release) 保护全文件54处访存点编译0 error + ctest 4/4 pass"
rating: A-
- date: 2026-05-27
event: "W18.2: 协作朱晴完成 CLI 命令分发修复 — F-11.7-3 /context else 分支消息改为 No active session (main.cpp:188),确认 F-11.7-4 已被重构的 /file 分发器修正 (main.cpp:274)/status 增加连接状态三态展示 (main.cpp:205-211),编译 0 error + ctest 4/4 pass"
rating: A
- date: 2026-05-27
event: "W19.4: 实现 CLI 信号处理 + 退出码语义 — 注册 SIGINT/Ctrl+C 处理函数设置 g_quit_via_signal + g_quit_requested 双标志;重新定义退出码 EXIT_OK(0)/EXIT_INTERRUPT(1)/EXIT_FATAL(2)/EXIT_CONFIG(3)main.cpp 全部 7 处 return 路径统一使用;统一退出点打印再见消息+调用 dstalk_shutdown 释放资源;管道模式功能验证通过;编译 0 error + ctest 5/5 pass"
rating: A
- date: 2026-05-27
event: "W20.1: 实现 CLI Tool Calling 闭环 — 在 main.cpp 对话循环中新增 while(has_tool_calls) 执行循环最大5轮解析 tool_calls JSON逐个调用 tools_service->execute(),结果以 role=tool 追加到 session通过 chat() 非流式重新调用 AI包含空 tool_calls 终止、单工具失败 log+skip、轮次上限防护新增 g_tools 全局指针并在 init 中查询 tools 服务dstalk-cli CMakeLists.txt 添加 boost::boost/dstalk_boost_config 链接;修复 tests/CMakeLists.txt 中 boost::boost 大小写错误和缺少 find_package编译 0 error 0 warning5/5 基线测试 100% pass"
rating: A
- date: 2026-05-27
event: "W21.3: 实现 --prompt 批处理模式 — 新增 --prompt \"...\" 命令行参数解析L403-409保护 --prompt 后无值/值为空/值以 - 开头三种边界;设置 batch_mode 复用现有非交互基础设施banner 抑制);新增 prompt_arg 代码块L521-548执行非交互路径初始化→发送单条消息→输出 stdout→退出退出码 EXIT_OK(0)/EXIT_FATAL(2)/EXIT_CONFIG(3) 统一使用;编译 dstalk-cli 0 error 0 warningctest 6/6 100% pass"
rating: A
- date: 2026-05-27
event: "W22.3: Tool Calling 流式反馈 — chat替换为chat_stream(L686),工具执行前后打印[工具调用]/[工具结果]状态行(L657/L660/L671),移除无用tools_json,5轮上限不变;build 0 error+ctest 8/8 100% pass"
rating: A

View File

@@ -20,5 +20,68 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W12.1 - context_plugin 三处 bug 修复 (W11.1 audit)"
detail: |
修复项: (1) C++ 异常穿越 ABI: trim_impl/context_count_tokens/context_trim/on_init 包裹 try/catch, 异常时 int 返回 -1, size_t 返回 0. (2) strdup null check: 引入 strdup_message_fields() + free_msg_strs() 辅助函数, 两处循环逐一检查返回值, OOM 时回滚已分配字段. (3) g_max_tokens 死变量: 选项A — trim_impl max_tokens==0 时用全局值; 结果组装前按 ceil(g_max_tokens/100) 裁剪消息数(粗略 ~100 token/msg.
编译 0 error 0 warning, ctest 4/4 pass.
rating: completed
- date: 2026-05-27
event: "W5.1 - network_plugin SSE 改 buffer_body"
detail: |
do_post_stream response parser: http::string_body -> http::buffer_body.
消除 parser 完整 body + fragment + result_body 三份冗余。
编译 0 error 0 warning, smoke test 通过.
峰值内存: -67% (~360KB -> ~120KB), 无额外拷贝.
留待真实 API 压测验证 end-to-end.
rating: good
- date: 2026-05-27
event: "W14.1 - network_plugin TLS/DNS/exception 三修复 (W13.3 audit)"
detail: |
修复 W13.3 审计三个问题:
(1) TLS 证书验证 (CVSS 7.4): HttpClientCtx 构造添加 set_verify_mode(verify_peer);
handshake 前 SSL_set1_host 启用 hostname 验证; SNI/hostname-fail / handshake-fail
均 host_log(ERROR).
(2) DNS resolve 超时: 用 steady_timer + async_wait + resolver.cancel() 实现 10s 超时,
超时/失败均返回明确错误码.
(3) catch(...) 兜底: 在 catch(const std::exception&) 后追加 catch(...),
非 std 异常不再穿越 C ABI (对齐 plugin-abi.md §8).
编译 0 error 0 warning, ctest 4/4 pass.
无新增依赖. Windows 上 set_default_verify_paths 可能找不到系统 CA, 已加 TODO
建议设置 SSL_CERT_FILE 或 bundle cacert.pem.
rating: completed
- date: 2026-05-27
event: "W16.5 - W13.3 网络审计报告补充 Findings Summary"
detail: |
审计报告 W13.3-network-audit.md 末尾新增 Findings Summary 小节,列出 3 个发现:
F-13.3-1 (CRITICAL) TLS 证书验证、F-13.3-2 (HIGH) DNS 解析无超时、
F-13.3-3 (MEDIUM) 缺 catch(...) 兜底。3 条全部录入 findings-registry.md Open 分区。
格式对齐 WORKFLOW.md §14.6 / §14.2 字段定义。
rating: completed
- date: 2026-05-27
event: "W20.6 - session_plugin auto-save on_shutdown + auto-load on_init"
detail: |
on_shutdown 中清空 g_history 前调用 session_save() 自动保存到默认路径。
on_init 注册服务后调用 session_load() 自动恢复(文件不存在静默失败)。
默认路径Windows %APPDATA%/dstalk/session.jsonLinux ~/.dstalk/session.json。
使用 _dupenv_s/getenv 获取平台标准目录。
编译 0 error 0 warningctest 5/5 pass。
无新增依赖,不涉及多会话管理。
rating: completed
- date: 2026-05-27
event: "W21.4 - session auto-save 失败告警 + 当前目录 fallback"
detail: |
on_shutdown 中 session_save 返回值检查:失败时 DSTALK_LOG_WARN + 尝试 ./dstalk_session_backup.json fallback。
fallback 也失败时 DSTALK_LOG_ERROR但不崩溃。
编译 0 errorctest 8/8 pass。
rating: done
- date: 2026-05-27
event: "W22.5 - get_default_session_path() 加 mkdir 保障 + 静态缓存"
detail: |
get_default_session_path() 改为 static 缓存lambda 立即求值C++11 线程安全静态初始化)。
计算路径后 std::filesystem::create_directories 确保目录存在。
mkdir 失败时 g_host->log(WARN) + 返回 "./session.json" fallback。
编译 0 error 0 warningctest 8/8 pass。
rating: completed
current_groups: [] current_groups: []
--- ---

249
agents/mailroom/README.md Normal file
View File

@@ -0,0 +1,249 @@
# Mailroom — agents 信箱系统
> **版本**: 1.0 草案 (2026-05-31 设计)
> **状态**: 试运行;格式稳定后由 CEO 决定是否在 WORKFLOW.md §15 正式纳入流程
> **设计目标**: 让无状态的子代理之间能异步传递消息,把进行中的协调状态从 CEO 的隐式上下文搬到磁盘
## 1. 设计动机
子代理是 stateless 的——每次 spawn 都是新会话,看不到其他子代理当前在做什么。现行所有协调依赖 CEO 在 prompt 里手动列禁忌、手动转交依赖、手动追踪谁回了什么。一波 6-9 路子代理并行时CEO 的上下文窗口很快被占满。
信箱让这些消息**显式落盘**
- CEO 派活前可以扫一遍候选执行者的 inbox看有没有未结的 handoff / blocker
- 子代理 spawn 时可以读到自己的待办邮件,了解上下文
- 处理完毕的邮件归档到 `archive/W<n>/`,不丢失追溯,但不打扰当前视图
## 2. 目录结构
```
agents/mailroom/
├── README.md # 本文件
├── inbox/
│ ├── ceo/ # CEO 的收件箱
│ ├── architect-lin/ # 各 agent 的收件箱(按需创建)
│ ├── engineer-zhao/
│ └── ...
└── archive/
├── W17/ # 按 Wave 归档已处理邮件
├── W18/
└── ...
```
- `inbox/<agent-id>/` —— 待处理邮件status: pending / read / in_progress
- `archive/W<n>/` —— 处理完毕邮件status: closed按 Wave 归档保留
- 收件箱目录按需创建,未创建表示该 agent 当前无邮件
## 3. 权限规则
文件系统不强制权限;权限靠子代理 prompt 中的禁忌条款约束。
| 角色 | 自己的 inbox | 别人的 inbox | archive/ |
|------|--------------|--------------|----------|
| 接收者本人 | 读 / 改 status / 移到 archive | 只能投递新邮件write-only | 只读 |
| CEO | 所有权限 | 所有权限(含重投递、强制保留) | 所有权限 |
| 其他子代理 | — | 只能投递新邮件 | 只读 |
派子代理时 prompt 必须显式声明可访问的邮箱范围。新增防御性规则 **R-MAIL-SCOPE**:子代理不得读写超出自身 inbox 之外的他人邮箱内容,仅可投递新邮件。
## 4. 消息文件命名
`<timestamp>-<kind>-<from>-to-<to>.md`
- timestamp: `YYYY-MM-DDTHHmm` 本地时间,分钟级即可区分
- kind: 见 §5
- from / to: agent-idCEO 用 `ceo`,广播用 `all`
示例:`2026-05-31T1430-task-ceo-to-architect-lin.md`
## 5. 五种邮件类型
| kind | 用途 | 投递方 | 接收方 |
|------|------|--------|--------|
| task | CEO 派活prompt 副本,便于事后追溯) | CEO | 执行者 |
| report | 子代理回执done / blocked / aborted | 执行者 | CEO |
| handoff | 子代理之间工作交接(前序产出移交下一棒) | 子代理 A | 子代理 B |
| blocker | 阻塞通知(依赖未满足) | 任何 | CEO必抄送 |
| notice | 广播in-flight 文件锁定 / 流程变更 / 元数据自检失败) | CEO 或系统 | all |
## 6. Frontmatter schema
```yaml
---
id: msg-W17.3-001 # 全局唯一,格式 msg-<Wave>-<序号>
from: ceo
to: architect-lin # 单人用 agent-id广播用 all
wave: W17.3
kind: task # task | report | handoff | blocker | notice
status: pending # pending | read | in_progress | closed
created: 2026-05-31T14:30
closed: # 处理完毕时填写YYYY-MM-DDTHHmm
related: # 关联消息 id 列表(如本报告对应的 task
- msg-W17.3-000
fixes: # 关联 findings-registry 的 finding id如有
- F-13.5-1
---
邮件正文markdown
```
- 必填字段:`id` / `from` / `to` / `wave` / `kind` / `status` / `created`
- 选填字段:`closed` / `related` / `fixes`
- 字符串值不带引号YAML 1.2 标准)
## 7. 生命周期
```
[投递] [归档]
│ │
▼ ▼
inbox/<to>/ pending ──→ read ──→ in_progress ──→ 处理完毕 archive/W<n>/
status: closed
```
操作动作:
| 动作 | 谁执行 | 文件操作 | status 字段 |
|------|--------|----------|--------------|
| 投递 | 任意 agent | 在 `inbox/<to>/` 创建新文件 | pending |
| 阅读 | 接收者 | frontmatter 改 status | read |
| 开工 | 接收者 | frontmatter 改 status | in_progress |
| 完成 | 接收者 | 移动文件到 `archive/W<n>/` + 改 status | closed |
| 重投递 | 仅 CEO | 复制 archive 中的文件到 inbox保留原件 | pending |
接收者**不可物理删除**邮件——只允许移到 archive如需删除由 CEO 手动操作。这是与 R-NO-FORCE-PUSH 同等级的强约束(新增 **R-MAIL-NO-DELETE**)。
## 8. 与现有机制的边界
| 机制 | 职责 | 与 mailroom 的区别 |
|------|------|---------------------|
| `STATUS.md` | 实时编制状态快照 | 由 `scripts/refresh_status.py` 扫 profile.md 生成mailroom 是消息流 |
| `agents/<id>/profile.md` performance_log | 永久任务履历 | 任务粒度事后追加mailroom 是消息粒度,进行中 |
| `agents/audits/findings-registry.md` | 审计发现追踪 | 跨 Wave 持续追踪mailroom 单条消息生命周期短(一波内闭环) |
| `WORKFLOW.md` | 流程规范 | 是规则mailroom 是规则产生的数据 |
**重叠处理原则**:邮件正文 ≠ 履历正文。task 邮件中的 prompt 是 CEO 派活的原始材料report 邮件中的回执是子代理的原始报告;最终摘要仍按现有流程写到 profile.md 的 performance_log 一条短记录。**邮件保留细节profile.md 保留摘要,两者互补不重复**。
审计发现的归宿仍是 findings-registry。blocker 邮件如对应某个 findingfrontmatter 的 `fixes` 字段填写 finding idCEO INSPECT 时按 §14.4 A3 检查关联。
## 9. CEO 派活前 5 步检查
扩展 [PROMPT_TEMPLATE.md](PROMPT_TEMPLATE.md) 的 CEO 派活前 4 步检查,新增第 5 步:
| # | 检查项 | 操作 | 写入模板字段 |
|---|--------|------|--------------|
| 1 | 列 in-flight 工作区 | 查当前有哪些子代理在跑 | 禁忌 |
| 2 | 找前序 Wave 产出 | 从 performance_log 追溯 | 前序成果 |
| 3 | 设定任务范围三档 | 必做 / 可做 / 不做 | 任务范围 |
| 4 | 统一字数上限 | 固定 250 字 | 字数上限 |
| **5** | **扫候选执行者 inbox** | `ls agents/mailroom/inbox/<候选id>/` 看 pending 的 handoff / blocker | 禁忌 / 前序成果 |
若候选执行者 inbox 有未结的 blocker依赖未满足→ 优先让其处理 blocker 再派新活,否则新活也会立即变 blocker。
## 10. 邮件示例
### 10.1 task 邮件
文件:`inbox/architect-lin/2026-05-31T1500-task-ceo-to-architect-lin.md`
```markdown
---
id: msg-W18.1-001
from: ceo
to: architect-lin
wave: W18.1
kind: task
status: pending
created: 2026-05-31T15:00
---
# W18.1 任务: 评审 mailroom 设计
请读 agents/mailroom/README.md 并评估:
- 是否值得正式纳入 WORKFLOW.md §15
- 权限规则在 prompt 层面如何强约束
- 与 findings-registry 的边界是否清晰
字数上限 250。完成后请发 report 邮件回 ceo。
```
### 10.2 report 邮件
文件:`inbox/ceo/2026-05-31T1630-report-architect-lin-to-ceo.md`
```markdown
---
id: msg-W18.1-002
from: architect-lin
to: ceo
wave: W18.1
kind: report
status: pending
created: 2026-05-31T16:30
related:
- msg-W18.1-001
---
# W18.1 评审结论
赞成纳入 §15。建议补充三条边界规则
1. 接收者不可删除别人邮箱里的邮件
2. CEO 重投递时必须复制而非移动,保留 archive 原件
3. blocker 邮件必须抄送 CEO
profile.md 已追加rating: A-。
```
### 10.3 handoff 邮件
文件:`inbox/qa-xu/2026-05-31T1700-handoff-engineer-sun-to-qa-xu.md`
```markdown
---
id: msg-W18.2-003
from: engineer-sun
to: qa-xu
wave: W18.2
kind: handoff
status: pending
created: 2026-05-31T17:00
related:
- msg-W18.2-001
---
# W18.2 LSP 死锁修复完成,请测
修复了 lsp_plugin.cpp:312 的死锁。需要你跑:
- `ctest -R lsp_plugin_test`
- `ctest -R smoke`
测试通过后请新建一封 report 邮件发给 CEO 并抄送我。
```
## 11. 后续路线v1.0 之外)
格式稳定 + CEO 在 2-3 个 Wave 中实战使用后,再考虑:
- 在 WORKFLOW.md §15 加一节正式纳入流程
- 补充 `scripts/mailroom_summary.py` 自动汇总每个 agent 的未读邮件数与最老 pending 邮件年龄
- 在 STATUS.md 增加 邮件待办数 列
- 扩展 `scripts/check_agents_metadata.py` 校验邮件 frontmatter
不在 v1.0 范围。先用 1-2 个 Wave 看实际效果再说。
## 12. 关联文档
- [WORKFLOW.md](../WORKFLOW.md) — 流程主文档mailroom 将来可能并入 §15
- [PROMPT_TEMPLATE.md](../PROMPT_TEMPLATE.md) — 派活模板(影响第 5 步检查)
- [STATUS.md](../STATUS.md) — 实时编制状态
- [findings-registry.md](../audits/findings-registry.md) — 审计发现追踪
- [POSTMORTEM.md](../POSTMORTEM.md) — 防御性规则(新增 R-MAIL-SCOPE / R-MAIL-NO-DELETE
## 13. 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-31 | 1.0 草案 | 初始化。CEO 与用户讨论后落地,方案 2归档保留不真删 |

View File

@@ -20,5 +20,22 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W9.10: host_api 单元测试 (8 cases, tests/host_api_test.cpp)"
rating: completed
- date: 2026-05-27
event: "W11.3: event_bus 单元测试 (6 cases, tests/event_bus_test.cpp) + service_registry 补充测试 (6 cases, tests/service_registry_test.cpp) — 提升 core 覆盖率,补边界/生命周期 case"
rating: completed
- date: 2026-05-27
event: "W19.2: 验证 plugin_loader MEDIUM 发现修复 — F-18.3-2 诊断日志/F-18.3-3 路径验证/F-18.3-4 日志管线/F-18.3-5 原子递增, cmake --build build --config Release 0 error + ctest 5/5 pass"
rating: success
details:
- "F-18.3-2: load_plugin 5 失败点添加 host->log 错误日志 + GetLastError()/dlerror() 诊断"
- "F-18.3-3: 添加 fs::absolute + lexically_normal 路径规范化、扩展名白名单(.dll/.so/.dylib)、目录约束(plugins/子目录)、目录遍历防护"
- "F-18.3-4: initialize_all fprintf(stderr) 改为 host->log() (已在 W18 前置修复)"
- "F-18.3-5: next_id_ 从 int 改为 std::atomic<int> 确保原子递增"
- "编译: cmake --build build --config Release → 0 error"
- "测试: ctest → 5/5 pass (smoke + host-api + event-bus + service-registry + context)"
- "协作: 与陈风 (engineer-chen) 配对实施 + 验证"
current_groups: [] current_groups: []
--- ---

View File

@@ -31,8 +31,32 @@ performance_log:
event: "C1 编码完成6项修改全部正确竞态彻底消除" event: "C1 编码完成6项修改全部正确竞态彻底消除"
rating: A+ rating: A+
- date: 2026-05-27 - date: 2026-05-27
event: "晋升为核心质量小组(grp-quality-core)组长与林深协作执行C2" event: "W7: smoke test 插件加载修复。tests/CMakeLists.txt 增加 POST_BUILD 拷贝逻辑修复5个插件 Boost.JSON header-only 链接,实现 9/9 插件加载,所有 [Block] 零 WARN100% pass"
rating: ongoing rating: A+
- date: 2026-05-27
event: "W13.3: network_plugin.cpp 深度审计 (322行, 9维度)。发现 TLS 证书验证完全禁用 (F, CVSS 7.4) + DNS 解析无超时 (永久hang) + 缺 catch(...)。RAII/堆纪律/并发 A 级。综合 C 级"
rating: A
- date: 2026-05-27
event: "W15.2: 设计审计→修复闭环机制。定义 findings-registry.md 格式 + OPEN→ASSIGNED→FIXED→VERIFIED→CLOSED 状态生命周期 + 4级严重度自动转化规则 + WORKFLOW.md §14 完整草案。从 W11.1/W11.7 提取 10 条历史发现初始化注册表"
rating: A
- date: 2026-05-27
event: "W15.5: §14 审计闭环内部一致性检查。发现 5 项不一致Closed Date 未定义/C3 历史发现时限歧义/8 份审计报告缺 Findings Summary/PROMPT_TEMPLATE 缺 Fixes 标注/findings-registry L4 引用偏差。结论 PASS-WITH-NOTES4 项修复建议已提交。"
rating: A
- date: 2026-05-27
event: "W15.8: 根据 W15.5 审查发现修复 §14 内部问题 + PROMPT_TEMPLATE 缺失标注。Fix1: findings-registry.md Closed Date→Close Date + 字段定义补注。Fix2: WORKFLOW.md §14.3 新增历史发现时限计算说明。Fix3: WORKFLOW.md §14.6 新增存量审计报告缺失 Findings Summary 处理指南。Fix4: PROMPT_TEMPLATE.md 交付清单新增 Finding 关联行 + 反模式7。4项全部完成。"
rating: A
- date: 2026-05-27
event: "W17.3: 验证 W14 已修复的 4 条发现并关单 + F-11.7-2。F-13.1-1 (anthropic 6 C ABI try/catch) PASSF-13.1-4 (sse_line_callback 异常保护) PASSF-13.2-1 (deepseek C++ 异常被外层 catch) PASSF-13.2-2 (非对称异常保护已消解) PASS。F-11.7-2 /clear 命令代码已有 g_session null 检查 (L168-174)无需修改代码直接关单。5 条全部 CLOSED。编译 0 error + ctest 4/4 pass。"
rating: A
- date: 2026-05-27
event: "W18.1 (协作 林深): 关闭 F-11.1-3/4/5/6 共4条 context_plugin 遗留发现。(3) 删除 g_max_tokens 死变量 + context_set_max_tokens API + dstalk_services.h vtable 字段;(4) count_tokens_utf8 共享函数新增多字节序列越界检查i+N >= len + 后继字节 0x80 校验);(5) 提取 count_tokens_utf8(const char*, size_t, size_t) 取代 count_tokens_one_message / count_tokens_trim 双份重复实现;(6) 新增 c==0xC0||0xC1 分支检测过短编码。新增 context_plugin_test.cpp (13 测试块, 36 CHECK),覆盖 ASCII/CJK/mixed/truncated UTF-8/0xC0-0xC1/4-byte/multi-msg/trim null+limit+system。更新 findings-registry Closed + Change Log。编译 0 error + ctest 5/5 pass。"
rating: A
- date: 2026-05-27
event: "W19.3 (协作 林深): plugin_loader 5 条发现修复验证。逐条审查 plugin_loader.cpp/host.cpp/plugin_loader.hppF-18.3-1 (ABI try/catch) 仅 2/5 调用点受保护load_plugin L59/ unload_plugin L108-109/shutdown_all L306-307 仍裸奔F-18.3-2 (静默失败) load_plugin 5 个失败路径零日志输出F-18.3-3 (路径验证) load_plugin L28 仅 null 检查,无规范化/目录约束/扩展名校验F-18.3-4 (fprintf→host->log) initialize_all L229+L239-240 仍用 fprintfhost_api 在手未用F-18.3-5 (next_id_ atomics) plugin_loader.hpp L54 仍是 plain int无 std::atomic无 mutex。5 条发现全部 NOT FIXED不予关单。编译 0 error + ctest 5/5 pass。"
rating: A
- date: 2026-05-27
event: "W21.6: anthropic/deepseek plugin 单元测试框架搭建。通过 #include plugin source 访问 file-scope static 函数。anthropic_plugin_test.cpp: 11 测试块 78 CHECK 覆盖 parse_sse_data 边界 (空body/格式异常/畸形JSON/message_stop/content_block_delta 含 W21.2 tool_use 路径)/build_request_json (空消息/超长消息/temperature 边界/多 system 合并)/build_headers_json/extract_host_port/secure_zero/my_free_result/my_configure。deepseek_plugin_test.cpp: 12 测试块 78 CHECK 覆盖 parse_sse_line ([DONE] 精确匹配/大小写/whitespace trimming/content delta/tool_calls 增量累积)/build_request_json (tool_use/tools_json/空输入 guard)/build_headers_json/extract_host_port/append_history (tool/assistant tool_calls/null 字段)。cmake --build build --config Release 0 error。ctest 8/8 (100%) pass。"
rating: A
current_groups: current_groups:
- grp-quality-core (组长) - grp-quality-core (组长)
--- ---

View File

@@ -18,11 +18,77 @@ weaknesses:
- 单元测试有时过于针对实现 - 单元测试有时过于针对实现
- 不太关注测试可读性 - 不太关注测试可读性
performance_log: performance_log:
- date: 2026-05-27
event: "W22.2: network_plugin 单元测试 — 7 测试块, 88 断言, 覆盖 parse_headers_json/SSE 解析/参数校验"
rating: done
detail: |
创建 tests/network_plugin_test.cpp (290 行), 通过 #include source 模式测试:
Block1 parse_headers_json 正常 JSON (13 断言)
Block2 空/null 输入 (5 断言)
Block3 畸形 JSON 边界 — 未闭合括号/引号/嵌套对象/二进制垃圾等 11 场景 (15 断言)
Block4 超长 header 值 — 5000/10000 char value, 1000 char key, 空 key (8 断言)
Block5 SSE 行解析 — LF/CRLF/空行/[DONE]/断帧/null bytes (23 断言)
Block6 http_post_json 参数校验 — nullptr host/port/target/body/response_body/status_code (10 断言)
Block7 http_post_stream 参数校验 (2 断言)
_exit() 绕过 OpenSSL 静态析构顺序问题; tests/CMakeLists.txt 新增 dstalk-network-plugin-test 目标
cmake --build build --config Release 0 error, ctest 9/9 100% pass
- date: 2026-05-27
event: "W21.5: smoke 回归补 tool_calls 边界用例 — 4 测试块, 12 断言"
rating: done
detail: |
在 tests/smoke_test.cpp 新增 W21.5 边界测试块(行 563-658, +96 行):
W21.5-1 null tool_calls_json: add message 后验证 history+1 且字段为 nullptr (2 断言)
W21.5-2 空 "[]" tool_calls_json: add message 后验证 history+1 且字段保留为 "[]" (2 断言)
W21.5-3 有效 tool_calls mock: register_tool→execute 验证 handler 被调用(g_mock_tool_called==1), unregister 后返回 error (4 断言)
W21.5-4 save/load 往返: 含 tool_calls 消息的 session 经 save/load 后数据不丢失 (3 断言)
新增 mock 全局: g_mock_tool_called + mock_tool_handler (行 27-31)
cmake --build build --config Release 0 error, ctest 6/6 100% pass
- date: 2026-05-27
event: "W20.3: plugin_loader 安全回归测试 — 覆盖 W19 修复的 F-18.3-1~5"
rating: done
detail: |
创建 tests/plugin_loader_test.cpp (265行), 5 个测试块 32 条断言:
Block1 路径验证: nullptr/非法扩展名/..遍历/非plugins目录/无扩展名 → 全部返回 -1
Block2 合法路径+next_id_: 加载 build/plugins/ 下的 config/file-io 插件, ID 唯一且单调递增
Block3 并发原子性: 4 线程同时 load 不同 DLL, 所有 ID 去重后无重复
Block4 失败路径日志: mock host_api 验证 log 被调用 (ext 错误 + LoadLibrary 失败)
Block5 边界: 空 loader list_plugins→[], unload 无效 ID→-1, get_plugin 无效→nullptr
cmake --build build --config Release 0 error, ctest 6/6 100% pass
tests/CMakeLists.txt 新增 dstalk-plugin-loader-test 目标, 含 compiler define + boost_json.cpp
- date: 2026-05-27
event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 security-cao)"
rating: done
detail: |
为 5 处 C ABI 调用点添加 try/catch(const std::exception&)+catch(...) 双层保护:
init_fn(L59)/on_init×2(L237,L272)/on_shutdown×2(L108,L306)。
initialize_all fail-continue: 单插件异常仅记录日志+跳过,不阻断其余插件加载。
shutdown_all/unload_plugin on_shutdown 异常仅 log 不阻断 DLL 卸载。
新增 host_api_ 成员统一日志通道fprintf→host_api->log。
编译 0 error + ctest 5/5 pass。findings-registry F-18.3-1→FIXED。
- date: 2026-05-27
event: "W18.3: plugin_loader 安全审计 (合作 security-cao) — 9 维度审计, 1 HIGH + 4 MEDIUM + 3 LOW 发现"
rating: done
detail: |
联合审计 dstalk-core/src/plugin_loader.cpp + plugin_loader.hpp (385行)。
破坏性关注点: on_init/on_shutdown 五处 C ABI 调用无 try/catch → 单个插件 OOM 即可 std::terminate() 拖垮 host;
load_plugin 全路径静默失败, GetLastError/dlerror 不调用;
dstalk_plugin_load 公开 API 路径直传 LoadLibrary/dlopen 零验证, 相对路径触发 DLL 搜索劫持。
报告: agents/audits/W18.3-plugin-loader-audit.md。
findings-registry: F-18.3-1~5 录入 Open 分区。
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27 - date: 2026-05-27
event: "B3 三方案破坏性评审:否决 A3/B1/C2上报恶意管道 OOM 路径" event: "B3 三方案破坏性评审:否决 A3/B1/C2上报恶意管道 OOM 路径"
rating: good rating: good
- date: 2026-05-27
event: "W10.4 创建 agents/POSTMORTEM.md 项目级踩坑记录172行收录 PM-001~PM-005 共5条事故7条防御性规则覆盖 stale obj / Boost.JSON 链接 / 跨DLL堆释放 / plugin_loader fail-fast / push --force 未告知"
rating: completed
- date: 2026-05-27
event: "W11.7 破坏性输入测试build/dstalk-cli/dstalk-cli.exe (commit 004a81d) 10 场景全 PASS 零崩溃。发现 BUG-1 [CRITICAL] build/bin/ 下存在损坏副本 (MD5 d8e8c92b vs 正常 803ca2ea命令解析全失效)BUG-2 /clear 谎报成功BUG-3 /context 静默无输出BUG-4 /file write 裸命令匹配失败。报告写入 agents/audits/W11.7-destructive-test.md"
rating: completed
- date: 2026-05-27
event: "W13.6 扩展 tests/smoke_test.cpp (430→623 行, +193): 新增 4 个回归保护 case — R1 context max_tokens trim (覆盖 W11.1 Discovery 3 死变量 + W11.7 BUG-3 /context 静默), R2 config 双 store 一致性 (host config_set → plugin service get, 发现跨 store 可见性仍为已知 gap 以 WARN 记录), R3 HTTP 错误路径 (127.0.0.1:1 连接拒绝返回 error 不崩溃), R4 重复 init/shutdown 生命周期 (3 轮循环全部通过). cmake build 0 error, ctest 4/4 pass."
rating: completed
current_groups: [] current_groups: []
--- ---

View File

@@ -18,8 +18,100 @@ weaknesses:
- 对功能开发节奏感知较弱,容易"挡路" - 对功能开发节奏感知较弱,容易"挡路"
- 偶尔过度强调低风险问题 - 偶尔过度强调低风险问题
performance_log: performance_log:
- date: 2026-05-27
event: "W20.4: plugin_loader ABI 契约校验(降级版函数名白名单)"
rating: done
detail: |
在 load_plugin 中 init_fn() 调用后新增 55 行校验块:
name 字段 null/empty → DSTALK_LOG_ERROR + return -1
version 字段 null/empty → DSTALK_LOG_ERROR + return -1
description null → DSTALK_LOG_WARN非阻断
on_init 回调 null → DSTALK_LOG_WARN非阻断
通过 ABI 契约验证替代 SHA-256 哈希白名单(投票 3/4 反对引入 crypto 依赖)。
编译 cmake --build build --config Release: 0 error, ctest: 6/6 pass。
- date: 2026-05-27
event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 qa-xu)"
rating: done
detail: |
为 load_plugin init_fn(L59)、initialize_all on_init(L237)、initialize_pending on_init(L272)、
unload_plugin on_shutdown(L108-109)、shutdown_all on_shutdown(L306-307) 五处 C ABI 调用点
添加 try/catch(const std::exception&)+catch(...) 双层保护。
initialize_all 实现 fail-continue单插件异常不阻断其他插件加载。
新增 host_api_ 成员存储日志通道fprintf(stderr) 替换为 host_api->log()。
编译 cmake --build build --config Release: 0 error, ctest: 5/5 pass。
findings-registry: F-18.3-1 OPEN→FIXED, Fix Wave W19.1。
- date: 2026-05-27
event: "W18.3: plugin_loader 安全审计 (合作 qa-xu) — 9 维度审计, 1 HIGH + 4 MEDIUM + 3 LOW 发现"
rating: done
detail: |
审计 dstalk-core/src/plugin_loader.cpp + plugin_loader.hpp (385行), 9 维度全覆盖。
TOP3: (1) [HIGH] 5 处 C ABI on_init/on_shutdown/init_fn 调用 zero try/catch → std::terminate();
(2) [MEDIUM] load_plugin 5 失败路径全静默返回 -1 无日志;
(3) [MEDIUM] dstalk_plugin_load 公开 API 路径零验证 + DLL 无来源完整性检查。
额外: fprintf(stderr) 绕过 host->log、PluginLoader 零同步、符号解析无诊断、终端转义注入风险。
评级: 综合 C (ABI 安全 F, 路径安全 D, 错误处理 D, 堆纪律 A)。
报告: agents/audits/W18.3-plugin-loader-audit.md。
findings-registry: F-18.3-1~5 (1 HIGH + 4 MEDIUM) 录入 Open 分区。
- date: 2026-05-27
event: "W17.1: 验证 F-13.3-1/2/3 (network_plugin TLS/DNS/exception) — W14.1 已全部修复,关闭 3 条发现"
rating: done
detail: |
检查 network_plugin.cpp 现状F-13.3-1 (TLS) 已有 set_verify_mode(verify_peer) + SSL_set1_host
F-13.3-2 (DNS) 已有 steady_timer 10s 超时 + resolver.cancel()
F-13.3-3 (catch) 已有 catch(...) 兜底。均为 W14.1 周岩修复。
编译 0 errorctest 4/4 pass。findings-registry 更新3 条 OPEN→CLOSED, Fix Wave W14.1, Verified By security-cao。
- date: 2026-05-27
event: "W16.1: 验证 F-11.7-1 (build/bin/ 损坏二进制副本) — W12.4 已彻底修复"
rating: done
detail: |
确认 build/dstalk-cli/dstalk-cli.exe 已删除 (W12.4 devops-hu 修复);
build/bin/dstalk-cli.exe 为唯一正确副本 (67584 bytes);
cmake --build build --config Release: 0 error; ctest: 4/4 pass。
更新 findings-registry.md: F-11.7-1 OPEN→CLOSED, Close Date 2026-05-27, Fix Wave W12.4, Verified By security-cao。
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "W2.2: api_key 在 on_shutdown 时安全清零 (deepseek + anthropic)"
rating: done
detail: |
在 deepseek_plugin.cpp 和 anthropic_plugin.cpp 的 anonymous namespace 内
新增 secure_zero(void*, size_t),通过 volatile 写零循环对 g_cfg.api_key
执行安全擦除后 clear。编译0 error 0 warning与改动相关的文件
- date: 2026-05-27
event: "W9.3: 错误日志凭证泄露审计8文件0真实漏洞"
rating: done
detail: |
审计了 8 个文件的所有 host->log / printf / fprintf(stderr) / std::cerr 调用。
0 真实可利用漏洞。deepseek/anthropic 的 configure 日志有意排除了 api_key
build_headers_json() 产生的凭证字符串仅通过内存传递给 Beast HTTP未经过日志管道。
低风险/假阳性 2 项lsp server_cmd 日志 + network e.what() 异常信息),无需代码修改。
审计报告写入 docs/explanation/security-logging.md。CVSS: N/A无可利用漏洞
- date: 2026-05-27
event: "W13.5: session + tools 联合安全审计 (515行)"
rating: done
detail: |
联合审计 session_plugin.cpp (264行) + tools_plugin.cpp (251行)。
TOP3: (1) tools L50/L85 路径遍历→任意文件读写 (CVSS 7.5);
(2) 两插件全文 static global 无 mutex→多线程竞态 UAF/NPD;
(3) session L127/L141/L204/L242 + tools L106/L132/L203
缺 try/catch→§8违反→std::terminate。
凭证泄露: session_save 明文落盘含 tool_calls_json(潜在token泄露)。
命令注入: 未发现。路径遍历: tools 确认。
评级 session:D+ / tools:D。
报告: agents/audits/W13.5-session-tools-audit.md
- date: 2026-05-27
event: "W14.3: 修复 W13.5 审计发现 — 路径遍历 + 全局状态加锁 + 9 vtable try/catch"
rating: done
detail: |
修改 session_plugin.cpp (294行) + tools_plugin.cpp (292行)。
(1) is_safe_path() 拒绝空路径、绝对路径(/或盘符)、含..段lexically_normal二次校验
builtin_file_read(L50) 和 builtin_file_write(L85) 入口调用不安全→log ERROR + 返回错误JSON。
(2) 加锁: session g_history/g_cached_history→g_session_mutex; tools g_tools→g_tools_mutex;
g_host/g_file_io→std::atomic<T*> load(acquire)/store(release)。
(3) 9 vtable try/catch 覆盖: session_add/save/load/history (session) +
tools_register_tool/unregister_tool/get_tools_json/execute/on_init (tools)。
编译: cmake --build build --config Release → 0 error 0 warning。
ctest -C Release → 4/4 pass。
current_groups: [] current_groups: []
--- ---

View File

@@ -20,5 +20,11 @@ performance_log:
- date: 2026-05-27 - date: 2026-05-27
event: "入职 dstalk 团队" event: "入职 dstalk 团队"
rating: ongoing rating: ongoing
- date: 2026-05-27
event: "Diátaxis 第二刀: 补充 Explanation 类文档 — architecture.md (插件架构哲学/三层模型/C ABI) + plugin-lifecycle.md (生命周期/拓扑排序/on_init on_shutdown 契约/ABI 纪律), 更新 docs/README.md 导航"
rating: completed
- date: 2026-05-27
event: "W12.6 ABI 文档缺口填补: plugin-abi.md 追加 §8 异常安全(涵盖 service vtable 函数, 反例来自 context_plugin.cpp L114-226) + §9 字符串返回值生命周期(反例来自 config_store.cpp:72 / config_plugin.cpp:77 的锁外 c_str() 悬垂), 并更新 §2.3/§5.3/§6.4 交叉引用"
rating: completed
current_groups: [] current_groups: []
--- ---

View File

@@ -140,7 +140,7 @@ if !errorlevel! neq 0 (
echo. echo.
echo ============================================ echo ============================================
echo 编译成功! echo 编译成功!
echo build\dstalk-core\dstalk.dll echo build\bin\dstalk.dll
echo build\dstalk-cli\dstalk-cli.exe echo build\bin\dstalk_cli.exe
echo ============================================ echo ============================================
pause pause

130
build.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# build.sh — dstalk cross-platform build script (Linux / macOS)
# Mirrors build.bat; installs tools into tools/ if missing,
# then runs Conan + CMake + Ninja without modifying system PATH.
# ============================================================
ROOT="$(cd "$(dirname "$0")" && pwd)"
TOOLS="${ROOT}/tools"
BUILD_DIR="${ROOT}/build"
BUILD_TYPE="${1:-Release}"
echo "[dstalk] Build type: ${BUILD_TYPE}"
echo "[dstalk] Project root: ${ROOT}"
# ============================================================
# 1. Detect / resolve tool paths (prefer tools/, fallback PATH)
# ============================================================
find_tool() {
local name="$1"
shift
for candidate in "$@"; do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
local sys
sys=$(command -v "$name" 2>/dev/null || true)
if [ -n "$sys" ]; then
echo "$sys"
return 0
fi
return 1
}
CMAKE_EXE=$(find_tool cmake "${TOOLS}/cmake/bin/cmake") || {
echo " [ERROR] CMake not found. Run: tools/setup.sh"
exit 1
}
NINJA_EXE=$(find_tool ninja "${TOOLS}/ninja/ninja") || {
echo " [ERROR] Ninja not found. Run: tools/setup.sh"
exit 1
}
CONAN_EXE=$(find_tool conan "${TOOLS}/.venv/bin/conan") || {
echo " [ERROR] Conan2 not found. Run: tools/setup.sh"
exit 1
}
# Compiler: prefer clang, fallback gcc
if [ "$(uname)" = "Darwin" ]; then
CC_EXE=$(find_tool clang "${TOOLS}/llvm/bin/clang") || CC_EXE="clang"
CXX_EXE=$(find_tool clang++ "${TOOLS}/llvm/bin/clang++") || CXX_EXE="clang++"
else
CC_EXE=$(find_tool clang "${TOOLS}/llvm/bin/clang") || \
CC_EXE=$(find_tool gcc) || { echo " [ERROR] No C compiler found"; exit 1; }
CXX_EXE=$(find_tool clang++ "${TOOLS}/llvm/bin/clang++") || \
CXX_EXE=$(find_tool g++) || { echo " [ERROR] No C++ compiler found"; exit 1; }
fi
echo "[dstalk] CMake: ${CMAKE_EXE}"
echo "[dstalk] Ninja: ${NINJA_EXE}"
echo "[dstalk] Conan: ${CONAN_EXE}"
echo "[dstalk] CC: ${CC_EXE}"
echo "[dstalk] CXX: ${CXX_EXE}"
# ============================================================
# 2. Conan install dependencies
# ============================================================
echo "[dstalk] Conan: installing dependencies..."
"${CONAN_EXE}" install deps/ -of "${BUILD_DIR}" --build=missing \
-s build_type="${BUILD_TYPE}" \
-c tools.cmake.cmaketoolchain:generator=Ninja
# Locate the conan toolchain
CONAN_TOOLCHAIN=""
for candidate in \
"${BUILD_DIR}/build/${BUILD_TYPE}/generators/conan_toolchain.cmake" \
"${BUILD_DIR}/${BUILD_TYPE}/generators/conan_toolchain.cmake" \
"${BUILD_DIR}/${BUILD_TYPE}/conan_toolchain.cmake" \
"${BUILD_DIR}/conan_toolchain.cmake"; do
if [ -f "$candidate" ]; then
CONAN_TOOLCHAIN="$candidate"
break
fi
done
if [ -z "${CONAN_TOOLCHAIN}" ]; then
echo " [ERROR] Could not find conan_toolchain.cmake"
echo " Searched in: ${BUILD_DIR}/build/${BUILD_TYPE}/generators/"
echo " ${BUILD_DIR}/${BUILD_TYPE}/generators/"
exit 1
fi
echo "[dstalk] Conan toolchain: ${CONAN_TOOLCHAIN}"
CONAN_PREFIX_PATH="$(dirname "${CONAN_TOOLCHAIN}")"
# Patch conan toolchain if it hardcodes 'cl' (Windows-generated)
if grep -q 'set(CMAKE_C_COMPILER "cl")' "${CONAN_TOOLCHAIN}" 2>/dev/null; then
echo "[dstalk] Patching conan toolchain: replacing cl -> detected compiler"
sed -i.bak \
-e "s|set(CMAKE_C_COMPILER \"cl\")|set(CMAKE_C_COMPILER \"${CC_EXE}\")|" \
-e "s|set(CMAKE_CXX_COMPILER \"cl\")|set(CMAKE_CXX_COMPILER \"${CXX_EXE}\")|" \
"${CONAN_TOOLCHAIN}"
fi
# ============================================================
# 3. CMake configure + Ninja build
# ============================================================
echo "[dstalk] CMake configure..."
"${CMAKE_EXE}" -S "${ROOT}" -B "${BUILD_DIR}" -G Ninja \
-DCMAKE_MAKE_PROGRAM="${NINJA_EXE}" \
-DCMAKE_C_COMPILER="${CC_EXE}" \
-DCMAKE_CXX_COMPILER="${CXX_EXE}" \
-DCMAKE_TOOLCHAIN_FILE="${CONAN_TOOLCHAIN}" \
-DCMAKE_PREFIX_PATH="${CONAN_PREFIX_PATH}" \
-DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
-DDSTALK_BUILD_GUI=OFF
echo "[dstalk] Building..."
"${CMAKE_EXE}" --build "${BUILD_DIR}"
echo ""
echo "============================================"
echo " Build succeeded!"
echo " ${BUILD_DIR}/bin/dstalk$([ "$(uname)" = 'Darwin' ] && echo '.dylib' || echo '.so')"
echo " ${BUILD_DIR}/bin/dstalk_cli"
echo "============================================"

90
config.example.toml Normal file
View File

@@ -0,0 +1,90 @@
# ============================================================================
# dstalk config.example.toml — 配置模板 / Configuration template
# ============================================================================
# 复制为 config.toml 并修改即可使用。
# Copy to config.toml and edit before use.
#
# 两种配置方式(互斥,选择一种即可):
# Two configuration modes (mutually exclusive, pick one):
#
# 1) 单 Provider 模式(简单,一个 AI 后端)
# Single-provider mode (simple, one AI backend)
# 使用 keys: ai.provider / api.base_url / api.api_key / api.model
#
# 2) 多 Endpoint 模式(高级,同时配置多个 AI 后端并通过 /endpoint 切换)
# Multi-endpoint mode (advanced, multiple AI backends switchable via /endpoint)
# 使用 keys: endpoints.names / endpoints.active / endpoint.<name>.*
#
# 如果同时配置了两种方式ai_endpoint_mgr 的 chat 调用优先走 endpoints.*。
# If both are configured, ai_endpoint_mgr chat calls prefer endpoints.*.
# ============================================================================
# ----------------------------------------------------------------------------
# 方式 1: 单 Provider 模式 / Mode 1: Single Provider
# ----------------------------------------------------------------------------
# 取消下方注释即可使用 / Uncomment to use
# ai.provider = "ai_openai" # ai_openai 或 ai_anthropic / ai_openai or ai_anthropic
#
# api.base_url = "https://api.openai.com/v1"
# api.api_key = "sk-your-key-here"
# api.model = "gpt-4o"
# 或者用 Anthropic / Or use Anthropic:
# ai.provider = "ai_anthropic"
# api.base_url = "https://api.anthropic.com"
# api.api_key = "sk-ant-your-key-here"
# api.model = "claude-sonnet-4-20250514"
# ----------------------------------------------------------------------------
# 方式 2: 多 Endpoint 模式 / Mode 2: Multi-Endpoint
# ----------------------------------------------------------------------------
# 取消下方注释即可使用,多个 endpoint 在对话中通过 /endpoint 命令切换。
# Uncomment to use. Switch endpoints during a session via the /endpoint command.
# 逗号分隔的 endpoint 名称列表(至少一个) / Comma-separated endpoint names (at least one)
# endpoints.names = "openai_main, anthropic_alt"
# 默认激活的 endpoint可选不设置则取列表第一个 / Default active endpoint (optional, defaults to first in list)
# endpoints.active = "openai_main"
# --- openai_main ---
# provider 必须 / required
# endpoint.openai_main.provider = "ai_openai"
# base_url 可选 / optional (默认按 provider 自动填 / defaults by provider: OpenAI→api.openai.com/v1, Anthropic→api.anthropic.com)
# endpoint.openai_main.base_url = "https://api.openai.com/v1"
# api_key 必须 / required
# endpoint.openai_main.api_key = "sk-your-key-here"
# model 必须 / required
# endpoint.openai_main.model = "gpt-4o"
# max_tokens 可选 / optional (默认 4096) / default 4096
# endpoint.openai_main.max_tokens = 4096
# temperature 可选 / optional (默认 0.7, 范围 0.0~2.0) / default 0.7, range 0.0~2.0
# endpoint.openai_main.temperature = 0.7
# --- anthropic_alt ---
# endpoint.anthropic_alt.provider = "ai_anthropic"
# base_url 未设置时自动使用 https://api.anthropic.com / defaults to https://api.anthropic.com when unset
# endpoint.anthropic_alt.api_key = "sk-ant-your-key-here"
# endpoint.anthropic_alt.model = "claude-sonnet-4-20250514"
# endpoint.anthropic_alt.max_tokens = 8192
# --- deepseek (自定义 base_url 示例 / custom base_url example) ---
# endpoint.deepseek.provider = "ai_openai"
# endpoint.deepseek.base_url = "https://api.deepseek.com/v1"
# endpoint.deepseek.api_key = "sk-deepseek-your-key-here"
# endpoint.deepseek.model = "deepseek-chat"
# 提示 / Tips:
# - list_json 输出不含 api_key安全脱敏
# list_json output excludes api_key (security de-identification).
# - 未知 provider 必须显式配置 base_url否则 endpoint 加载失败。
# Unknown providers must explicitly set base_url or the endpoint won't load.

74
docs/README.md Normal file
View File

@@ -0,0 +1,74 @@
# dstalk 文档
> 文档采用 [Diataxis](https://diataxis.fr/) 框架组织: 教程 / 操作指南 / 解释 / 参考 四类分离。
---
## 已有文档
### 教程 (Tutorials) — 从零开始的学习路径
| 文档 | 说明 |
|------|------|
| [快速入门](tutorial/quick-start.md) | 5 步上手: 安装工具链、编译、配置、运行、第一个对话 |
### 解释 (Explanation) — 背景与设计决策
| 文档 | 说明 |
|------|------|
| [架构哲学](explanation/architecture.md) | 为什么是插件架构, host/plugin/service 三层模型, service registry 和 event bus 的职责, C ABI 的选择理由 |
| [插件生命周期](explanation/plugin-lifecycle.md) | DLL 加载与拓扑排序, on_init/on_shutdown 契约, 跨 DLL 堆分配与 ABI 纪律 |
### 参考 (Reference) — 精确的技术描述
| 文档 | 说明 |
|------|------|
| [CLI 命令速查](reference/commands.md) | 全部 CLI 命令的别名、作用与示例 |
| [配置参考](reference/config.md) | config.toml 单 provider 与多 endpoint 全部字段说明、默认值与安全提示 |
| [Plugin ABI 契约](reference/plugin-abi.md) | 跨 DLL 通信的 C ABI 规范:内存所有权、堆纪律、回调线程安全 |
---
## 未来计划
### 教程
- [ ] **插件开发入门** (`tutorial/plugin-dev.md`) — TODO: 写一个 Hello World 插件, 注册服务并调用
- [ ] **LSP 集成教程** (`tutorial/lsp-setup.md`) — TODO: 配置 clangd 并在 dstalk 中使用诊断/补全
### 操作指南 (How-to Guides) — 解决具体问题
- [ ] **切换 AI 后端** (`how-to/switch-provider.md`) — TODO: 在 DeepSeek / Anthropic / OpenAI 间切换
- [ ] **配置 API Key** (`how-to/config-api-key.md`) — TODO: config.toml 各项配置说明与安全建议
- [ ] **使用 File 命令管理项目文件** (`how-to/file-commands.md`) — TODO: 列目录、查看、读写文件的工作流
- [ ] **自定义前端开发** (`how-to/custom-frontend.md`) — TODO: 基于 C ABI 编写自己的前端程序
- [ ] **批处理模式** (`how-to/batch-mode.md`) — TODO: `--batch` 标志与非交互式使用
### 解释 (Explanation) — 背景与设计决策
- [x] **架构哲学** (`explanation/architecture.md`) — 插件架构、三层模型、service registry/event bus、C ABI 选择
- [x] **插件生命周期** (`explanation/plugin-lifecycle.md`) — DLL 加载、拓扑排序、on_init/on_shutdown 契约、ABI 纪律
- [ ] **为什么用 C/C++** (`explanation/why-cpp.md`) — TODO: 启动速度、内存占用、运行时依赖、嵌入能力的对比分析
- [ ] **Diataxis 文档框架** (`explanation/diataxis.md`) — TODO: 本文档组织方式的设计思路
### 参考
- [ ] **API 参考** (`reference/api.md`) — TODO: dstalk_host.h 完整 API 说明与调用示例
- [x] **配置参考** (`reference/config.md`) — config.toml 所有字段的详细说明、默认值与安全提示
- [ ] **服务接口参考** (`reference/services.md`) — TODO: dstalk_services.h 中所有 vtable 接口定义
---
## 文档约定
- 命令以 `$ ` 前缀表示, 在终端中运行
- 代码块标注语言 (```c, ```toml, ```bash 等)
- [ ] 表示计划中未完成的文档
- 配置文件模板见项目根目录 `config.example.toml`
---
## 贡献文档
文档使用 Markdown 编写, 存放在 `docs/` 目录下。
欢迎通过 PR 补充或修正文档内容。

View File

@@ -0,0 +1,138 @@
# dstalk 架构哲学
> **本文属于**: Explanation -- 解释"为什么这么设计",建立心智模型。
> 不涉及具体 API 签名或配置字段,那属于 Reference 的职责。
---
## 为什么是插件架构?
dstalk 选择的不是单体架构,而是**以 C ABI 为边界的插件架构**。这是几条需求推导出的必然结果:
1. **AI 后端会变**。OpenAI-compatible / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A明天切 B后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
2. **能力会增长**。LSP 集成、文件管理、会话持久化、工具调用——这些能力不是 CLI 启动时必须加载的。使用者可能只需要聊天,不需要 LSP。插件架构让能力按需加载启动更快内存更省。
3. **插件作者不是核心团队**。第三方应该能用自己的编译器、自己的 C++ 标准库版本编写插件,而不必须链接 dstalk_core 的静态库。这要求 ABI 稳定。C ABI 是唯一具有跨编译器二进制兼容性的选择。
**一句话心智模型**: 不要想象一个胖二进制把所有功能静态链接在一起;想象一个内核 (host) + 一圈可插拔的服务单元 (plugin),内核只负责编排,不负责实现。
---
## 三层模型
dstalk 的架构由 3 层组成。从上到下看,每一层依赖下一层提供的抽象:
```
┌────────────────────────────────────────────┐
│ Plugin (DLL) │
│ 实现具体能力AI 聊天、文件读写、LSP... │
│ 通过服务注册表向其他插件暴露自己的功能 │
├────────────────────────────────────────────┤
│ Host (dstalk_core) │
│ 拥有事件总线、服务注册表、插件加载器、配置 │
│ 提供一个 dstalk_host_api_t 接口给所有插件 │
├────────────────────────────────────────────┤
│ Service (C vtable) │
│ 抽象接口ai / http / session / file_io... │
│ 消费者通过 query_service 查找,不看实现者是谁│
└────────────────────────────────────────────┘
```
### Host 层
Host 是 dstalk 的 **编排内核**。它自己不做 AI 推理,不读写文件,不管理会话。
Host 拥有四样东西:
- **配置存储 (ConfigStore)**: 管理 `config.toml` 的加载和键值查询。
- **事件总线 (EventBus)**: 插件间松耦合通信的唯一通道。
- **服务注册表 (ServiceRegistry)**: 按名称 + 版本号存储和查找服务 vtable。
- **插件加载器 (PluginLoader)**: 扫描 `plugins_base/``plugins_middle/``plugins_upper/` 三层目录、加载 DLL、按依赖拓扑排序后调用初始化。
Host 启动时,按严格顺序执行:
1. 分配上述四个组件。
2. 加载 `config.toml`(如果提供了路径)。
3. 扫描 `plugin_dir` 目录下所有 `.dll` / `.so` 文件。
4. 调用每个插件的 `dstalk_plugin_init()` 获取插件描述符。
5. 按依赖拓扑排序执行 `on_init`
### Plugin 层
每个插件是一个**独立的动态库**,导出一个唯一入口函数 `dstalk_plugin_init()`
该入口返回一个 `dstalk_plugin_info_t`,声明了:
- 插件名称(依赖解析时的唯一标识)。
- 依赖列表(必须先于本插件初始化的其他插件名)。
- 生命周期回调(`on_init` / `on_shutdown`)。
- 可选的事件回调(`on_event`)。
插件在 `on_init` 中做三件事:
1. 保存 `host_api` 指针,这是它此后访问一切 Host 能力的唯一通道。
2. 通过 `host_api->query_service` 查找它依赖的服务(例如 openai 插件查询 `http``config`)。
3. 通过 `host_api->register_service` 向注册表注册自己提供的服务 vtable。
关键设计:插件之间**不直接链接**。插件 A 不知道插件 B 是否在当前进程中。A 只对 Host 说"我需要名为 `http` 的服务"Host 从注册表里找出那个 vtable把指针交给 A。
### Service 层
Service 是**纯 C vtable**——一个包含函数指针的结构体。以 `dstalk_ai_service_t` 为例:
```
struct dstalk_ai_service_t {
int (*configure)(...);
dstalk_chat_result_t (*chat)(...);
dstalk_chat_result_t (*chat_stream)(...);
void (*free_result)(dstalk_chat_result_t*);
};
```
每种服务都有一个预定义的 vtable 结构体(定义在 `dstalk_services.h`)。第三方也可以扩展自己的服务 vtable版本号随注册一起提供允许消费者做最低版本检查。
服务注册采用 **name + version** 两要素:
- 全局唯一名称(如 `"ai_openai"``"http"``"file_io"`)。
- 版本号(消费者可以要求 `min_version`)。
---
## Service Registry 解决什么问题?
核心问题是 **耦合方向**。如果不使用注册表openai 插件就得直接知道 http 插件的符号,通过 `dlsym` 或头文件耦合。换成注册表后:
- 提供者说:"我注册一个名为 `http` 的 vtable"。
- 消费者说:"给我一个名为 `http`、版本 >= 1 的 vtable"。
- Host 做中间人查表。
**效果**: 你可以用任意方式实现 `http` 服务——用 libcurl、用 WinHTTP、用 mock——openai 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。
---
## Event Bus 解决什么问题?
服务注册表解决的是**调用链**"我需要你,你给我提供服务"。但架构里还有另一类通信需求——**通知链**"某件事发生了,关心的人自行处理"。
举例:
- 会话清空了 (`DSTALK_EVENT_SESSION_CLEAR`)——所有关心会话状态的插件应当知道。
- 配置更新了 (`DSTALK_EVENT_CONFIG_CHANGED`)——读取了配置的插件应当刷新。
- 插件加载/卸载——其他插件可能需要重新查询依赖。
Event Bus 把这些需求抽象为**发布-订阅**
- 发布者不知道谁在听。
- 订阅者不知道谁在发布。
- 发布者和订阅者通过事件类型编号耦合(如 `DSTALK_EVENT_SESSION_CLEAR = 2`)。
**效果**: 新写一个监控插件,只需 `event_subscribe` 即可收到所有感兴趣的事件,不需要修改任何现有代码。
---
## 为什么所有插件用 C ABI
C++ 没有稳定的 ABI。不同编译器MSVC / GCC / Clang、不同编译器版本、不同标准库版本libstdc++ vs libc++之间C++ 类的内存布局、虚函数表布局、异常展开机制都不保证兼容。
C ABI 的保证:
- 编译单元之间传递的只有**结构体**和**函数指针**。
- 结构体布局确定(`dstalk_plugin_info_t` 的字段顺序不会变)。
- 函数调用约定确定(`extern "C"` 关闭 name mangling
- 所有内存分配通过 Host 提供的 `alloc/free`,而非各自调用 `malloc/free`(不同 DLL 使用不同运行时库的 `malloc` 会崩溃)。
**一句话心智模型**: C ABI 是唯一能保证"用 MSVC 编译的插件在 GCC 编译的 host 中运行"的接口层。代价是只能传递纯数据结构和函数指针,不能使用异常、重载或模板跨 DLL 边界。

View File

@@ -0,0 +1,167 @@
# 插件生命周期
> **本文属于**: Explanation -- 解释插件从加载到卸载的完整生命周期和关键契约。
> 不涉及具体 API 签名细节,那属于 Reference (`reference/api.md`) 的职责。
---
## 总览
一个 dstalk 插件从磁盘上的 DLL 文件到进程中活跃的服务提供者,经历了四个阶段:
```
加载 DLL → 依赖解析 → on_init → on_shutdown
(load_plugin) (topo sort) (初始化) (逆序清理)
```
---
## 阶段 1DLL 加载
Host 调用 `load_plugin(path)`,由 `PluginLoader` 执行:
1. **加载动态库**。Windows 用 `LoadLibraryA`Linux 用 `dlopen(..., RTLD_NOW | RTLD_LOCAL)`。注意 Linux 端使用了 `RTLD_LOCAL` 而非 `RTLD_GLOBAL`,这意味着每个插件的符号默认对其他插件不可见——跨插件通信必须通过 Host API不能通过直接符号引用。
2. **查找入口函数**。Host 在 DLL 中搜索名为 `dstalk_plugin_init` 的符号,调用它获取 `dstalk_plugin_info_t*`
3. **版本校验**。Host 检查 `info->api_version` 是否等于 `DSTALK_API_VERSION`。不匹配则拒绝加载——这是防止 ABI 断裂的第一道防线。
4. **存储元数据**`PluginInfo` 被存入 `plugins_` map此时 `initialized = false`,插件尚未初始化。
**关键点**: 加载阶段只做符号获取和版本校验,不调用 `on_init`。这意味着插件代码此时尚未运行,不持有任何资源。
---
## 阶段 2依赖解析拓扑排序
所有插件加载完毕后,`initialize_all` 在调用 `on_init` 之前先执行拓扑排序。
**为什么需要排序?** 如果 openai 插件依赖 `http``config`,那么 `http``config` 插件的 `on_init` 必须先于 openai 执行——否则 openai 在 `on_init` 中调用 `query_service("http")` 会得到 `nullptr`
**算法**Kahn 算法BFS 拓扑排序)。
1. 构建 `name → id` 的映射表。
2. 为每个插件计算入度in-degree = 被多少个其他插件依赖)。
3. 从入度为 0 的节点(无依赖或依赖已满足的插件)开始,逐层输出。
4. 最终检查:若输出节点数不等于总节点数,说明存在**循环依赖**,抛出异常阻止初始化。
依赖声明在 `dstalk_plugin_info_t.dependencies` 中,以 NULL 结尾的字符串数组。最多 `DSTALK_MAX_DEPS` (8) 个依赖。
```
// 示例openai 插件声明依赖 http 和 config
{ "http", "config", NULL }
```
依赖名称与目标插件的 `info->name` 字段匹配(如 `"file_io"` 不是 DLL 的文件名),因此依赖声明和插件命名必须一致。
---
## 阶段 3on_init 的契约
`on_init` 是插件获得生命信号的地方。Host 按拓扑排序的结果依次调用每个插件的 `on_init`,传入 `dstalk_host_api_t*`
### 契约条款
**契约 1host_api 在 on_init 执行期间完全可用。**
`dstalk_host_api_t` 是插件访问一切 Host 能力的唯一通道。它的所有字段(`register_service``query_service``log``alloc``free` 等)在 `on_init` 调用时已经有效。插件应当将这个指针保存为全局变量——每次跨 API 调用都需要它。
```c
static const dstalk_host_api_t* g_host = nullptr;
static int on_init(const dstalk_host_api_t* host) {
g_host = host; // 保存,此后的所有 API 调用都用它
// ...
}
```
**契约 2query_service 查找依赖。** 插件在 `on_init` 中查询它声明的每一个依赖。如果某个依赖不存在,`on_init` 应当返回非零值,告知 Host 初始化失败。
```c
g_http = (dstalk_http_service_t*)host->query_service("http", 1);
if (!g_http) return -1; // 依赖缺失,初始化失败
```
Host 收到非零返回值后,会跳过后续插件的初始化并报告警告。
**契约 3register_service 注册自己的服务。** 插件将自己的 vtable 注册到服务注册表后,其他依赖它的插件才能在后续的 `on_init` 中通过 `query_service` 找到它。
```c
return host->register_service("ai_openai", 1, &g_service);
```
注册表内的 vtable 是原始指针,不拷贝。因此 vtable 指向的结构体必须是**静态生命周期**(全局变量或 static 局部变量)。
**契约 4不要在 on_init 中做阻塞操作。** 当前 Host 是单线程初始化,阻塞一个插件的 `on_init` 会阻塞整个启动流程。如果需要异步初始化(如连接远程服务),在 `on_init` 中仅做最基本的 vtable 注册,把长连接放到首次服务调用时再建立。
**契约 5所有内存分配通过 host_api->alloc/free。** 见下文"ABI 纪律"节。
---
## 阶段 4on_shutdown 的契约
Host 关闭时,按拓扑排序的**逆序**调用 `on_shutdown`——这保证了被依赖者后卸载。
```
加载顺序: config → http → openai
卸载顺序: openai → http → config
```
注意:如果拓扑排序失败(如循环依赖),`shutdown_all` 会退化为任意顺序,仅保证所有插件的 `on_shutdown` 都被调用、所有 DLL 句柄都被释放。
### 契约条款
**契约 1逆序卸载释放持久的服务引用。**`on_init` 中保存的服务指针(如 `g_http`)应在 `on_shutdown` 中置为 `nullptr`。这防止插件在卸载后仍持有悬垂指针——虽然当前实现是在 `on_shutdown` 之后才释放 DLL但防御性置空是好习惯。
```c
static void on_shutdown() {
g_http = nullptr;
g_config = nullptr;
g_host = nullptr;
}
```
**契约 2不能跨 DLL 堆边界释放。** 在插件 A 的 `on_shutdown` 中,如果还持有插件 B 分配的内存,不能简单地调用 `g_host->free`——这会触发跨堆释放的未定义行为。正确做法是调用提供方的专属释放函数(如 AI 服务的 `free_result`),或让提供方在 `on_shutdown` 中清理自己分配的资源。
**契约 3删除已注册的服务。** 当前 `ServiceRegistry` 不自动清理。如果一个服务 remove 了对应的插件,应该在卸载期间调用 `unregister_service`。当前实现未强制这一点,但关闭过程会销毁整个 `ServiceRegistry`,所以注销是可选的(若不注销,重启 host 时不会残留)。
---
## ABI 纪律:为什么 Host 提供 alloc / free
这是插件架构中最容易被忽视但最容易出错的问题。
**问题**:在 Windows 上,每个 DLL 可能链接不同的 CRTC 运行时库。DLL A 用 MSVC 2022 的 `malloc` 分配内存DLL B 用 MSVC 2019 的 `free` 释放——两个 `free` 管理不同的堆,导致崩溃或堆损坏。
**解决**dstalk 要求所有跨 DLL 边界的内存操作使用 Host 提供的统一分配/释放函数:
| 操作 | 用这个 |
|------|--------|
| 分配内存 | `host_api->alloc(size)` → 调用 host 启动时链接的 `malloc` |
| 释放内存 | `host_api->free(ptr)` → 调用 host 启动时链接的 `free` |
| 复制字符串 | `host_api->strdup(s)` → 用 host 的 `malloc` + `memcpy` |
```c
// 正确:跨 DLL 返回的字符串用 host 分配
r.content = g_host->strdup(ctx.accumulated.c_str());
// 正确:调用方释放跨 DLL 返回的数据用 host 释放
g_host->free((void*)result->content);
// 错误:用本地 malloc 分配跨 DLL 边界的数据
// r.content = strdup(...); // 消费者的 free() 和此 strdup 的 malloc() 可能不同堆!
```
**规则记忆**: 谁分配谁负责提供释放手段。dstalk 的选择是让 Host 统一分配——所有 `alloc/free` 调用走同一个 CRT 的堆。
---
## 生命周期速查
| 阶段 | 谁触发 | 插件状态 | 关键函数 |
|------|--------|----------|----------|
| DLL 加载 | `dstalk_init``dstalk_plugin_load` | 未初始化 | `load_plugin``dstalk_plugin_init()` |
| 依赖排序 | `initialize_all` | 等待初始化 | `topological_sort()` (Kahn) |
| 初始化 | Host 按序调用 | 运行中 | `on_init(host_api)` |
| 服务调用 | 任意插件/CLI 前端 | 运行中 | `query_service` → vtable 调用 |
| 卸载 | `dstalk_plugin_unload``dstalk_shutdown` | 关闭中 | `on_shutdown()``FreeLibrary`/`dlclose` |

View File

@@ -0,0 +1,109 @@
# Security Logging Audit (W9.3): 错误日志凭证泄露审计
**审计人**: 曹武 (security-cao)
**日期**: 2026-05-27
**审计范围**: 8 个核心/插件源码文件
**结论**: 未发现真实凭证泄露漏洞CVSS 不适用,零高危/中危/低危可利用漏洞)
---
## 审计方法论
对每个文件搜索以下输出调用:
- `host->log(...)` / `g_host->log(...)` (插件日志 API)
- `printf` / `fprintf(stderr, ...)` (C 标准输出)
- `std::cerr` / `std::cout` (C++ 标准流)
对每个匹配项检查其格式字符串和参数是否包含 `api_key``Authorization` header、`token` 或原始 request body/headers。
## 文件清单
### 1. `plugins_upper/openai/src/openai_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 242-245 | `g_host->log(INFO, ...)` | 输出 model / base_url / max_tokens / temperature | 无 -- api_key 被有意排除在格式字符串外 |
| 442 | `g_host->log(ERROR, ...)` | 静态字符串 "http service not found" | 无 |
| 446 | `g_host->log(INFO, ...)` | 静态字符串 "initializing OpenAI-compatible AI plugin" | 无 |
| 453 | `g_host->log(INFO, ...)` | 静态字符串 "shutdown" | 无 |
**build_headers_json() (行 59-63)**: 构建 `{"Authorization":"Bearer <key>"}` 并传给 HTTP 服务。该字符串从未传递给任何 log 调用,仅在 `http_post_json()` / `http_post_stream()` 的参数链中使用,最终由 Beast 直接设置到 HTTP request headers -- 全程无日志记录。
**parse_response() 错误路径 (行 135-151)**: HTTP 错误响应体仅用于提取 JSON `error.message` 字段放入 `r.error`,不会输出到日志。原始 response_body 在解析后被 `g_host->free()` 释放。
### 2. `plugins_upper/anthropic/src/anthropic_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 247-250 | `g_host->log(INFO, ...)` | 输出 model / base_url / max_tokens / temperature | 无 -- api_key 被有意排除 |
| 453 | `g_host->log(ERROR, ...)` | 静态字符串 "http service not found" | 无 |
| 457 | `g_host->log(INFO, ...)` | 静态字符串 "initializing Anthropic AI plugin" | 无 |
| 464 | `g_host->log(INFO, ...)` | 静态字符串 "shutdown" | 无 |
**build_headers_json() (行 59-65)**: 构建 `{"x-api-key":"<key>","anthropic-version":"2023-06-01"}` 仅用于 HTTP 请求,不经过日志路径。
### 3. `plugins_middle/network/src/network_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| -- | 无任何 `host->log` / `printf` / `cerr` 调用 | -- | 无 |
**parse_headers_json() (行 40-80)**: 解析包含 api_key 的 headers JSON 字符串。解析结果仅用于 `req.set(h.first, h.second)` (行 176) 设置 HTTP header不输出日志。
**do_post_stream() 异常路径 (行 280-282)**: `catch (std::exception& e)``e.what()` 赋值给 `result_body`。Beast/ASIO 异常消息为 OS 级别错误描述(如 "Connection refused"),不含 HTTP header/body 内容。
### 4. `plugins_base/config/src/config_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| -- | 无任何日志调用 | -- | 无 |
ConfigStore 仅提供 get/set/load_file无日志输出。
### 5. `dstalk_core/src/host.cpp` -- 基础设施(不动)
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 48,51,52 | `fprintf(stderr, ...)` | 日志前缀 + vfprintf(格式,参数) + 换行 | 无 -- 基础设施自身不包含业务数据 |
该文件是日志基础设施 (`host_log_impl`),仅负责格式化输出。安全性依赖于调用方不传敏感数据(本审计已确认所有调用方均安全)。按 W9.3 禁忌不修改此文件。
### 6. `dstalk_core/src/plugin_loader.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| -- | 无任何日志调用 | -- | 无 |
### 7. `plugins_middle/session/src/session_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 233 | `host->log(ERROR, ...)` | 静态字符串 "required service 'file_io' not found" | 无 |
该插件处理消息内容role/content但不记录任何消息数据到日志。
### 8. `plugins_base/lsp/src/lsp_plugin.cpp` -- 低风险
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 446 | `g_host->log(ERROR, ...)` | 静态字符串 "Invalid LSP frame..." | 无 |
| 479 | `g_host->log(ERROR, ...)` | `"failed to start: %s", server_cmd` | 低 -- 理论上 server_cmd 可能含令牌,但实际为 LSP 服务器路径(如 `clangd`),不属于 API 密钥范畴 |
| 525 | `g_host->log(ERROR, ...)` | 静态字符串 "initialize timed out" | 无 |
| 535 | `g_host->log(INFO, ...)` | `"server started: %s", server_cmd` | 低 -- 同上 |
| 565 | `g_host->log(INFO, ...)` | 静态字符串 "server stopped" | 无 |
| 720 | `g_host->log(INFO, ...)` | 静态字符串 "initializing LSP service plugin" | 无 |
| 728 | `g_host->log(INFO, ...)` | 静态字符串 "shutdown" | 无 |
`server_cmd` 是启动 LSP 子进程的命令行(例如 `clangd --log=error`),不是 API 密钥或 token。若用户将 API key 嵌入命令行(极不寻常且不安全的使用方式),则存在理论泄漏风险。判断为**低风险/假阳性**,不做代码修改。
## 总结
| 风险等级 | 数量 | 说明 |
|----------|------|------|
| 严重 (CVSS 9.0+) | 0 | 无 |
| 高危 (CVSS 7.0-8.9) | 0 | 无 |
| 中危 (CVSS 4.0-6.9) | 0 | 无 |
| 低危 (CVSS 0.1-3.9) | 0 | 无真实可利用漏洞 |
| 低风险/假阳性 | 2 | 仅 lsp `server_cmd` 日志和 network `e.what()` 理论上可能暴露非凭证信息 |
**审计结论**: 所有日志输出路径均已检查,证实 OpenAI-compatible 和 Anthropic 插件的 `my_configure()` 日志有意排除了 `api_key` 字段。HTTP headers 中的凭证仅通过内存传递至 Beast HTTP 请求对象,从未进入日志管道。代码库对此攻击面防御充分,无需修改。

View File

@@ -0,0 +1,39 @@
# CLI 命令速查
dstalk 所有内置命令。在对话中直接输入 `/help``/h` 也可查看此列表。
---
## 命令表
| 命令 | 别名 | 作用 | 示例 |
|------|------|------|------|
| `/help` | `/h` | 显示命令列表 | `/help` |
| `/quit` | `/q` | 退出程序 | `/quit` |
| `/clear` | — | 清空当前会话上下文 | `/clear` |
| `/context` | — | 显示当前 Token 数和消息条数 | `/context` |
| `/status` | — | 显示当前运行状态 (脱敏: 不打印完整 API Key) | `/status` |
| `/model <name>` | — | 切换 AI 模型 | `/model gpt-4o` |
| `/file list [path]` | — | 列出目录内容, 不填 path 列出当前目录 | `/file list src/` |
| `/file show <path>` | — | 查看文件内容 | `/file show main.cpp` |
| `/file read <path>` | — | 读取文件内容 (同 `/file show`) | `/file read config.toml` |
| `/file write <path> <content>` | — | 写入文件内容 | `/file write hello.c #include <stdio.h>...` |
| `/save <path>` | — | 保存当前会话到文件 | `/save session.json` |
| `/load <path>` | — | 从文件恢复会话 | `/load session.json` |
---
## 命令规则
- 所有命令以 `/` 开头
- 不以下划线开头的输入视为 AI 对话内容, 由 AI 服务处理
- 模型切换即时生效, 不影响已累积的会话历史
- `/file show``/file read` 功能相同, 均为读取并打印文件内容
- `/status` 显示脱敏信息: API Key 只显示 "已设置" 或 "未设置", 不暴露完整值
---
## 相关文档
- [快速入门教程](../tutorial/quick-start.md) — 5 步上手
- [文档导航](../README.md) — 全部文档索引

156
docs/reference/config.md Normal file
View File

@@ -0,0 +1,156 @@
# 配置参考 / Configuration Reference
`config.toml` 是 dstalk 的唯一配置文件,放在项目根目录。本文档列出所有支持字段、类型、默认值与使用说明。
---
## 配置概览
配置分为两种模式:
| 模式 | 适用场景 | 核心 key |
|------|----------|----------|
| **单 Provider** | 只需一个 AI 后端 | `ai.provider`, `api.*` |
| **多 Endpoint** | 同时配置多个 AI 后端,运行时切换 | `endpoints.names`, `endpoint.<name>.*` |
两种模式可以同时配置,但 `ai_endpoint_mgr``chat` / `chat_stream` 调用优先走 `endpoints.*`
---
## 一、单 Provider 模式 (Legacy)
直接在 `[global]` 下声明。这些 key 无默认值——不配置则无法启动 AI 对话。
### ai.provider
- **类型**: string
- **默认**: 无(必填)
- **值**: `"ai_openai"``"ai_anthropic"`
- **说明**: 指定要加载的 AI provider 插件。该名称对应插件 DLL 注册的服务名。
### api.base_url
- **类型**: string
- **默认**: 无(必填)
- **说明**: AI API 的基础 URL。例如:
- OpenAI: `https://api.openai.com/v1`
- Anthropic: `https://api.anthropic.com`
- DeepSeek (OpenAI 兼容): `https://api.deepseek.com/v1`
### api.api_key
- **类型**: string
- **默认**: 无(必填)
- **说明**: API 密钥。注意保管,不要提交到版本控制。
### api.model
- **类型**: string
- **默认**: 无(必填)
- **说明**: 要使用的模型名称。例如 `gpt-4o``claude-sonnet-4-20250514``deepseek-chat`
---
## 二、多 Endpoint 模式 (推荐)
`ai_endpoint_mgr` 插件管理。每个 endpoint 是一个命名的 AI 后端配置,运行时可通过 `/endpoint` 命令切换。
### endpoints.names
- **类型**: string (逗号分隔)
- **默认**: 无
- **说明**: 所有要启用的 endpoint 名称列表。例如 `"openai_main, anthropic_alt"`。重复名称会被跳过并输出警告。
### endpoints.active
- **类型**: string
- **默认**: 取 `endpoints.names` 中第一个成功加载的 endpoint
- **说明**: 启动时默认使用的 endpoint 名称。如果指定的名称不在 `endpoints.names` 中或对应配置无效,则回退到第一个成功加载的 endpoint。
---
### endpoint.\<name\>.provider
- **类型**: string
- **默认**: 无(必填)
- **值**: `"ai_openai"``"ai_anthropic"`
- **说明**: 该 endpoint 使用的 AI provider 服务名称。
### endpoint.\<name\>.base_url
- **类型**: string
- **默认**: 按 provider 自动推导
- **说明**:
- 未配置时,已知 provider 自动使用默认 base_url:
- `ai_anthropic``https://api.anthropic.com`
- `ai_openai``https://api.openai.com/v1`
- 如果 provider 不在已知列表中,**必须**显式配置 `base_url`,否则该 endpoint 加载失败。
- 显式配置的值优先于默认值可用于自定义端点如代理、DeepSeek 兼容 API 等)。
### endpoint.\<name\>.api_key
- **类型**: string
- **默认**: 无(必填)
- **说明**: 该 endpoint 的 API 密钥。
> **安全提示**: `api_key` **不会**出现在 `list_json()` 的输出中,也不进入日志。这是有意为之的安全策略。
### endpoint.\<name\>.model
- **类型**: string
- **默认**: 无(必填)
- **说明**: 该 endpoint 默认使用的模型名称。运行时可通过 `set_model()` 修改并同步回 `config.toml`
### endpoint.\<name\>.max_tokens
- **类型**: integer
- **默认**: `4096`
- **范围**: `1` ~ `1000000`(超出范围的值视为无效,回退到默认值)
- **说明**: 每次请求的最大输出 token 数。
### endpoint.\<name\>.temperature
- **类型**: double
- **默认**: `0.7`
- **范围**: `0.0` ~ `2.0`(超出范围的值视为无效,回退到默认值)
- **说明**: 生成温度。越高随机性越强,越低越确定性。
---
## 三、ui 配置 (可选 / optional)
以下 key 目前为 CLI 前端预留,后续 GUI/Web 前端也会使用。
### ui.prompt
- **类型**: string
- **默认**: `"> "`
- **说明**: 命令行提示符字符串。
### ui.multiline
- **类型**: string
- **默认**: `"/"` (即不支持多行输入)
- **说明**: 多行输入结束符。发送该字符串结束多行输入模式。
---
## 四、完整配置示例
见项目根目录 `config.example.toml`
---
## 五、运行时 endpoint 操作
`ai_endpoint_mgr` 服务提供以下接口(通过 C ABI供前端调用:
| 操作 | 说明 |
|------|------|
| `count()` | 返回已加载的 endpoint 数量 |
| `list_json()` | 返回 JSON 数组,每项含 `name`, `provider`, `base_url`, `model`, `active` (不含 `api_key` |
| `get_active()` | 返回当前激活的 endpoint 名称 |
| `set_active(name)` | 切换激活 endpoint返回 0 成功,-2 表示不存在 |
| `set_model(name, model)` | 修改 endpoint 的模型并同步到 configname 为 null 时作用于当前 active endpoint |
| `chat(ep, history, len, input, tools)` | 路由对话到指定 endpointep 为 null 时用 active |
| `chat_stream(ep, history, len, input, cb, userdata)` | 流式路由对话 |

View File

@@ -0,0 +1,522 @@
# dstalk Plugin ABI 契约
> **面向**: 插件作者、host 维护者
> **性质**: 规范性文档。违反任何条目 = 未定义行为。
---
## 1. DSTALK_API_VERSION
```c
#define DSTALK_API_VERSION 1 // dstalk_host.h
```
**语义**: 主版本号。当且仅当 `dstalk_host_api_t` 的字段布局、`dstalk_plugin_info_t` 的结构、或任意
`*_service_t` vtable 的函数签名发生**不兼容变更**时 bump。
**不 bump 的情况**: 新增 event type 枚举值、新增日志级别、在 vtable 末尾追加函数指针(注意:
不能改变已有字段的偏移)。
**匹配规则**: `plugin::api_version` 必须**精确等于** `DSTALK_API_VERSION`。host 拒绝加载不匹配的
插件(`plugin_loader.cpp:68`),不提供后向兼容。这是硬断点——旧插件在新 host 上重新编译即可适配,
二进制兼容不做保证。
**版本协商被拒绝的理由**: 早期版本刻意保持简单。如果未来需要协商(如 min/max version range
通过 bump `DSTALK_API_VERSION` 到 2 并定义协商结构体即可。W1.x 阶段不需要。
---
## 2. 内存所有权契约
Core rule: **谁分配,谁释放。分配函数必须与释放函数配对。**
### 2.1 Host 分配的字符串
以下函数返回的 `const char*` / `char*` **由 host 拥有**,调用方不得释放:
| 来源 | 示例 | 生命周期 |
|------|------|----------|
| `host->config_get(key)` | 配置值字符串 | 随 config 条目存在,或下次 `config_set` 覆盖前有效 |
| `dstalk_chat_result_t.content` | AI 回复 | 必须在读取后立即复制;下一轮 `chat()` 调用可能覆盖 |
以下函数返回的 `char*` **由调用方释放**(用 `dstalk_free` / `host->free`
| 函数 | 释放方式 |
|------|----------|
| `dstalk_strdup(s)` / `host->strdup(s)` | `dstalk_free(ptr)` / `host->free(ptr)` |
| `dstalk_plugin_list(output_json)` | `dstalk_free(*output_json)` |
| LSP 便捷函数 (`dstalk_lsp_*`) 的 `char** output` 参数 | `dstalk_free(*output)` |
### 2.2 插件返回的字符串
`dstalk_plugin_info_t` 中的 `name``version``description` 由插件的 `dstalk_plugin_init()`
返回。host 在加载时读取并内部分配 `std::string` 副本(`plugin_loader.cpp:81-83`),随后不再
引用原始指针。因此这些字符串的**生命周期只需覆盖 `init_fn()` 调用期间**——可以是静态字面量、
栈上字符串、或插件内部分配的内存。
### 2.3 服务 vtable 的返回值
`dstalk_chat_result_t.content``.error``.tool_calls_json` 按结构体注释约定由 `dstalk_strdup`
分配,**调用方**(即查询该服务的插件)负责 `dstalk_free`
**反例**: 插件直接返回 `std::string::c_str()` 或栈上 buffer —— 因为服务调用完成后插件栈帧
可能已销毁。有关字符串返回值的完整规则见 **§9 字符串返回值生命周期**。
---
## 3. 跨 DLL 堆纪律
### 3.1 问题
Windows 上每个 DLL 拥有独立的 CRT 堆(取决于链接方式:/MD 共享 或 /MT 静态)。插件在其
CRT 中调用 `malloc` 得到的指针host 调用 `free` 时访问的是 host CRT 的堆——行为未定义。
Linux/macOS 通常共享 libc但静态链接或不同 libc 版本时同样可能 crash。
### 3.2 硬性规则
> **严禁插件直接调用 `malloc`/`free`/`strdup`/`new`/`delete` 处理 host 传入或传出的数据。**
正确做法:
| 场景 | 错误做法 | 正确做法 |
|------|----------|----------|
| 释放 host 给的字符串 | `free(host->config_get("key"))` | 不释放;只读后丢弃 |
| 分配传给 host 的缓冲区 | `malloc(256)` | `host->alloc(256)``dstalk_alloc(256)` |
| 释放 host 分配的内存 | `delete ptr` / `std::free(ptr)` | `host->free(ptr)``dstalk_free(ptr)` |
| 复制 host 给的字符串 | `strdup(host->config_get("k"))` | `host->strdup(...)``dstalk_strdup(...)` |
| 在插件 DLL 内分配/释放私有数据 | 可以用任何方式 | 只要不跨越 DLL 边界 |
### 3.3 设计理由
`host->alloc` / `host->free` / `host->strdup` 是通过函数指针调回 host DLL 的 `malloc`/`free`
保证分配和释放发生在**同一个堆**上。`g_host_api` 表的 `api_alloc`/`api_free` 直接就是 `malloc`
/`free``host.cpp:111-112`),所以 "host 分配 → host 释放" 总是在同一个 CRT 堆内。
---
## 4. register_service 契约
### 4.1 调用时机
`host->register_service(name, version, vtable)` **仅可在 `on_init` 回调期间调用**
原因:
- 服务注册表 `ServiceRegistry``dstalk_init()` 内创建,`on_init` 之前已存在。
- 初始化顺序由拓扑排序保证:依赖方的 `on_init` 在被依赖方之后调用,因此被依赖方注册的服务
在依赖方调用时已可用。
- `on_shutdown` 期间不应注册新服务(该阶段仅做清理)。
### 4.2 重复注册
同一 `name` 不可重复注册:第二次调用返回 `-2``service_registry.cpp:13`)。插件应检查返回
值,在共享服务名(如 `"ai_openai"`)的场景中避免冲突。
### 4.3 版本协商
`register_service``version` 参数声明了该 vtable 实现的版本。`query_service`
`min_version` 允许调用方声明最低需求版本。当前实现为整型比较:`registered_version >=
min_version` 即可。
**插件作者约定**: version 从 1 开始。vtable 新增函数指针(追加到末尾)→ bump version。
vtable 重排字段或删除函数 → 改 service name`"lsp"``"lsp2"`)。
### 4.4 vtable 生命周期
注册的 vtable 指针在 `ServiceRegistry` 中存储,随插件存在。插件卸载时,`ServiceRegistry::
unregister_service` 会被调用。**插件不得在卸载后继续持有该 vtable 指针**。
---
## 5. on_init / on_shutdown 契约
### 5.1 调用顺序保证
- **on_init**: 按拓扑顺序调用(依赖项先初始化)。由 `PluginLoader::topological_sort()`
使用 Kahn 算法计算(`plugin_loader.cpp:144-198`)。
- **on_shutdown**: 按拓扑顺序的**逆序**调用(依赖项后销毁)。如果拓扑排序因循环依赖失败,
降级为任意顺序(`plugin_loader.cpp:263-267`)。
- **增量加载**: `dstalk_plugin_load()` 只初始化新加载的插件(`initialize_pending`),已
初始化的插件不受影响。
### 5.2 返回值
`on_init` 返回 `0` = 成功;非零 = 失败。失败导致 `initialize_all` 返回 `-1`,整体初始化
流程中止。失败插件的 `on_shutdown` **不会被调用**`plugin.initialized` 保持 false
### 5.3 异常安全 (C++ ABI)
> **C++ 异常不得穿越 C ABI 边界。**
`on_init``on_shutdown` 定义为 C 函数指针:
```c
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
```
调用方PluginLoader**不设置** `try/catch` 保护(`plugin_loader.cpp:212-214`)。
如果 `on_init` 由 C++ 实现且抛出异常,将导致 `std::terminate` / 未定义行为。
**防护规则**:
-`extern "C"` 声明实现
- 所有可能抛异常的 C++ 逻辑用 `try { ... } catch (...) { return -1; }` 包裹
- `on_shutdown` 同理,即使 void 也不能抛异常
> **延伸**: 本节仅覆盖 `on_init` / `on_shutdown` 两个生命周期回调。**所有**通过 C ABI 导出的函数
> (包括 service vtable 中的函数指针)均适用同样规则,详见 **§8 异常安全——穿越 ABI 边界**。
---
## 6. 回调线程安全
### 6.1 诊断回调 (diag_callback)
`g_diag_callback``std::atomic<dstalk_diag_cb>`,使用 `memory_order_acquire` / `release`
`host.cpp:28,54,305`)。**多线程同时调用 `dstalk_log` 安全**——每个线程独立读取原子指针,
无数据竞争。
### 6.2 事件总线 (EventBus)
`EventBus` 使用 `std::shared_mutex`
- `subscribe` / `unsubscribe` 持有 `unique_lock`
- `emit` 持有 `shared_lock`
**结论**: 多线程 subscribe/unsubscribe/emit 安全。但 emit 持有 shared_lock 期间直接调用
handler —— handler 内**不得调用 subscribe/unsubscribe**(会尝试 unique_lock 导致死锁)。
`on_event` 回调与 `on_init` / `on_shutdown` 之间无互斥保护,因此**不应在 `on_event` 回调
内调用 host API 执行插件的 load/unload**PluginLoader 无内部锁)。
### 6.3 服务注册表 (ServiceRegistry)
使用 `std::shared_mutex`register/unregister 持写锁query 持读锁。并发安全。
### 6.4 配置 (ConfigStore)
使用 `std::mutex`get/set 串行化。`config_get` 返回的指针指向内部 `std::string`;在并发
`config_set` 同一 key 后指针可能悬垂——调用方应复制。详细规则见 **§9 字符串返回值生命周期**。
### 6.5 Plugin Loader
`PluginLoader` **无内部互斥**`plugin_loader.hpp` 无 mutex 成员。load/unload 不应在
多线程中并发调用。这是 host 层的设计假设——仅 `dstalk_init` / `dstalk_shutdown` 和显式
CLI 命令使用。
---
## 7. 依赖声明 (dependencies)
### 7.1 语法
```c
const char* dependencies[DSTALK_MAX_DEPS]; // DSTALK_MAX_DEPS = 8
```
`NULL` 终止的字符串数组,每个元素为被依赖插件的 `name`
### 7.2 语义
- 被依赖项必须先于依赖方初始化(拓扑排序保证)
- 被依赖项必须后于依赖方销毁(逆序 shutdown
- 如果被依赖项不存在:初始化时不影响拓扑排序(仅计算已加载插件间的依赖关系),但插件的
`on_init` 自己应检查所需服务是否可用(通过 `host->query_service`
- 循环依赖:拓扑排序失败,`topological_sort()` 抛出 `std::runtime_error("Circular
dependency detected")`,被 `initialize_all` 捕获并返回 `-1`
- 最大 8 个依赖,超出部分截断(`plugin_loader.cpp:90`
### 7.3 最佳实践
`on_init` 内通过 `host->query_service("service_name", min_version)` 验证依赖可用。
不做假设。这也是一种隐式版本协商:如果 service version 不足,`on_init` 返回错误。
---
## 8. 异常安全——穿越 ABI 边界
> **强制规则**: 所有导出给 host 的 C 函数(包括 `on_init` / `on_shutdown` 以及 service vtable
> 中的每一个函数指针)**严禁**让 C++ 异常穿越函数边界。
### 8.1 适用范围
本规则覆盖以下所有通过 C ABI 调用的函数入口:
1. `dstalk_plugin_info_t` 中的 `on_init` / `on_shutdown` / `on_event`(已在 §5.3 覆盖)
2. 所有 `*_service_t` vtable 中的函数指针(如 `context_trim`、`config_get`、
`ai_chat` 等)
3. `EventBus` 中注册的 `dstalk_event_handler_fn` 回调
4. 任何通过 `host->register_service` 注册的 vtable 函数
**为什么 service vtable 函数也受约束**: vtable 函数指针的类型签名为纯 C
`int (*)(const dstalk_message_t*, int, dstalk_message_t**, int*, size_t)`
调用方 `host` / 其他插件通过该指针直接调用,调用路径上无 `try/catch` 保护。
C++ 异常在此路径上传播 → `std::terminate()` → 进程崩溃。
### 8.2 实施要求
**每个**使用以下 C++ 类型的函数外层,必须包裹异常保护:
- `std::string` / `std::wstring`
- `std::vector` / `std::map` / `std::unordered_map` 等 STL 容器
- `std::unique_ptr` / `std::shared_ptr` / `std::make_unique` / `std::make_shared`
- `std::stringstream` / `std::ifstream` / `std::ofstream`
- 任何可能抛出 `std::bad_alloc`、`std::out_of_range`、`std::system_error` 的操作
**标准包裹模式**:
```c
static int service_function(/* 参数 */) {
try {
// 主逻辑:可以使用 std::string / std::vector 等
std::vector<std::string> items;
// ...
return 0;
} catch (const std::exception& e) {
g_host->log(DSTALK_LOG_ERROR, "service_function: %s", e.what());
return -1;
} catch (...) {
g_host->log(DSTALK_LOG_ERROR, "service_function: unknown exception");
return -1;
}
}
```
### 8.3 错误返回约定
| 返回类型 | 错误值 | 示例函数 |
|----------|--------|----------|
| `int` | 非零(通常 `-1` | `on_init`, `context_trim`, `config_set` |
| `const char*` / `char*` | `nullptr` | `config_get`, `strdup` |
| `bool` | `false` | (当前无 bool 返回值的 vtable 函数) |
| `size_t` | `0` | `context_count_tokens` |
| `void` | 无返回值;仅记日志,禁止抛异常 | `on_shutdown`, `context_set_max_tokens` |
失败时推荐调用 `host->log(DSTALK_LOG_ERROR, "function_name: <reason>")` 记录原因,
便于诊断。`void` 返回类型的函数即使无法向调用方报告错误,也必须记录日志。
### 8.4 反例:未保护的 service vtable 函数
以下代码来自 `context_plugin.cpp``trim_impl`, L114-226。该函数由 vtable 中的
`context_trim` 直接调用,底层使用 `std::vector` / `std::string`,无 `try/catch`
```c
// 反例C++ 异常可穿越 ABI 边界 → std::terminate()
static int trim_impl(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
// 无 try/catch 包裹!
// ▼ std::vector::reserve / push_back 可抛 std::bad_alloc
std::vector<TrimMessage> messages;
messages.reserve(in_count);
for (int i = 0; i < in_count; ++i) {
TrimMessage tm;
tm.role = in[i].role; // std::string::operator= → bad_alloc
tm.content = in[i].content; // 同上
// ...
messages.push_back(std::move(tm)); // push_back → bad_alloc
}
// ▼ count_tokens_trim_vec 内对每个元素调用 count_tokens_trim
// 后者访问 std::string::operator[] → 不抛异常(已 bounds-checked
// 但 vector 迭代器可能被异常干扰
size_t current = count_tokens_trim_vec(messages);
// ... 更多 std::vector 操作 ...
return 0; // ← 若以上任何操作抛异常,永远不会到达这里
// 异常沿 C 函数指针返回 host触发 std::terminate()
}
// vtable 绑定——通过 C 函数指针直接暴露
static int context_trim(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
return trim_impl(in, in_count, out, out_count, max_tokens);
}
```
**违反代价**: OOM 或任何 STL 异常发生时,进程直接 `std::terminate()`,无任何恢复机会。
### 8.5 正例:异常安全的 service 函数
```c
// 正例try/catch 包裹所有 C++ 操作,异常转换为错误码
static int trim_impl_safe(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
try {
if (!in || in_count <= 0 || !out || !out_count) return -1;
std::vector<TrimMessage> messages;
messages.reserve(in_count);
for (int i = 0; i < in_count; ++i) {
TrimMessage tm;
if (in[i].role) tm.role = in[i].role;
if (in[i].content) tm.content = in[i].content;
if (in[i].tool_call_id) tm.tool_call_id = in[i].tool_call_id;
if (in[i].tool_calls_json) tm.tool_calls_json = in[i].tool_calls_json;
messages.push_back(std::move(tm));
}
// ... 裁剪逻辑 ...
return 0;
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host; // 原子读或以其他安全方式获取
if (host) {
host->log(DSTALK_LOG_ERROR, "trim_impl: %s", e.what());
}
return -1;
} catch (...) {
if (g_host) {
g_host->log(DSTALK_LOG_ERROR, "trim_impl: unknown exception");
}
return -1;
}
}
```
**关键点**:
- `try/catch` 覆盖整个函数体——所有 STL 操作均在保护范围内
- 两个 `catch` 子句:先捕获 `std::exception`(可取 `what()` 消息),再兜底 `...`
- 日志中标注函数名,便于问题定位
- 返回值转换为 `-1`,调用方可安全处理错误
---
## 9. 字符串返回值生命周期
> **强制规则**: 通过 ABI 返回的 `const char*` 必须满足以下二选一,禁止任何其他模式。
### 9.1 问题根源
C ABI 只能传递裸指针 `const char*`。指针指向的内存在何时释放、由谁释放,没有
类型系统保障。错误的生命周期管理导致两大类 bug
1. **悬垂指针 (use-after-free)**: 指针指向的 `std::string` 内部 buffer 在锁释放后
被并发 `set()` 的 realloc 回收,或函数返回后栈帧销毁。
2. **跨堆释放 (heap corruption)**: 调用方尝试 `free()` 插件 CRT 堆分配的指针,
或反之。
§2 已覆盖内存所有权归属§3 已覆盖跨 DLL 堆纪律。本节是对字符串返回值**生命周期**的
专项强制条款,与 §2、§3 构成完整的指针契约体系。
### 9.2 规则:两种合法模式
#### 模式 A拥有权转移推荐
**约定**: 返回的 `char*` 由函数分配(通过 `host->strdup` 或 `host->alloc`
**调用方**负责用 `host->free` 释放。
```c
// 返回由 host 堆分配、调用方负责释放的字符串
static char* service_get_owned_value(const char* key) {
const char* raw = g_host->config_get(key);
if (!raw) return nullptr;
return g_host->strdup(raw); // 在 host 堆上复制,调用方 host->free 释放
}
```
**适用场景**: 需要将数据传出函数作用域、调用方需持有的情况。service vtable 函数返回
动态内容时应使用此模式。
**文档要求**: 函数注释必须明确声明 "caller must free with `host->free`" 或等效说明。
#### 模式 B静态 / 全局生命周期
**约定**: 返回的 `const char*` 指向编译期确定的内存,调用方**不得**释放。
允许的来源:
- 字符串字面量: `return "hello";`
- `static` 字符串数组: `static const char buf[] = "value"; return buf;`
- 全局变量的 `c_str()`: **仅当**文档明确约束 "下次调用同函数前有效" 且无并发写入
```c
// OK: 字符串字面量——程序生命周期内有效
static const char* plugin_get_name(void) {
return "context";
}
// OK: static buffer——程序生命周期内有效
static const char* plugin_get_version(void) {
static const char version[] = "1.0.0";
return version;
}
```
**不推荐的模式**: `static thread_local std::string` 虽然技术上满足 "下次调用前有效"
但语义晦涩,调用方容易误用。仅在性能热点且调用方可证明不会跨调用持有指针时使用。
### 9.3 反例:锁外返回 `c_str()` (悬垂指针)
以下代码来自 `config_store.cpp:66-73` 和 `config_plugin.cpp:72-78`。
`get()` 在锁内获取 `std::string::c_str()`,锁释放后返回:
```c
// 反例:锁释放后 c_str() 指针悬垂
const char* ConfigStore::get(const char* key) const {
if (!key) return nullptr;
std::lock_guard<std::mutex> lock(mutex_); // 获取锁
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
return it->second.c_str(); // 获取内部指针
} // ← lock 在此释放。指针仍指向 map 内 string 的 buffer。
// 并发场景:
// T1: const char* v = store.get("api_key"); // 得到指针 p → "sk-abc123"
// T2: store.set("api_key", "sk-very-long-new-key-..."); // realloc! p 悬垂
// T1: printf("%s\n", v); // ← 可能读取已释放内存、乱码或 crash
```
**问题分析**:
1. `std::string` 内部 buffer 由 `std::unordered_map` 的值对象拥有。
2. 并发 `set()` 同一 key 触发 `operator=` → 可能 realloc → 旧 buffer 被释放。
3. 外部持有的 `c_str()` 指针已悬垂,访问即未定义行为。
W11.2 审计已确认此问题存在于两个独立 ConfigStore 实现中
`config_store.cpp:72` 和 `config_plugin.cpp:77`)。
### 9.4 正例:用 `host->strdup` 返回安全副本
```c
// 正例:在锁内用 host->strdup 复制,调用方负责释放
static char* config_get_safe(const char* key) {
if (!key) return nullptr;
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
// ★ 在锁内完成复制——指针从"内部 buffer"变为"独立分配的内存"
return g_host->strdup(it->second.c_str());
// 调用方用 host->free(ptr) 释放
}
```
**为什么这是安全的**:
1. `host->strdup` 调回 host CRT 堆,分配和未来的释放都在同一堆(符 §3
2. 锁内完成复制——即使 T2 随后 `set()` 同一 key新分配的内存与旧 buffer 无关。
3. 调用方拥有返回指针的所有权——不再依赖 map 内部状态。
**权衡**: 每次 `get()` 多一次堆分配。对于高频调用的 key如配置读取调用方应在
获取后立即消费并释放;对于需长期持有的指针,这是唯一安全的方式。
### 9.5 违规代价
| 违规模式 | 后果 |
|----------|------|
| 返回锁外 `c_str()` | 并发 set 后悬垂指针 → crash 或静默数据损坏 |
| 返回栈上 buffer 指针 | 函数返回后栈帧销毁 → use-after-free |
| 返回 `std::string{...}.c_str()` | 临时对象在语句结束时析构 → 立即悬垂 |
| 调用方 `free()` 模式 B 的指针 | 跨堆释放 → heap corruption / crash |
| 调用方不释放模式 A 的指针 | 内存泄漏 |
---
## 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-27 | 1.1 | W12.6 追加 §8 异常安全 (涵盖 service vtable 函数) 和 §9 字符串返回值生命周期§2.3 / §5.3 / §6.4 添加交叉引用 |
| 2026-05-27 | 1.0 | 初始版本。W9.4 交付。基于 DSTALK_API_VERSION=1 的当前实现。 |

View File

@@ -0,0 +1,179 @@
# 快速入门
5 步上手 dstalk, 从安装工具链到完成第一个 AI 对话。
---
## 1. 安装工具链
dstalk 需要 CMake、Ninja、LLVM/Clang 和 Conan2。`tools/` 下的脚本全自动安装。
**Windows:**
```bash
cd tools
setup.bat
```
**Mac:**
```bash
# 方式 A: 手动安装后运行脚本
brew install cmake ninja
cd tools && bash setup.sh
# 方式 B: 脚本全自动 (推荐)
cd tools && bash setup.sh
```
**Linux (Ubuntu/Debian):**
```bash
# 方式 A: 手动安装后运行脚本
sudo apt install cmake ninja-build
cd tools && bash setup.sh
# 方式 B: 脚本全自动 (推荐)
cd tools && bash setup.sh
```
> **前提**: 系统需已安装 Python 3.10+。
>
> 网络不畅时, 可手动下载放入对应目录:
> - [Ninja](https://github.com/ninja-build/ninja/releases) → `tools/ninja/ninja` (或 `ninja.exe`)
> - [CMake](https://cmake.org/download/) → `tools/cmake/bin/cmake` (或 `cmake.exe`)
> - [LLVM](https://github.com/llvm/llvm-project/releases) → `tools/llvm/bin/clang` (或 `clang.exe`)
> - Conan2 通过 pip 安装到 `tools/.venv/Scripts/conan.exe` (Win) 或 `tools/.venv/bin/conan` (Mac/Linux)
---
## 2. 编译
项目根目录提供 `build.bat` (Windows) / `build.sh` (Mac/Linux), 一键完成: Conan 拉取依赖 -> CMake 配置 -> Ninja 编译。
**Windows:**
```bash
build.bat
```
**Mac / Linux:**
```bash
bash build.sh
```
编译产物输出到 `build/` 目录:
| 产物 | Windows | Mac | Linux |
|------|---------|-----|-------|
| 核心 DLL | `build/bin/dstalk.dll` | `build/bin/libdstalk.dylib` | `build/bin/libdstalk.so` |
| CLI 前端 | `build/bin/dstalk_cli.exe` | `build/bin/dstalk_cli` | `build/bin/dstalk_cli` |
| 功能插件 | `build/plugins/*.dll` | `build/plugins/*.dylib` | `build/plugins/*.so` |
---
## 3. 创建 config.toml
在项目根目录创建 `config.toml`, 配置 AI 后端和 API Key。
```bash
# 在项目根目录手动创建 config.toml
```
**config.toml 示例:**
```toml
# 选择 AI 后端插件: ai_openai 或 ai_anthropic
ai.provider = "ai_openai"
# OpenAI-compatible
api.base_url = "https://api.openai.com/v1"
api.api_key = "sk-xxxxxxxx"
api.model = "gpt-4o"
# Anthropic Claude (切换 ai.provider 为 "ai_anthropic" 即可)
# api.base_url = "https://api.anthropic.com/v1"
# api.api_key = "sk-ant-xxxxxxxx"
# api.model = "claude-opus-4-20250514"
```
> **关键**: 修改 `ai.provider` 字段即可在不同后端间切换, 无需改动代码。
>
> API Key 可从 [OpenAI-compatible 开放平台](https://platform.openai.com/) 或 [Anthropic Console](https://console.anthropic.com/) 获取。
>
> **多 Endpoint 配置** (同时使用多个 AI 后端并在运行时通过 `/endpoint` 切换): 参见 [配置参考](../reference/config.md) 和项目根目录 `config.example.toml`。
---
## 4. 运行 dstalk_cli
**Windows:**
```bash
build\bin\dstalk_cli.exe
```
**Mac / Linux:**
```bash
build/bin/dstalk_cli
```
启动后显示欢迎横幅:
```text
dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
[gpt-4o] >
```
> 图形模式默认关闭。需要 SDL3 GUI 时, 用 `-DDSTALK_BUILD_GUI=ON` 重新配置 CMake。
---
## 5. 第一个对话
在提示符 `>` 后输入自然语言, 即可与 AI 对话。
```text
[gpt-4o] > 帮我写一个读取 CSV 并计算平均值的 C 程序
[dstalk] 正在思考...
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "用法: %s <csv文件>\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (!fp) { perror("fopen"); return 1; }
double sum = 0.0;
int count = 0;
char line[1024];
while (fgets(line, sizeof(line), fp)) {
sum += atof(line);
count++;
}
fclose(fp);
printf("平均值: %.2f (共 %d 行)\n", sum / count, count);
return 0;
}
已写入 csv_avg.c。需要我帮你编译测试吗
[gpt-4o] > 把这段代码改成支持表头的
[dstalk] 已更新 csv_avg.c——跳过第一行表头, 增加列选择功能。
[gpt-4o] > /file show csv_avg.c
[dstalk] 已显示 csv_avg.c 内容。
```
---
## 下一步
- 查看 [CLI 命令速查表](../reference/commands.md) 了解全部命令
- 查看 [配置参考](../reference/config.md) 了解 config.toml 所有字段
- 输入 `/help` 在 dstalk 内查看命令列表
- 输入 `/status` 查看当前运行状态

View File

@@ -1,17 +0,0 @@
# ============================================================
# dstalk-cli — 命令行前端 (ANSI 转义码)
# ============================================================
add_executable(dstalk-cli
src/main.cpp
)
target_link_libraries(dstalk-cli
PRIVATE dstalk
)
add_custom_command(TARGET dstalk-cli POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_FILE:dstalk>
$<TARGET_FILE_DIR:dstalk-cli>
)

View File

@@ -1,468 +0,0 @@
// ============================================================================
// dstalk-cli — 命令行前端 (使用插件化架构)
// ============================================================================
// 通过 dstalk_host.h API 初始化核心,然后查询插件服务 vtable 调用功能。
// ============================================================================
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
#include <system_error>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#include <io.h>
#else
#include <signal.h>
#include <termios.h>
#include <unistd.h>
#endif
#include "dstalk/dstalk_host.h"
// ---- ANSI 简写 ----
#define CLR_RESET "\033[0m"
#define CLR_CYAN "\033[36m"
#define CLR_YELLOW "\033[33m"
#define CLR_GREEN "\033[32m"
#define CLR_RED "\033[31m"
#define CLR_DIM "\033[2m"
#define CLR_BOLD "\033[1m"
// ---- 退出码 ----
#define EXIT_OK 0
#define EXIT_INIT_FAIL 1
#define EXIT_AI_ERROR 2
#define EXIT_SVC_UNAVAIL 3
// ---- 服务 vtable 指针 ----
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static const dstalk_file_io_service_t* g_file_io = nullptr;
// ---- 运行时状态 ----
static std::string g_current_model;
static std::atomic<bool> g_quit_requested{false};
// ---- Ctrl+C 信号处理 ----
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) {
g_quit_requested = true;
return TRUE;
}
return FALSE;
}
#else
static void on_signal(int /*sig*/)
{
g_quit_requested = true;
}
#endif
// ---- 工具函数 ----
static void print_banner()
{
std::printf("%sdstalk v0.1.0%s | %sdstalk AI%s | "
"%s/help%s 查看帮助 | %s/quit%s 退出\n",
CLR_CYAN CLR_BOLD, CLR_RESET,
CLR_GREEN, CLR_RESET,
CLR_DIM, CLR_RESET,
CLR_DIM, CLR_RESET);
}
static void print_help()
{
std::printf("\n%s命令列表:%s\n", CLR_BOLD, CLR_RESET);
std::printf(" %s/help%s 显示此帮助\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/quit%s 退出程序\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/clear%s 清空当前会话上下文\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/context%s 显示当前 Token 数和消息条数\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/status%s 显示当前运行状态(脱敏)\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/model <name>%s 切换模型\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file list [path]%s 列出目录内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file show <path>%s 查看文件内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file read <path>%s 读取文件内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file write <p> <c>%s 写入文件\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/save <path>%s 保存会话\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/load <path>%s 恢复会话\n", CLR_YELLOW, CLR_RESET);
std::printf("\n直接输入问题即可与 AI 对话。\n\n");
}
static void print_file(const char* path)
{
while (*path == ' ') path++;
if (!g_file_io) {
std::printf(CLR_RED "[ERROR] file_io 服务不可用\n" CLR_RESET);
return;
}
char* content = nullptr;
if (g_file_io->read(path, &content) == 0 && content) {
std::printf("%s--- %s ---%s\n", CLR_DIM, path, CLR_RESET);
std::printf("%s\n", content);
std::printf(CLR_DIM "--- EOF ---\n" CLR_RESET);
dstalk_free(content);
} else {
std::printf(CLR_RED "[ERROR] 无法读取: %s\n" CLR_RESET, path);
}
}
static void list_files(const char* path)
{
while (*path == ' ') path++;
std::filesystem::path dir = *path ? std::filesystem::path(path) : std::filesystem::current_path();
std::error_code ec;
if (!std::filesystem::exists(dir, ec) || !std::filesystem::is_directory(dir, ec)) {
std::printf(CLR_RED "[ERROR] 不是有效目录: %s\n" CLR_RESET, dir.string().c_str());
return;
}
std::vector<std::filesystem::directory_entry> entries;
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
if (ec) break;
entries.push_back(entry);
}
if (ec) {
std::printf(CLR_RED "[ERROR] 无法列出目录: %s\n" CLR_RESET, dir.string().c_str());
return;
}
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
return a.path().filename().string() < b.path().filename().string();
});
std::printf("%s--- %s ---%s\n", CLR_DIM, dir.string().c_str(), CLR_RESET);
for (const auto& entry : entries) {
std::error_code status_ec;
const bool is_dir = entry.is_directory(status_ec);
std::printf(" %s%s%s\n", is_dir ? CLR_CYAN : "", entry.path().filename().string().c_str(), is_dir ? "/" CLR_RESET : "");
}
}
static void handle_command(const char* line)
{
if (!line || line[0] != '/') return;
// /quit —— 设置退出标志,让控制流自然回到 main 末尾
if (std::strcmp(line, "/quit") == 0 || std::strcmp(line, "/q") == 0) {
g_quit_requested = true;
std::printf("再见!\n");
return;
}
// /help
if (std::strcmp(line, "/help") == 0 || std::strcmp(line, "/h") == 0) {
print_help();
return;
}
// /clear
if (std::strcmp(line, "/clear") == 0) {
if (g_session) g_session->clear();
std::printf(CLR_GREEN "[OK] 会话已清空\n" CLR_RESET);
return;
}
// /context
if (std::strcmp(line, "/context") == 0) {
if (g_session) {
int count = 0;
g_session->history(&count);
int tokens = g_session->token_count();
std::printf(CLR_DIM "消息条数: " CLR_RESET "%d | "
CLR_DIM "Token 估算: " CLR_RESET "%d\n",
count, tokens);
}
return;
}
// /status —— 脱敏显示当前运行状态
if (std::strcmp(line, "/status") == 0) {
const char* provider = dstalk_config_get("ai.provider");
if (!provider) provider = "ai.deepseek";
const char* base_url = dstalk_config_get("api.base_url");
if (!base_url) base_url = "https://api.deepseek.com/v1";
const char* api_key = dstalk_config_get("api.api_key");
std::printf(" 模型: %s\n", g_current_model.empty() ? "(未设置)" : g_current_model.c_str());
std::printf(" base_url: %s\n", base_url ? base_url : "(未设置)");
std::printf(" api_key: %s\n", (api_key && api_key[0]) ? "已设置" : "未设置");
std::printf(" provider: %s\n", provider);
std::printf(" AI 服务: %s\n", g_ai ? "就绪" : "不可用");
std::printf(" Session 服务: %s\n", g_session ? "就绪" : "不可用");
std::printf(" File IO 服务: %s\n", g_file_io ? "就绪" : "不可用");
const dstalk_tools_service_t* tools = static_cast<const dstalk_tools_service_t*>(
dstalk_service_query("tools", 1));
std::printf(" Tools 服务: %s\n", tools ? "就绪" : "不可用");
return;
}
// /model <name>
if (std::strncmp(line, "/model ", 7) == 0) {
const char* model = line + 7;
while (*model == ' ') model++;
if (*model == '\0') {
std::printf(CLR_RED "[ERROR] /model 需要模型名\n" CLR_RESET);
return;
}
if (g_ai) {
g_ai->configure(nullptr, nullptr, nullptr, model, 0, 0.0);
g_current_model = model;
std::printf(CLR_GREEN "[OK] 模型已切换: %s\n" CLR_RESET, model);
} else {
std::printf(CLR_RED "[ERROR] AI 服务不可用\n" CLR_RESET);
}
return;
}
// /file list [path]
if (std::strcmp(line, "/file list") == 0 || std::strncmp(line, "/file list ", 11) == 0) {
const char* path = line + 10;
list_files(path);
return;
}
// /file show <path>
if (std::strncmp(line, "/file show ", 11) == 0) {
print_file(line + 11);
return;
}
// /file read <path>
if (std::strncmp(line, "/file read ", 11) == 0) {
print_file(line + 11);
return;
}
// /file write <path> <content...>
if (std::strncmp(line, "/file write ", 12) == 0) {
const char* rest = line + 12;
while (*rest == ' ') rest++;
const char* space = std::strchr(rest, ' ');
if (!space) {
std::printf(CLR_RED "[ERROR] 用法: /file write <path> <content>\n" CLR_RESET);
return;
}
std::string path(rest, space - rest);
const char* content = space + 1;
while (*content == ' ') content++;
if (g_file_io && g_file_io->write(path.c_str(), content) == 0) {
std::printf(CLR_GREEN "[OK] 已写入: %s\n" CLR_RESET, path.c_str());
} else {
std::printf(CLR_RED "[ERROR] 写入失败: %s\n" CLR_RESET, path.c_str());
}
return;
}
// /save <path>
if (std::strncmp(line, "/save ", 6) == 0) {
const char* path = line + 6;
while (*path == ' ') path++;
if (g_session && g_session->save(path) == 0) {
std::printf(CLR_GREEN "[OK] 会话已保存: %s\n" CLR_RESET, path);
} else {
std::printf(CLR_RED "[ERROR] 保存失败: %s\n" CLR_RESET, path);
}
return;
}
// /load <path>
if (std::strncmp(line, "/load ", 6) == 0) {
const char* path = line + 6;
while (*path == ' ') path++;
if (g_session && g_session->load(path) == 0) {
std::printf(CLR_GREEN "[OK] 会话已恢复: %s\n" CLR_RESET, path);
} else {
std::printf(CLR_RED "[ERROR] 恢复失败: %s\n" CLR_RESET, path);
}
return;
}
std::printf(CLR_RED "未知命令: %s (输入 /help 查看帮助)\n" CLR_RESET, line);
}
// ---- 流式回调 ----
static int on_stream_token(const char* token, void* userdata)
{
bool* first = static_cast<bool*>(userdata);
if (*first) {
std::printf(CLR_GREEN);
*first = false;
}
std::printf("%s", token);
std::fflush(stdout);
return 0;
}
// ---- 主程序 ----
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码支持
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
GetConsoleMode(hOut, &mode);
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
#endif
// ---- C1: batch 模式检测 ----
bool batch_mode = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--batch") == 0) {
batch_mode = true;
break;
}
}
#ifdef _WIN32
if (!batch_mode && _isatty(_fileno(stdin)) == 0) batch_mode = true;
#else
if (!batch_mode && isatty(fileno(stdin)) == 0) batch_mode = true;
#endif
// ---- B1: 安装 Ctrl+C 处理 ----
#ifdef _WIN32
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件
const char* config_path = nullptr;
if (argc >= 2) {
// 跳过 --batch 标志
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--batch") != 0) {
config_path = argv[i];
break;
}
}
}
if (!config_path) {
const char* default_configs[] = {"config.toml", nullptr};
for (int i = 0; default_configs[i]; i++) {
FILE* f = nullptr;
#ifdef _WIN32
fopen_s(&f, default_configs[i], "r");
#else
f = fopen(default_configs[i], "r");
#endif
if (f) {
fclose(f);
config_path = default_configs[i];
break;
}
}
}
// 初始化主机(加载配置 + 自动扫描 plugins/ 目录加载插件)
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, CLR_RED "[dstalk] 初始化失败\n" CLR_RESET);
return EXIT_INIT_FAIL;
}
// 查询插件服务
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
g_file_io = static_cast<const dstalk_file_io_service_t*>(dstalk_service_query("file_io", 1));
if (!g_ai) {
std::fprintf(stderr, CLR_RED "[dstalk] AI 服务未找到(请检查插件目录)\n" CLR_RESET);
}
if (!g_session) {
std::fprintf(stderr, CLR_RED "[dstalk] Session 服务未找到\n" CLR_RESET);
}
// 自动从配置加载 AI 设置
if (g_ai) {
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!model) model = "deepseek-v4-pro";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
g_current_model = model; // A1: 记录当前模型名
}
if (!batch_mode) {
std::printf("\n");
print_banner();
std::printf("\n");
}
char buffer[8192];
while (true) {
// B1: 检查退出标志
if (g_quit_requested) break;
// A1: 提示符带模型名batch 模式不打印)
if (!batch_mode) {
std::printf(CLR_CYAN "[%s] " CLR_RESET CLR_YELLOW "> " CLR_RESET,
g_current_model.empty() ? "?" : g_current_model.c_str());
std::fflush(stdout);
}
if (!std::fgets(buffer, sizeof(buffer), stdin)) break;
// C3: fgets 截断检测
if (!std::strchr(buffer, '\n') && !feof(stdin)) {
std::fprintf(stderr, CLR_RED "[ERROR] 输入超过 8KB已截断。建议用文件方式dstalk --batch < file.txt\n" CLR_RESET);
int c;
while ((c = std::fgetc(stdin)) != '\n' && c != EOF) {}
}
// 去除末尾换行
size_t len = std::strlen(buffer);
while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\r')) {
buffer[--len] = '\0';
}
if (len == 0) continue;
// 命令处理
if (buffer[0] == '/') {
handle_command(buffer);
continue;
}
// AI 对话(通过插件服务 vtable
if (!g_ai || !g_session) {
std::printf(CLR_RED "[ERROR] AI 或 Session 服务不可用\n" CLR_RESET);
continue;
}
// 获取会话历史
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
bool first = true;
dstalk_chat_result_t result = g_ai->chat_stream(
history, history_count, buffer, on_stream_token, &first);
if (result.ok) {
std::printf(CLR_RESET "\n\n");
// 将用户消息和 AI 回复添加到会话
dstalk_message_t user_msg = {"user", buffer, nullptr, nullptr};
g_session->add(&user_msg);
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
} else {
// A3: error 路径下需 NULL 保护;当前只取 result.errorcontent 未涉及
std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
}
g_ai->free_result(&result);
}
// B2: 单一退出点dstalk_shutdown 只在此调用
dstalk_shutdown();
return EXIT_OK;
}

View File

@@ -1,132 +0,0 @@
#ifndef DSTALK_HOST_H
#define DSTALK_HOST_H
#include "dstalk_types.h"
#include "dstalk_services.h"
#ifdef __cplusplus
extern "C" {
#endif
// === 平台导出宏 ===
#ifndef DSTALK_API
#if defined(_WIN32)
#ifdef DSTALK_BUILD_DLL
#define DSTALK_API __declspec(dllexport)
#else
#define DSTALK_API __declspec(dllimport)
#endif
#else
#define DSTALK_API __attribute__((visibility("default")))
#endif
#endif
// === 插件导出宏 ===
#if defined(_WIN32)
#define DSTALK_PLUGIN_EXPORT __declspec(dllexport)
#else
#define DSTALK_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
// === API 版本 ===
#define DSTALK_API_VERSION 1
#define DSTALK_MAX_DEPS 8
// === 诊断 ===
typedef void (*dstalk_diag_cb)(int severity, const char* file,
int line, const char* func, const char* message);
#define DSTALK_ERROR_RETURN(expr, retval) do { \
if (!(expr)) { \
dstalk_log(DSTALK_LOG_ERROR, "[%s:%d] %s: assertion '%s' failed", \
__FILE__, __LINE__, __func__, #expr); \
return (retval); \
} \
} while(0)
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb);
// === 事件处理器 ===
typedef void (*dstalk_event_handler_fn)(int event_type, const void* data, void* userdata);
// === Host 提供给插件的 API 表 ===
typedef struct {
// 服务注册/查询
int (*register_service)(const char* name, int version, void* vtable);
void*(*query_service)(const char* name, int min_version);
// 事件
int (*event_subscribe)(int event_type, dstalk_event_handler_fn handler, void* userdata);
int (*event_emit)(int event_type, const void* data);
void (*event_unsubscribe)(int sub_id);
// 配置
const char* (*config_get)(const char* key);
int (*config_set)(const char* key, const char* value);
// 日志
void (*log)(int level, const char* fmt, ...);
// 内存
void* (*alloc)(size_t size);
void (*free)(void* ptr);
char* (*strdup)(const char* s);
} dstalk_host_api_t;
// === 插件信息结构 ===
typedef struct {
const char* name; // 插件名称(唯一标识)
const char* version; // 语义化版本号,如 "1.0.0"
const char* description; // 描述
int api_version; // 必须 == DSTALK_API_VERSION
// 依赖声明(以 NULL 结尾)
const char* dependencies[DSTALK_MAX_DEPS];
// 生命周期回调
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
// 事件处理(可选)
void (*on_event)(int event_type, const void* data);
} dstalk_plugin_info_t;
// === 插件入口函数 ===
typedef dstalk_plugin_info_t* (*dstalk_plugin_init_fn)(void);
// === Host 公共 API ===
// 初始化/销毁
DSTALK_API int dstalk_init(const char* config_path);
DSTALK_API void dstalk_shutdown(void);
// 插件管理
DSTALK_API int dstalk_plugin_load(const char* path);
DSTALK_API int dstalk_plugin_unload(int plugin_id);
DSTALK_API int dstalk_plugin_list(char** output_json);
// 服务查询
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version);
// 事件系统
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata);
DSTALK_API int dstalk_event_emit(int event_type, const void* data);
DSTALK_API void dstalk_event_unsubscribe(int subscription_id);
// 配置
DSTALK_API const char* dstalk_config_get(const char* key);
DSTALK_API int dstalk_config_set(const char* key, const char* value);
// 日志
DSTALK_API void dstalk_log(int level, const char* fmt, ...);
// 内存
DSTALK_API void* dstalk_alloc(size_t size);
DSTALK_API void dstalk_free(void* ptr);
DSTALK_API char* dstalk_strdup(const char* s);
#ifdef __cplusplus
}
#endif
#endif // DSTALK_HOST_H

View File

@@ -1,91 +0,0 @@
#ifndef DSTALK_LSP_H
#define DSTALK_LSP_H
#include "dstalk_host.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- LSP 服务器生命周期 ---- */
/*
* 启动语言服务器进程
* server_cmd: 命令字符串,例如 "clangd" 或 "pyright --stdio" 或完整路径
* language: 语言标识,例如 "c", "cpp", "python", "javascript", "rust"
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language);
/*
* 停止语言服务器
* 发送 shutdown 请求,然后发送 exit 通知
* 关闭管道,终止子进程
*/
DSTALK_API void dstalk_lsp_stop(void);
/* ---- 文档管理 ---- */
/*
* 在语言服务器中打开一个文档
* uri: 文件 URI例如 "file:///path/to/file.c"
* content: 文件内容文本
* language_id: 语言 ID例如 "c", "cpp", "python", "javascript"
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content,
const char* language_id);
/*
* 关闭语言服务器中的文档
* uri: 文件 URI
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_close(const char* uri);
/* ---- 查询操作 ---- */
/*
* 获取诊断信息 (编译错误、警告等)
* uri: 文件 URI
* output: 输出参数JSON 格式的诊断列表 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*
* JSON 输出格式示例:
* [
* {
* "range": { "start": {"line":0,"character":0}, "end":{"line":0,"character":5} },
* "severity": 1,
* "message": "error message"
* }
* ]
*/
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output);
/*
* 获取悬停信息 (类型、文档等)
* uri: 文件 URI
* line: 行号 (0-based)
* character: 列号 (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的悬停信息 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character,
char** output);
/*
* 获取代码补全建议
* uri: 文件 URI
* line: 行号 (0-based)
* character: 列号 (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的补全列表 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character,
char** output);
#ifdef __cplusplus
}
#endif
#endif /* DSTALK_LSP_H */

View File

@@ -1,97 +0,0 @@
#ifndef DSTALK_SERVICES_H
#define DSTALK_SERVICES_H
#include "dstalk_types.h"
#ifdef __cplusplus
extern "C" {
#endif
// === AI 服务 vtable (实际服务名由插件注册: "ai.deepseek" / "ai.anthropic") ===
typedef struct {
int (*configure)(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature);
dstalk_chat_result_t (*chat)(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
dstalk_chat_result_t (*chat_stream)(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_service_t;
// === Session 服务 (service name: "session") ===
typedef struct {
void (*add)(const dstalk_message_t* msg);
void (*clear)(void);
int (*save)(const char* path);
int (*load)(const char* path);
const dstalk_message_t* (*history)(int* out_count);
int (*token_count)(void);
} dstalk_session_service_t;
// === Context 服务 (service name: "context") ===
typedef struct {
size_t (*count_tokens)(const dstalk_message_t* msgs, int count);
int (*trim)(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens);
void (*set_max_tokens)(size_t max);
} dstalk_context_service_t;
// === HTTP 服务 (service name: "http") ===
typedef struct {
int (*post_json)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code);
int (*post_stream)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code);
} dstalk_http_service_t;
// === File IO 服务 (service name: "file_io") ===
typedef struct {
int (*read)(const char* path, char** content);
int (*write)(const char* path, const char* content);
} dstalk_file_io_service_t;
// === Config 服务 (service name: "config") ===
typedef struct {
const char* (*get)(const char* key);
int (*set)(const char* key, const char* value);
int (*load_file)(const char* path);
} dstalk_config_service_t;
// === Tools 服务 (service name: "tools") ===
typedef char* (*dstalk_tool_handler_fn)(const char* args_json);
typedef struct {
int (*register_tool)(const char* name, const char* desc,
const char* params_schema,
dstalk_tool_handler_fn handler);
void (*unregister_tool)(const char* name);
char* (*get_tools_json)(void);
char* (*execute)(const char* name, const char* args_json);
} dstalk_tools_service_t;
// === LSP 服务 (service name: "lsp") ===
typedef struct {
int (*start)(const char* server_cmd, const char* language);
void (*stop)(void);
int (*open_document)(const char* uri, const char* content, const char* lang_id);
int (*close_document)(const char* uri);
int (*get_diagnostics)(const char* uri, char** json_out);
int (*get_hover)(const char* uri, int line, int col, char** json_out);
int (*get_completion)(const char* uri, int line, int col, char** json_out);
} dstalk_lsp_service_t;
#ifdef __cplusplus
}
#endif
#endif // DSTALK_SERVICES_H

View File

@@ -1,52 +0,0 @@
#ifndef DSTALK_TYPES_H
#define DSTALK_TYPES_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// 消息结构(跨插件共享)
typedef struct {
const char* role; // "user", "assistant", "system", "tool"
const char* content; // 消息内容
const char* tool_call_id; // tool 响应时必填
const char* tool_calls_json;// assistant 返回的工具调用JSON 数组)
} dstalk_message_t;
// 聊天结果
typedef struct {
int ok;
const char* content; // dstalk_strdup 分配,调用方 dstalk_free
const char* error; // dstalk_strdup 分配
int http_status;
const char* tool_calls_json;// dstalk_strdup 分配
} dstalk_chat_result_t;
// 流式回调
typedef int (*dstalk_stream_cb)(const char* token, void* userdata);
// 事件类型
enum {
DSTALK_EVENT_MESSAGE = 1, // data = dstalk_message_t*
DSTALK_EVENT_SESSION_CLEAR,
DSTALK_EVENT_CONFIG_CHANGED,
DSTALK_EVENT_PLUGIN_LOADED, // data = plugin info JSON string
DSTALK_EVENT_PLUGIN_UNLOADED,
DSTALK_EVENT_CUSTOM = 1000, // 插件自定义事件起始值
};
// 日志级别
enum {
DSTALK_LOG_DEBUG = 0,
DSTALK_LOG_INFO = 1,
DSTALK_LOG_WARN = 2,
DSTALK_LOG_ERROR = 3,
};
#ifdef __cplusplus
}
#endif
#endif // DSTALK_TYPES_H

View File

@@ -1 +0,0 @@
#include <boost/json/src.hpp>

View File

@@ -1,83 +0,0 @@
#include "config_store.hpp"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
namespace dstalk {
int ConfigStore::load_file(const char* path)
{
if (!path) return -1;
std::ifstream file(path);
if (!file.is_open()) return -1;
std::stringstream ss;
ss << file.rdbuf();
std::string data = ss.str();
// 简易 TOML 解析:只处理 [section] 和 key = "value"
std::string current_section;
size_t pos = 0;
while (pos < data.size()) {
while (pos < data.size() && (data[pos] == ' ' || data[pos] == '\t'))
pos++;
if (pos >= data.size()) break;
size_t nl = data.find('\n', pos);
std::string line = (nl != std::string::npos)
? data.substr(pos, nl - pos) : data.substr(pos);
pos = (nl != std::string::npos) ? nl + 1 : data.size();
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
line.pop_back();
if (line.empty() || line[0] == '#') continue;
if (line[0] == '[' && line.back() == ']') {
current_section = line.substr(1, line.size() - 2);
continue;
}
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
while (!key.empty() && key.back() == ' ') key.pop_back();
if (key.empty()) continue;
std::string val = line.substr(eq + 1);
while (!val.empty() && (val.front() == ' ' || val.front() == '\t'))
val.erase(0, 1);
if (val.size() >= 2 && val.front() == '"' && val.back() == '"')
val = val.substr(1, val.size() - 2);
std::lock_guard<std::mutex> lock(mutex_);
std::string full_key = current_section.empty()
? key : current_section + "." + key;
data_[full_key] = val;
}
return 0;
}
const char* ConfigStore::get(const char* key) const
{
if (!key) return nullptr;
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
return it->second.c_str();
}
int ConfigStore::set(const char* key, const char* value)
{
if (!key || !value) return -1;
std::lock_guard<std::mutex> lock(mutex_);
data_[key] = value;
return 0;
}
} // namespace dstalk

View File

@@ -1,28 +0,0 @@
#pragma once
#include <mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
class ConfigStore {
public:
ConfigStore() = default;
~ConfigStore() = default;
// 从 TOML 文件加载配置
int load_file(const char* path);
// 获取配置值(返回内部指针,线程安全)
const char* get(const char* key) const;
// 设置配置值
int set(const char* key, const char* value);
private:
mutable std::mutex mutex_;
std::unordered_map<std::string, std::string> data_;
};
} // namespace dstalk

View File

@@ -1,39 +0,0 @@
#pragma once
#include <functional>
#include <mutex>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace dstalk {
using EventHandler = std::function<void(int event_type, const void* data)>;
class EventBus {
public:
EventBus() = default;
~EventBus() = default;
// 订阅事件返回订阅ID
int subscribe(int event_type, EventHandler handler);
// 取消订阅
void unsubscribe(int subscription_id);
// 发布事件
int emit(int event_type, const void* data);
private:
struct Subscription {
int id;
int event_type;
EventHandler handler;
};
mutable std::shared_mutex mutex_;
std::vector<Subscription> subscriptions_;
int next_id_ = 1;
};
} // namespace dstalk

View File

@@ -1,291 +0,0 @@
#include "plugin_loader.hpp"
#include <boost/json.hpp>
#ifdef _WIN32
#include <windows.h>
#else
#include <dlfcn.h>
#endif
#include <algorithm>
#include <queue>
#include <stdexcept>
namespace dstalk {
namespace json = boost::json;
PluginLoader::~PluginLoader()
{
shutdown_all();
}
int PluginLoader::load_plugin(const char* path)
{
if (!path) return -1;
// 加载DLL
#ifdef _WIN32
void* handle = LoadLibraryA(path);
#else
void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
#endif
if (!handle) {
return -1;
}
// 获取入口函数
#ifdef _WIN32
auto init_fn = (dstalk_plugin_init_fn)GetProcAddress(
(HMODULE)handle, "dstalk_plugin_init");
#else
auto init_fn = (dstalk_plugin_init_fn)dlsym(handle, "dstalk_plugin_init");
#endif
if (!init_fn) {
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 调用入口函数获取插件信息
dstalk_plugin_info_t* info = init_fn();
if (!info) {
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 检查API版本兼容性
if (info->api_version != DSTALK_API_VERSION) {
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 创建插件信息
int id = next_id_++;
PluginInfo plugin;
plugin.id = id;
plugin.name = info->name ? info->name : "";
plugin.version = info->version ? info->version : "";
plugin.description = info->description ? info->description : "";
plugin.api_version = info->api_version;
plugin.handle = handle;
plugin.info = info;
plugin.initialized = false;
// 解析依赖
for (int i = 0; i < DSTALK_MAX_DEPS && info->dependencies[i]; i++) {
plugin.dependencies.push_back(info->dependencies[i]);
}
plugins_[id] = std::move(plugin);
return id;
}
int PluginLoader::unload_plugin(int plugin_id)
{
auto it = plugins_.find(plugin_id);
if (it == plugins_.end()) return -1;
PluginInfo& plugin = it->second;
// 调用关闭回调
if (plugin.initialized && plugin.info->on_shutdown) {
plugin.info->on_shutdown();
}
// 卸载DLL
#ifdef _WIN32
FreeLibrary((HMODULE)plugin.handle);
#else
dlclose(plugin.handle);
#endif
plugins_.erase(it);
return 0;
}
std::string PluginLoader::list_plugins() const
{
json::array arr;
for (const auto& [id, plugin] : plugins_) {
json::object obj;
obj["id"] = id;
obj["name"] = plugin.name;
obj["version"] = plugin.version;
obj["description"] = plugin.description;
obj["api_version"] = plugin.api_version;
obj["initialized"] = plugin.initialized;
json::array deps;
for (const auto& dep : plugin.dependencies) {
deps.push_back(json::value(dep));
}
obj["dependencies"] = std::move(deps);
arr.push_back(std::move(obj));
}
return json::serialize(arr);
}
std::vector<int> PluginLoader::topological_sort() const
{
// 构建名称到ID的映射
std::unordered_map<std::string, int> name_to_id;
for (const auto& [id, plugin] : plugins_) {
name_to_id[plugin.name] = id;
}
// 计算入度
std::unordered_map<int, int> in_degree;
std::unordered_map<int, std::vector<int>> dependents;
for (const auto& [id, plugin] : plugins_) {
in_degree[id] = 0;
}
for (const auto& [id, plugin] : plugins_) {
for (const auto& dep_name : plugin.dependencies) {
auto it = name_to_id.find(dep_name);
if (it != name_to_id.end()) {
int dep_id = it->second;
dependents[dep_id].push_back(id);
in_degree[id]++;
}
}
}
// 拓扑排序Kahn算法
std::queue<int> queue;
for (const auto& [id, degree] : in_degree) {
if (degree == 0) {
queue.push(id);
}
}
std::vector<int> sorted;
while (!queue.empty()) {
int id = queue.front();
queue.pop();
sorted.push_back(id);
for (int dependent : dependents[id]) {
if (--in_degree[dependent] == 0) {
queue.push(dependent);
}
}
}
// 检查循环依赖
if (sorted.size() != plugins_.size()) {
throw std::runtime_error("Circular dependency detected");
}
return sorted;
}
int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
{
try {
std::vector<int> order = topological_sort();
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (plugin.initialized) continue;
if (plugin.info->on_init) {
int result = plugin.info->on_init(host_api);
if (result != 0) {
return -1;
}
}
plugin.initialized = true;
}
return 0;
} catch (const std::exception&) {
return -1;
}
}
int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
{
try {
std::vector<int> order = topological_sort();
int count = 0;
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (plugin.initialized) continue;
if (plugin.info->on_init) {
int result = plugin.info->on_init(host_api);
if (result != 0) {
return -1;
}
}
plugin.initialized = true;
count++;
}
return count;
} catch (const std::exception&) {
return -1;
}
}
void PluginLoader::shutdown_all()
{
// 按逆序关闭
std::vector<int> order;
try {
order = topological_sort();
std::reverse(order.begin(), order.end());
} catch (...) {
// 如果排序失败,按任意顺序关闭
for (const auto& [id, _] : plugins_) {
order.push_back(id);
}
}
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (!plugin.initialized) continue;
if (plugin.info->on_shutdown) {
plugin.info->on_shutdown();
}
plugin.initialized = false;
}
}
const PluginInfo* PluginLoader::get_plugin(int plugin_id) const
{
auto it = plugins_.find(plugin_id);
if (it == plugins_.end()) return nullptr;
return &it->second;
}
} // namespace dstalk

View File

@@ -1,57 +0,0 @@
#pragma once
#include "dstalk/dstalk_host.h"
#include <string>
#include <unordered_map>
#include <vector>
namespace dstalk {
struct PluginInfo {
int id;
std::string name;
std::string version;
std::string description;
int api_version;
std::vector<std::string> dependencies;
void* handle; // DLL handle
dstalk_plugin_info_t* info;
bool initialized;
};
class PluginLoader {
public:
PluginLoader() = default;
~PluginLoader();
// 加载插件返回插件ID失败返回-1
int load_plugin(const char* path);
// 卸载插件
int unload_plugin(int plugin_id);
// 获取插件列表JSON格式
std::string list_plugins() const;
// 按依赖顺序初始化所有插件
int initialize_all(const dstalk_host_api_t* host_api);
// 仅初始化尚未初始化的插件(增量加载场景)
int initialize_pending(const dstalk_host_api_t* host_api);
// 关闭所有插件
void shutdown_all();
// 获取插件信息
const PluginInfo* get_plugin(int plugin_id) const;
private:
// 拓扑排序(按依赖顺序)
std::vector<int> topological_sort() const;
std::unordered_map<int, PluginInfo> plugins_;
int next_id_ = 1;
};
} // namespace dstalk

View File

@@ -1,42 +0,0 @@
#include "service_registry.hpp"
namespace dstalk {
int ServiceRegistry::register_service(const char* name, int version, void* vtable)
{
if (!name || !vtable) return -1;
std::unique_lock<std::shared_mutex> lock(mutex_);
// 检查是否已注册
if (services_.find(name) != services_.end()) {
return -2; // 已存在
}
services_[name] = {name, version, vtable};
return 0;
}
void* ServiceRegistry::query_service(const char* name, int min_version) const
{
if (!name) return nullptr;
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = services_.find(name);
if (it == services_.end()) return nullptr;
if (it->second.version < min_version) return nullptr;
return it->second.vtable;
}
void ServiceRegistry::unregister_service(const char* name)
{
if (!name) return;
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.erase(name);
}
} // namespace dstalk

View File

@@ -1,35 +0,0 @@
#pragma once
#include <mutex>
#include <shared_mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
class ServiceRegistry {
public:
ServiceRegistry() = default;
~ServiceRegistry() = default;
// 注册服务
int register_service(const char* name, int version, void* vtable);
// 查询服务(返回 vtable 指针,或 nullptr
void* query_service(const char* name, int min_version) const;
// 注销服务
void unregister_service(const char* name);
private:
struct ServiceEntry {
std::string name;
int version;
void* vtable;
};
mutable std::shared_mutex mutex_;
std::unordered_map<std::string, ServiceEntry> services_;
};
} // namespace dstalk

23
dstalk_cli/CMakeLists.txt Normal file
View File

@@ -0,0 +1,23 @@
# ============================================================
# dstalk_cli — 命令行前端 (ANSI 转义码)
# ============================================================
add_executable(dstalk_cli
src/main.cpp
)
set_target_properties(dstalk_cli PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_cli
PRIVATE dstalk boost::boost dstalk_boost_config
)
# POSIX 平台需要 pthread (用于 std::thread spinner)
if(NOT WIN32)
find_package(Threads REQUIRED)
target_link_libraries(dstalk_cli PRIVATE Threads::Threads)
endif()

1001
dstalk_cli/src/main.cpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,19 @@
# ============================================================ # ============================================================
# dstalk-core DLL (插件宿主) # dstalk_core DLL (插件宿主)
# : / / 线 / # : / / 线 /
# ============================================================ # ============================================================
find_package(Boost REQUIRED CONFIG) find_package(Boost REQUIRED CONFIG)
find_package(OpenSSL REQUIRED CONFIG) find_package(OpenSSL REQUIRED CONFIG)
# Boost 编译宏 (header-only 模式)
add_library(dstalk_boost_config INTERFACE)
target_compile_definitions(dstalk_boost_config INTERFACE
BOOST_ALL_NO_LIB
BOOST_ERROR_CODE_HEADER_ONLY
BOOST_JSON_HEADER_ONLY
)
add_library(dstalk SHARED add_library(dstalk SHARED
src/host.cpp src/host.cpp
src/config_store.cpp src/config_store.cpp
@@ -20,8 +28,11 @@ target_include_directories(dstalk
PRIVATE src PRIVATE src
) )
target_compile_features(dstalk PUBLIC cxx_std_20)
target_link_libraries(dstalk target_link_libraries(dstalk
PRIVATE PRIVATE
dstalk_boost_config
boost::boost boost::boost
openssl::openssl openssl::openssl
) )
@@ -35,9 +46,6 @@ endif()
target_compile_definitions(dstalk target_compile_definitions(dstalk
PRIVATE PRIVATE
DSTALK_BUILD_DLL DSTALK_BUILD_DLL
BOOST_ALL_NO_LIB
BOOST_ERROR_CODE_HEADER_ONLY
BOOST_JSON_HEADER_ONLY
INTERFACE INTERFACE
DSTALK_USE_DLL DSTALK_USE_DLL
) )

View File

@@ -0,0 +1,164 @@
/**
* @file dstalk_host.h
* @brief Host API declarations: plugin lifecycle, service registry, event bus, config, logging, memory.
* 主机 API 声明:插件生命周期、服务注册表、事件总线、配置、日志、内存管理。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_HOST_H
#define DSTALK_HOST_H
#include "dstalk_types.h"
#include "dstalk_services.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 平台导出宏 / Platform export macros ---- */
#ifndef DSTALK_API
#if defined(_WIN32)
#ifdef DSTALK_BUILD_DLL
#define DSTALK_API __declspec(dllexport)
#else
#define DSTALK_API __declspec(dllimport)
#endif
#else
#define DSTALK_API __attribute__((visibility("default")))
#endif
#endif
/* ---- 插件导出宏 / Plugin export macro ---- */
#if defined(_WIN32)
#define DSTALK_PLUGIN_EXPORT __declspec(dllexport)
#else
#define DSTALK_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
/* ---- API 版本常量 / API version constants ---- */
#define DSTALK_API_VERSION 1 // 当前主机 API 版本,插件必须匹配 / current host API version plugins must match
#define DSTALK_MAX_DEPS 8 // 插件可声明的最大依赖项数量 / maximum dependency entries a plugin can declare
/* ---- 诊断回调 / Diagnostics callback ---- */
/* 主机调用此回调用于断言失败和内部诊断 / Called by the host for assertion failures and internal diagnostics */
typedef void (*dstalk_diag_cb)(int severity, const char* file,
int line, const char* func, const char* message);
/* 断言宏: 当 expr 为假时记录错误并返回 retval / Assertion macro: logs error and returns retval if expr is false */
#define DSTALK_ERROR_RETURN(expr, retval) do { \
if (!(expr)) { \
dstalk_log(DSTALK_LOG_ERROR, "[%s:%d] %s: assertion '%s' failed", \
__FILE__, __LINE__, __func__, #expr); \
return (retval); \
} \
} while(0)
/* 注册诊断回调用于内部错误报告 / Register a diagnostic callback for internal error reporting */
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb);
/* ---- 事件处理器类型 / Event handler type ---- */
/* 当已订阅的事件被触发时由主机调用 / Called by the host when a subscribed event is emitted */
typedef void (*dstalk_event_handler_fn)(int event_type, const void* data, void* userdata);
/* ---- 主机 API vtable (传递给插件的 on_init) / Host API vtable (passed to plugin's on_init) ---- */
typedef struct {
/* --- 服务注册表 / service registry --- */
int (*register_service)(const char* name, int version, void* vtable);
void*(*query_service)(const char* name, int min_version);
void (*unregister_service)(const char* name);
/* --- 事件总线 / event bus --- */
int (*event_subscribe)(int event_type, dstalk_event_handler_fn handler, void* userdata);
int (*event_emit)(int event_type, const void* data);
void (*event_unsubscribe)(int sub_id);
/* --- 配置管理 / configuration --- */
const char* (*config_get)(const char* key);
int (*config_set)(const char* key, const char* value);
/* --- 日志记录 / logging --- */
void (*log)(int level, const char* fmt, ...);
/* --- 内存管理 / memory management --- */
void* (*alloc)(size_t size);
void (*free)(void* ptr);
char* (*strdup)(const char* s);
} dstalk_host_api_t;
/* ---- 插件描述符 / Plugin descriptor ---- */
/* 每个插件通过 dstalk_plugin_init() 导出此结构体 / Every plugin exports this via dstalk_plugin_init() */
typedef struct {
const char* name; // 唯一插件标识符 / unique plugin identifier
const char* version; // 语义版本号,如 "1.0.0" / semantic version, e.g. "1.0.0"
const char* description; // 人类可读的描述信息 / human-readable description
int api_version; // 必须等于 DSTALK_API_VERSION / must equal DSTALK_API_VERSION
/* null-terminated 依赖插件名称列表 / null-terminated list of dependency plugin names */
const char* dependencies[DSTALK_MAX_DEPS];
/* 生命周期回调 / lifecycle callbacks */
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
/* 可选: 事件总线上每个事件通过时调用 / optional: called for every event passing through the bus */
void (*on_event)(int event_type, const void* data);
} dstalk_plugin_info_t;
/* ---- 插件入口点 / Plugin entry point ---- */
/* 每个共享库插件必须导出一个与此签名匹配的函数 / Every shared library plugin must export a function with this signature */
typedef dstalk_plugin_info_t* (*dstalk_plugin_init_fn)(void);
/* ========================================================================
* 主机公共 API / Host public API
* ======================================================================== */
/* 使用给定的配置文件路径初始化 dstalk 主机 / Initialize the dstalk host with the given config file path */
DSTALK_API int dstalk_init(const char* config_path);
/* 关闭主机: 卸载插件, 释放资源 / Shut down the host: unload plugins, free resources */
DSTALK_API void dstalk_shutdown(void);
/* 从共享库路径加载插件; 返回 plugin_id, 出错返回 -1 / Load a plugin from a shared library path; returns plugin_id or -1 on error */
DSTALK_API int dstalk_plugin_load(const char* path);
/* 按 id 卸载之前加载的插件 / Unload a previously loaded plugin by its id */
DSTALK_API int dstalk_plugin_unload(int plugin_id);
/* 将已加载插件信息的 JSON 数组写入 *output_json (调用方释放) / Write a JSON array of loaded plugin info to *output_json (caller frees) */
DSTALK_API int dstalk_plugin_list(char** output_json);
/* 按名称和最低版本号查找已注册的服务 vtable / Look up a registered service vtable by name and minimum version */
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version);
/* 为特定事件类型订阅处理器; 返回 subscription_id / Subscribe handler to a specific event type; returns subscription_id */
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata);
/* 向所有已订阅该类型事件的订阅者发送事件 / Emit an event to all subscribers of the given type */
DSTALK_API int dstalk_event_emit(int event_type, const void* data);
/* 按 id 移除订阅 / Remove a subscription by its id */
DSTALK_API void dstalk_event_unsubscribe(int subscription_id);
/* 通过键名获取配置值 (未找到返回 NULL) / Retrieve a config value by key (returns NULL if not found) */
DSTALK_API const char* dstalk_config_get(const char* key);
/* 设置配置键值对; 成功返回 0 / Set a config key/value pair; returns 0 on success */
DSTALK_API int dstalk_config_set(const char* key, const char* value);
/* 以给定严重等级记录日志消息 / Log a message at the given severity level */
DSTALK_API void dstalk_log(int level, const char* fmt, ...);
/* 使用主机的内存分配器分配内存 / Allocate memory using the host's allocator */
DSTALK_API void* dstalk_alloc(size_t size);
/* 释放之前由主机分配的内存 / Free memory previously allocated by the host */
DSTALK_API void dstalk_free(void* ptr);
/* 使用主机的内存分配器复制 C 字符串 / Duplicate a C-string using the host's allocator */
DSTALK_API char* dstalk_strdup(const char* s);
#ifdef __cplusplus
}
#endif
#endif // DSTALK_HOST_H

View File

@@ -0,0 +1,98 @@
/**
* @file dstalk_lsp.h
* @brief Convenience C API for Language Server Protocol operations (delegates to "lsp" plugin).
* LSP语言服务器协议操作的便捷 C API委托给 "lsp" 插件)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_LSP_H
#define DSTALK_LSP_H
#include "dstalk_host.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- LSP 服务器生命周期 / LSP Server Lifecycle ---- */
/*
* 启动语言服务器进程 / Start the language server process
* server_cmd: 命令字符串,例如 "clangd" 或 "pyright --stdio" 或完整路径 / command string, e.g. "clangd" or "pyright --stdio" or full path
* language: 语言标识,例如 "c", "cpp", "python", "javascript", "rust" / language identifier, e.g. "c", "cpp", "python", "javascript", "rust"
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language);
/*
* 停止语言服务器 / Stop the language server
* 发送 shutdown 请求,然后发送 exit 通知 / sends shutdown request, then exit notification
* 关闭管道,终止子进程 / closes pipes, terminates child process
*/
DSTALK_API void dstalk_lsp_stop(void);
/* ---- 文档管理 / Document Management ---- */
/*
* 在语言服务器中打开一个文档 / Open a document in the language server
* uri: 文件 URI例如 "file:///path/to/file.c" / file URI, e.g. "file:///path/to/file.c"
* content: 文件内容文本 / file content text
* language_id: 语言 ID例如 "c", "cpp", "python", "javascript" / language ID, e.g. "c", "cpp", "python", "javascript"
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content,
const char* language_id);
/*
* 关闭语言服务器中的文档 / Close a document in the language server
* uri: 文件 URI / file URI
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_close(const char* uri);
/* ---- 查询操作 / Query Operations ---- */
/*
* 获取诊断信息 (编译错误、警告等) / Get diagnostics (build errors, warnings, etc.)
* uri: 文件 URI / file URI
* output: 输出参数JSON 格式的诊断列表 (调用方通过 dstalk_free 释放) / output param, JSON list of diagnostics (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*
* JSON 输出格式示例 / JSON output format example:
* [
* {
* "range": { "start": {"line":0,"character":0}, "end":{"line":0,"character":5} },
* "severity": 1,
* "message": "error message"
* }
* ]
*/
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output);
/*
* 获取悬停信息 (类型、文档等) / Get hover info (type, documentation, etc.)
* uri: 文件 URI / file URI
* line: 行号 (0-based) / line number (0-based)
* character: 列号 (0-based, UTF-16 code units) / column number (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的悬停信息 (调用方通过 dstalk_free 释放) / output param, JSON hover info (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character,
char** output);
/*
* 获取代码补全建议 / Get code completion suggestions
* uri: 文件 URI / file URI
* line: 行号 (0-based) / line number (0-based)
* character: 列号 (0-based, UTF-16 code units) / column number (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的补全列表 (调用方通过 dstalk_free 释放) / output param, JSON completion list (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character,
char** output);
#ifdef __cplusplus
}
#endif
#endif /* DSTALK_LSP_H */

View File

@@ -0,0 +1,180 @@
/**
* @file dstalk_services.h
* @brief Service vtable definitions for all plugin-provided services (AI, Session, HTTP, etc.).
* 所有插件提供的服务 vtable 定义AI、会话、HTTP 等)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_SERVICES_H
#define DSTALK_SERVICES_H
#include "dstalk_types.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- AI 服务 vtable / AI service vtable ---- */
/* 以名称如 "ai_openai" 或 "ai_anthropic" 注册 / Registered under names such as "ai_openai" or "ai_anthropic" */
typedef struct {
/* 配置服务商连接 (base_url, api_key, model 等) / Configure provider connection (base_url, api_key, model, etc.) */
int (*configure)(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature);
/* 发送单轮聊天补全请求 (阻塞) / Send a single-turn chat completion (blocking) */
dstalk_chat_result_t (*chat)(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
/* 通过回调实现流式令牌传输的聊天补全 / Send a chat completion with streaming tokens via callback */
dstalk_chat_result_t (*chat_stream)(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
/* 释放 dstalk_chat_result_t 持有的资源 / Free resources held by a dstalk_chat_result_t */
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_service_t;
/* ---- AI endpoint manager 服务 vtable / AI endpoint manager service vtable ---- */
/* 以服务名称 "ai_endpoint_mgr" 注册;用于按名称路由到多个 provider/model endpoint。 / Registered as "ai_endpoint_mgr"; routes named endpoints to provider/model configs. */
typedef struct {
/* 返回已配置 endpoint 数量 / Return configured endpoint count. */
int (*count)(void);
/* 返回 endpoint 列表 JSON调用方用 dstalk_free 释放 / Return endpoint list JSON; caller frees with dstalk_free. */
char* (*list_json)(void);
/* 获取当前 active endpoint 名称 / Get current active endpoint name.
返回的指针指向 thread_local 存储区,调用方不需要释放。该指针在同线程下一次
get_active 调用前或 active endpoint 状态变化前有效;跨线程并发调用各自拥有
独立的 thread_local 副本,互不干扰。
The returned pointer points to thread-local storage; caller must not free it.
Valid until the next get_active call on the same thread or until the active
endpoint changes. Concurrent calls from different threads each have their own
independent thread-local copy. */
const char* (*get_active)(void);
/* 设置当前 active endpoint / Set current active endpoint. */
int (*set_active)(const char* endpoint_name);
/* 设置 endpoint 模型名endpoint_name 为空时修改 active endpoint / Set endpoint model; null endpoint_name updates active endpoint. */
int (*set_model)(const char* endpoint_name, const char* model);
/* 在指定 endpoint 上执行阻塞 chat / Run blocking chat on a named endpoint. */
dstalk_chat_result_t (*chat)(
const char* endpoint_name,
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
/* 在指定 endpoint 上执行流式 chat / Run streaming chat on a named endpoint. */
dstalk_chat_result_t (*chat_stream)(
const char* endpoint_name,
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
/* 释放 endpoint manager 返回的 chat result / Free chat result returned by endpoint manager. */
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_endpoint_mgr_t;
/* ---- 会话服务 vtable / Session service vtable ---- */
/* 以服务名称 "session" 注册 / Registered under service name "session" */
typedef struct {
/* 将消息追加到会话历史 / Append a message to the session history */
void (*add)(const dstalk_message_t* msg);
/* 清除会话历史中的所有消息 / Clear all messages from the session history */
void (*clear)(void);
/* 将会话历史保存到文件 (JSON); 成功返回 0 / Save session history to a file (JSON); returns 0 on success */
int (*save)(const char* path);
/* 从文件 (JSON) 加载会话历史; 成功返回 0 / Load session history from a file (JSON); returns 0 on success */
int (*load)(const char* path);
/* 获取完整消息历史; out_count 接收数组长度 / Get the full message history; out_count receives the array length */
const dstalk_message_t* (*history)(int* out_count);
/* 返回当前会话历史的近似令牌数 / Return the approximate token count of the current session history */
int (*token_count)(void);
} dstalk_session_service_t;
/* ---- 上下文服务 vtable / Context service vtable ---- */
/* 以服务名称 "context" 注册 / Registered under service name "context" */
typedef struct {
/* 计算消息数组中近似的令牌数 / Count approximate tokens in an array of messages */
size_t (*count_tokens)(const dstalk_message_t* msgs, int count);
/* 裁剪消息历史以适应 max_tokens; out/out_count 为新分配 / Trim message history to fit within max_tokens; out/out_count are newly allocated */
int (*trim)(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens);
} dstalk_context_service_t;
/* ---- HTTP 服务 vtable / HTTP service vtable ---- */
/* 以服务名称 "http" 注册 / Registered under service name "http" */
typedef struct {
/* POST JSON 体到主机; 返回响应体和 HTTP 状态码 / POST JSON body to a host; returns response body and HTTP status code */
int (*post_json)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code);
/* POST 带流式响应; 令牌通过回调传递 / POST with streaming response; tokens are delivered via callback */
int (*post_stream)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code);
} dstalk_http_service_t;
/* ---- 文件 I/O 服务 vtable / File I/O service vtable ---- */
/* 以服务名称 "file_io" 注册 / Registered under service name "file_io" */
typedef struct {
/* 读取整个文件内容到 *content; 成功返回 0 / Read entire file content into *content; returns 0 on success */
int (*read)(const char* path, char** content);
/* 将内容写入文件 (覆盖已有文件); 成功返回 0 / Write content to a file (overwrites if exists); returns 0 on success */
int (*write)(const char* path, const char* content);
} dstalk_file_io_service_t;
/* ---- 配置服务 vtable / Config service vtable ---- */
/* 以服务名称 "config" 注册 / Registered under service name "config" */
typedef struct {
/* 通过键名获取配置值; 未找到返回 NULL / Get a config value by key; returns NULL if not found */
const char* (*get)(const char* key);
/* 设置配置键值对; 成功返回 0 / Set a config key/value pair; returns 0 on success */
int (*set)(const char* key, const char* value);
/* 从 JSON 配置文件加载并合并键值对 / Load and merge key/value pairs from a JSON config file */
int (*load_file)(const char* path);
} dstalk_config_service_t;
/* ---- 工具服务 vtable / Tools service vtable ---- */
/* 以服务名称 "tools" 注册 / Registered under service name "tools" */
/* 已注册工具被调用时触发的处理器; 接收 JSON 参数, 返回 JSON 结果 / Handler invoked when a registered tool is called; receives JSON args, returns JSON result */
typedef char* (*dstalk_tool_handler_fn)(const char* args_json);
typedef struct {
/* 注册工具,包含名称、描述和 JSON Schema 参数 / Register a tool with name, description, and JSON Schema parameters */
int (*register_tool)(const char* name, const char* desc,
const char* params_schema,
dstalk_tool_handler_fn handler);
/* 取消注册之前注册的工具 / Unregister a previously registered tool */
void (*unregister_tool)(const char* name);
/* 获取所有已注册工具为 JSON 数组 (OpenAI 工具格式) / Get all registered tools as a JSON array (OpenAI tool format) */
char* (*get_tools_json)(void);
/* 按名称执行已注册工具,传入 JSON 参数 / Execute a registered tool by name with the given JSON arguments */
char* (*execute)(const char* name, const char* args_json);
} dstalk_tools_service_t;
/* ---- LSP 服务 vtable / LSP service vtable ---- */
/* 以服务名称 "lsp" 注册 / Registered under service name "lsp" */
typedef struct {
/* 启动指定语言的 LSP 服务器进程 / Start an LSP server process for the given language */
int (*start)(const char* server_cmd, const char* language);
/* 停止 LSP 服务器并清理资源 / Stop the LSP server and clean up resources */
void (*stop)(void);
/* 在 LSP 服务器中打开文档 / Open a document in the LSP server */
int (*open_document)(const char* uri, const char* content, const char* lang_id);
/* 在 LSP 服务器中关闭文档 / Close a document in the LSP server */
int (*close_document)(const char* uri);
/* 获取文档的诊断信息 (错误、警告) 以 JSON 格式返回 / Retrieve diagnostics (errors, warnings) for a document as JSON */
int (*get_diagnostics)(const char* uri, char** json_out);
/* 获取指定位置的悬停信息以 JSON 格式返回 / Retrieve hover information at a given position as JSON */
int (*get_hover)(const char* uri, int line, int col, char** json_out);
/* 获取指定位置的代码补全建议以 JSON 格式返回 / Retrieve code completion suggestions at a given position as JSON */
int (*get_completion)(const char* uri, int line, int col, char** json_out);
} dstalk_lsp_service_t;
#ifdef __cplusplus
}
#endif
#endif // DSTALK_SERVICES_H

View File

@@ -0,0 +1,59 @@
/**
* @file dstalk_types.h
* @brief Shared data types used across the dstalk host and all plugins.
* 跨主机和所有插件共享的数据类型定义。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_TYPES_H
#define DSTALK_TYPES_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 所有插件共享的消息结构体 / Shared message structure used across plugins */
typedef struct {
const char* role; // 角色标识 / Role identifier ("user", "assistant", "system", "tool")
const char* content; // 消息正文文本 / Message body text
const char* tool_call_id; // 工具调用响应消息所需 / Required for tool response messages
const char* tool_calls_json;// 助手工具调用的 JSON 数组 / JSON array of tool calls from assistant
} dstalk_message_t;
/* 聊天/补全调用返回的结果 / Result returned from a chat / completion call */
typedef struct {
int ok; // 0=失败, 非零=成功 / 0 = failure, non-zero = success
const char* content; // dstalk_strdup 分配; 调用方用 dstalk_free 释放 / allocated by dstalk_strdup; caller frees with dstalk_free
const char* error; // dstalk_strdup 分配; 成功时为 NULL / allocated by dstalk_strdup; NULL on success
int http_status; // 服务商返回的 HTTP 状态码 / HTTP status code from the provider
const char* tool_calls_json;// dstalk_strdup 分配; 工具调用的 JSON 数组 / allocated by dstalk_strdup; JSON array of tool calls
} dstalk_chat_result_t;
/* 流式令牌回调: 返回非零值提前中止流传输 / Streaming token callback: return non-zero to abort the stream early */
typedef int (*dstalk_stream_cb)(const char* token, void* userdata);
/* 事件类型代码 (匿名枚举) / Event type codes (anonymous enum) */
enum {
DSTALK_EVENT_MESSAGE = 1, // 数据为 dstalk_message_t* / data = dstalk_message_t*
DSTALK_EVENT_SESSION_CLEAR, // 会话历史已清除 / session history cleared
DSTALK_EVENT_CONFIG_CHANGED, // 配置键/值已更新 / configuration key/value updated
DSTALK_EVENT_PLUGIN_LOADED, // 数据为插件信息 JSON 字符串 / data = plugin info JSON string
DSTALK_EVENT_PLUGIN_UNLOADED, // 插件已卸载 / plugin unloaded
DSTALK_EVENT_CUSTOM = 1000, // 插件自定义事件的基础值 / base value for plugin-defined custom events
};
/* 日志严重等级 (匿名枚举) / Log severity levels (anonymous enum) */
enum {
DSTALK_LOG_DEBUG = 0, // 详细调试消息 / verbose debug messages
DSTALK_LOG_INFO = 1, // 信息性消息 / informational messages
DSTALK_LOG_WARN = 2, // 警告条件 / warning conditions
DSTALK_LOG_ERROR = 3, // 错误条件 / error conditions
};
#ifdef __cplusplus
}
#endif
#endif // DSTALK_TYPES_H

View File

@@ -0,0 +1,7 @@
/* @file boost_json.cpp
* @brief Boost.JSON header-only library compilation unit (single TU inclusion).
* Boost.JSON 仅头文件库的编译单元(单翻译单元包含)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include <boost/json/src.hpp>

View File

@@ -0,0 +1,75 @@
/* @file config_store.cpp
* @brief ConfigStore implementation: TOML parsing, thread-safe get/set with thread-local safety.
* ConfigStore 实现TOML 解析、线程安全的 get/set基于 thread-local 安全机制)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "config_store.hpp"
#include "../../plugins_base/config/include/toml_parse.h"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
namespace dstalk {
// 在互斥锁下加载并解析 TOML 文件到键值存储 / Load and parse a TOML file into the key-value store under mutex.
int ConfigStore::load_file(const char* path)
{
if (!path) return -1;
std::ifstream file(path);
if (!file.is_open()) return -1;
std::stringstream ss;
ss << file.rdbuf();
std::string data = ss.str();
// W12.2: 使用共享 TOML 解析器(从 config_plugin.cpp 去重) / Use shared TOML parser (de-duplicated from config_plugin.cpp)
toml::parse(data, [this](const std::string& key, const std::string& value) {
std::lock_guard<std::mutex> lock(mutex_);
data_[key] = value;
});
return 0;
}
// 检索配置值,返回线程本地副本以避免 c_str() 悬空 / Retrieve config value, returning a thread-local copy to avoid dangling c_str().
const char* ConfigStore::get(const char* key) const
{
if (!key) return nullptr;
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
// W12.2: 在释放锁之前复制到线程本地缓冲区 /
// Copy to thread-local buffer before releasing lock.
// 防止当并发 set() 触发 std::string 重新分配时 c_str() 悬空 /
// Prevents c_str() dangling when concurrent set() on the same key
// triggers std::string reallocation (W11.2 audit Finding 3).
thread_local std::string tls_cached;
tls_cached = it->second;
return tls_cached.c_str();
}
// 以 std::string 值类型检索配置(安全的值副本)/ Retrieve config value as an owned std::string (safe by-value copy).
std::string ConfigStore::get_copy(const char* key) const
{
if (!key) return {};
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return {};
return it->second; // 在锁下复制构造,始终安全 / copy-constructed under lock, always safe
}
// 在锁下设置配置键值对 / Set a config key-value pair under lock.
int ConfigStore::set(const char* key, const char* value)
{
if (!key || !value) return -1;
std::lock_guard<std::mutex> lock(mutex_);
data_[key] = value;
return 0;
}
} // namespace dstalk

View File

@@ -0,0 +1,47 @@
/* @file config_store.hpp
* @brief Thread-safe key-value configuration store with TOML file loading.
* 线程安全键值配置存储,支持 TOML 文件加载。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
// 线程安全的键值存储,支持 TOML 配置文件 / Thread-safe key-value store backed by TOML config files.
// 通过 mutex_ 支持并发读取get() 返回线程本地缓冲区 / Supports concurrent reads via mutex_ and returns thread-local buffers from get().
class ConfigStore {
public:
ConfigStore() = default;
~ConfigStore() = default;
// 从 TOML 文件加载键值对 / Load key-value pairs from a TOML file.
// 成功返回 0文件未找到或路径为空返回 -1 / Returns 0 on success, -1 if file not found or path is null.
int load_file(const char* path);
// 获取配置值(返回内部指针,线程安全)/ Get config value (returns internal pointer, thread-safe).
// W12.2: 返回的指针现在由线程局部副本支持,对其他线程对同一键的并发 set() 安全 /
// Returned pointer is now backed by a thread-local copy;
// safe against concurrent set() on the same key from other threads.
// 调用者仍应立即使用 — 同一线程上的下一次 get() 将覆盖缓冲区 /
// Caller should still consume immediately — next get() on same
// thread will overwrite the buffer.
const char* get(const char* key) const;
// 获取配置项的安全值副本(无悬空风险)/ Get a safe by-value copy of a config entry (no dangling risk).
// 如果键未找到,返回空字符串 / Returns empty string if key not found.
std::string get_copy(const char* key) const;
// 设置配置值 / Set config value. 成功返回 0参数为空返回 -1 / Returns 0 on success, -1 on null arguments.
int set(const char* key, const char* value);
private:
mutable std::mutex mutex_; // 保护所有 data_ 访问 / Protects all data_ access
std::unordered_map<std::string, std::string> data_; // 配置键值存储 / Config key-value store
};
} // namespace dstalk

View File

@@ -1,9 +1,16 @@
/* @file event_bus.cpp
* @brief EventBus implementation: subscribe, unsubscribe, emit with reader-writer locking.
* EventBus subscribeunsubscribeemit
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "event_bus.hpp" #include "event_bus.hpp"
#include <algorithm> #include <algorithm>
namespace dstalk { namespace dstalk {
// 为给定事件类型注册处理器,返回订阅 ID / Register a handler for the given event type, returning a subscription id.
int EventBus::subscribe(int event_type, EventHandler handler) int EventBus::subscribe(int event_type, EventHandler handler)
{ {
std::unique_lock<std::shared_mutex> lock(mutex_); std::unique_lock<std::shared_mutex> lock(mutex_);
@@ -12,6 +19,7 @@ int EventBus::subscribe(int event_type, EventHandler handler)
return id; return id;
} }
// 通过 ID 移除订阅(如果 ID 未找到则无操作)/ Remove a subscription by id (no-op if id not found).
void EventBus::unsubscribe(int subscription_id) void EventBus::unsubscribe(int subscription_id)
{ {
std::unique_lock<std::shared_mutex> lock(mutex_); std::unique_lock<std::shared_mutex> lock(mutex_);
@@ -23,6 +31,8 @@ void EventBus::unsubscribe(int subscription_id)
subscriptions_.end()); subscriptions_.end());
} }
// 在共享锁下将事件分发给所有匹配的订阅者 / Dispatch an event to all matching subscribers under a shared lock.
// 返回被调用的处理器数量 / Returns the count of handlers invoked.
int EventBus::emit(int event_type, const void* data) int EventBus::emit(int event_type, const void* data)
{ {
std::shared_lock<std::shared_mutex> lock(mutex_); std::shared_lock<std::shared_mutex> lock(mutex_);

View File

@@ -0,0 +1,50 @@
/* @file event_bus.hpp
* @brief Publish-subscribe event bus with shared_mutex for concurrent read access.
* 发布-订阅事件总线,使用 shared_mutex 支持并发读访问。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <functional>
#include <mutex>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace dstalk {
using EventHandler = std::function<void(int event_type, const void* data)>;
// 轻量级发布-订阅事件总线 / Lightweight pub-sub event bus.
// 读取者使用 shared_lockemit因此多个处理器可以并发分发
// 写入者使用 unique_locksubscribe / unsubscribe
// Readers use shared_lock (emit) so multiple handlers can be dispatched
// concurrently; writers use unique_lock (subscribe / unsubscribe).
class EventBus {
public:
EventBus() = default;
~EventBus() = default;
// 订阅事件返回订阅ID / Subscribe to an event, returning a subscription id
int subscribe(int event_type, EventHandler handler);
// 取消订阅 / Unsubscribe by subscription id
void unsubscribe(int subscription_id);
// 发布事件 / Emit an event to all matching subscribers
int emit(int event_type, const void* data);
private:
struct Subscription {
int id;
int event_type;
EventHandler handler;
};
mutable std::shared_mutex mutex_; // 读写锁emit 用 sharedsubscribe/unsubscribe 用 unique / RW lock: shared for emit, unique for subscribe/unsubscribe
std::vector<Subscription> subscriptions_; // emit 时线性扫描;对少量订阅者足够 / Linear scan on emit; ok for small subscriber counts
int next_id_ = 1; // 单调递增订阅 ID 计数器 / Monotonic subscription id counter
};
} // namespace dstalk

View File

@@ -1,9 +1,17 @@
/*
* @file host.cpp
* @brief Core host orchestrator: global singletons, dstalk_host_api_t instantiation, public C API, LSP delegation.
* dstalk_host_api_t C APILSP
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h" #include "dstalk/dstalk_host.h"
#include "config_store.hpp" #include "config_store.hpp"
#include "event_bus.hpp" #include "event_bus.hpp"
#include "service_registry.hpp" #include "service_registry.hpp"
#include "plugin_loader.hpp" #include "plugin_loader.hpp"
#include <atomic>
#include <cstdarg> #include <cstdarg>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
@@ -14,7 +22,7 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
// ============================================================ // ============================================================
// 全局主机上下文 // 全局主机上下文 / Global host context
// ============================================================ // ============================================================
namespace { namespace {
std::mutex g_init_mutex; std::mutex g_init_mutex;
@@ -24,10 +32,12 @@ namespace {
dstalk::EventBus* g_event_bus = nullptr; dstalk::EventBus* g_event_bus = nullptr;
dstalk::ServiceRegistry* g_service_registry = nullptr; dstalk::ServiceRegistry* g_service_registry = nullptr;
dstalk::PluginLoader* g_plugin_loader = nullptr; dstalk::PluginLoader* g_plugin_loader = nullptr;
static dstalk_diag_cb g_diag_callback = nullptr; static std::atomic<dstalk_diag_cb> g_diag_callback{nullptr};
// ---- 内部辅助 ---- // ---- 内部辅助 / Internal helpers ----
// 复制 C 字符串(用 malloc 分配,调用者必须用 api_free/free 释放)
// Duplicate a C string allocated with malloc (caller must free via api_free/free).
char* host_strdup(const char* s) { char* host_strdup(const char* s) {
if (!s) return nullptr; if (!s) return nullptr;
size_t len = strlen(s); size_t len = strlen(s);
@@ -36,6 +46,8 @@ namespace {
return copy; return copy;
} }
// 核心日志实现:格式化消息,写入 stderr并转发到诊断回调如果已设置
// Core logging implementation: formats message, writes to stderr, and forwards to diagnostic callback if set.
void host_log_impl(int level, const char* fmt, va_list args) { void host_log_impl(int level, const char* fmt, va_list args) {
const char* prefix = ""; const char* prefix = "";
switch (level) { switch (level) {
@@ -49,15 +61,18 @@ namespace {
va_copy(args_copy, args); va_copy(args_copy, args);
vfprintf(stderr, fmt, args); vfprintf(stderr, fmt, args);
fprintf(stderr, "\n"); fprintf(stderr, "\n");
// 转发到诊断回调 // 转发到诊断回调 / Forward to diagnostic callback
if (g_diag_callback) { auto cb = g_diag_callback.load(std::memory_order_acquire);
if (cb) {
char buf[1024]; char buf[1024];
vsnprintf(buf, sizeof(buf), fmt, args_copy); vsnprintf(buf, sizeof(buf), fmt, args_copy);
g_diag_callback(level, nullptr, 0, nullptr, buf); cb(level, nullptr, 0, nullptr, buf);
} }
va_end(args_copy); va_end(args_copy);
} }
// host_log_impl 的 printf 风格便捷包装。
// Convenience wrapper around host_log_impl for printf-style calls.
void host_log(int level, const char* fmt, ...) { void host_log(int level, const char* fmt, ...) {
va_list args; va_list args;
va_start(args, fmt); va_start(args, fmt);
@@ -65,16 +80,27 @@ namespace {
va_end(args); va_end(args);
} }
// ---- Host API 表回调 ---- // ---- Host API 表回调 / Host API table callbacks ----
// 将服务 vtable 按名称和版本注册到全局注册表。
// Register a service vtable with the given name and version into the global registry.
int api_register_service(const char* name, int version, void* vtable) { int api_register_service(const char* name, int version, void* vtable) {
return g_service_registry ? g_service_registry->register_service(name, version, vtable) : -1; return g_service_registry ? g_service_registry->register_service(name, version, vtable) : -1;
} }
// 按名称和最低版本从全局注册表查询服务 vtable。
// Query a service vtable by name and minimum version from the global registry.
void* api_query_service(const char* name, int min_version) { void* api_query_service(const char* name, int min_version) {
return g_service_registry ? g_service_registry->query_service(name, min_version) : nullptr; return g_service_registry ? g_service_registry->query_service(name, min_version) : nullptr;
} }
// Unregister a service by name from the global registry (no-op if name is null).
void api_unregister_service(const char* name) {
if (g_service_registry) g_service_registry->unregister_service(name);
}
// 通过全局事件总线订阅指定事件类型的处理函数。
// Subscribe a handler to a given event type via the global event bus.
int api_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata) { int api_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata) {
if (!g_event_bus || !handler) return -1; if (!g_event_bus || !handler) return -1;
return g_event_bus->subscribe(event_type, return g_event_bus->subscribe(event_type,
@@ -83,22 +109,32 @@ namespace {
}); });
} }
// 通过全局事件总路线程安全地向所有已注册处理函数发送事件。
// Emit an event to all registered handlers via the global event bus.
int api_event_emit(int event_type, const void* data) { int api_event_emit(int event_type, const void* data) {
return g_event_bus ? g_event_bus->emit(event_type, data) : -1; return g_event_bus ? g_event_bus->emit(event_type, data) : -1;
} }
// 通过订阅 ID 取消注册之前的事件处理函数。
// Unsubscribe a previously registered event handler by subscription ID.
void api_event_unsubscribe(int sub_id) { void api_event_unsubscribe(int sub_id) {
if (g_event_bus) g_event_bus->unsubscribe(sub_id); if (g_event_bus) g_event_bus->unsubscribe(sub_id);
} }
// 从全局配置存储中按键名读取配置值。
// Read a config value by key from the global config store.
const char* api_config_get(const char* key) { const char* api_config_get(const char* key) {
return g_config ? g_config->get(key) : nullptr; return g_config ? g_config->get(key) : nullptr;
} }
// 在全局配置存储中设置配置键值对。
// Set a config key/value pair in the global config store.
int api_config_set(const char* key, const char* value) { int api_config_set(const char* key, const char* value) {
return g_config ? g_config->set(key, value) : -1; return g_config ? g_config->set(key, value) : -1;
} }
// 主机端日志函数host_log_impl 的 varargs 包装)。
// Host-facing log function (varargs wrapper around host_log_impl).
void api_log(int level, const char* fmt, ...) { void api_log(int level, const char* fmt, ...) {
va_list args; va_list args;
va_start(args, fmt); va_start(args, fmt);
@@ -106,14 +142,20 @@ namespace {
va_end(args); va_end(args);
} }
// 内存分配包装 / Memory allocation wrapper (malloc).
void* api_alloc(size_t size) { return malloc(size); } void* api_alloc(size_t size) { return malloc(size); }
// 内存释放包装 / Memory free wrapper (free).
void api_free(void* ptr) { free(ptr); } void api_free(void* ptr) { free(ptr); }
// 字符串复制包装 / String duplication wrapper (host_strdup).
char* api_strdup(const char* s) { return host_strdup(s); } char* api_strdup(const char* s) { return host_strdup(s); }
// 传递给每个插件 on_init 的完整主机 API vtable。
// The complete host API vtable passed to every plugin's on_init.
dstalk_host_api_t g_host_api = { dstalk_host_api_t g_host_api = {
api_register_service, api_register_service,
api_query_service, api_query_service,
api_unregister_service,
api_event_subscribe, api_event_subscribe,
api_event_emit, api_event_emit,
api_event_unsubscribe, api_event_unsubscribe,
@@ -125,8 +167,12 @@ namespace {
api_strdup api_strdup
}; };
// ---- 插件目录扫描 ---- // ---- 插件目录扫描 / Plugin directory scanning ----
// 扫描目录中的插件 DLL 并通过 PluginLoader 加载。
// 返回加载的插件数量,出错返回 -1。
// Scan a directory for plugin DLLs and load them via PluginLoader.
// Returns the number of plugins loaded, or -1 on error.
int load_plugins_from_directory(const char* plugin_dir) { int load_plugins_from_directory(const char* plugin_dir) {
if (!plugin_dir) return -1; if (!plugin_dir) return -1;
@@ -161,9 +207,11 @@ namespace {
} }
// ============================================================ // ============================================================
// 公共 API // 公共 API / Public API
// ============================================================ // ============================================================
// 初始化 dstalk 主机:创建单例、加载配置、扫描插件、初始化所有插件。
// Initialize the dstalk host: create singletons, load config, scan plugins, initialize all plugins.
DSTALK_API int dstalk_init(const char* config_path) DSTALK_API int dstalk_init(const char* config_path)
{ {
std::lock_guard<std::mutex> lock(g_init_mutex); std::lock_guard<std::mutex> lock(g_init_mutex);
@@ -176,19 +224,35 @@ DSTALK_API int dstalk_init(const char* config_path)
g_service_registry = new dstalk::ServiceRegistry(); g_service_registry = new dstalk::ServiceRegistry();
g_plugin_loader = new dstalk::PluginLoader(); g_plugin_loader = new dstalk::PluginLoader();
// 加载配置 // 连接 PluginLoader 和 ServiceRegistry以便插件卸载时清理服务注册表 / Wire PluginLoader to ServiceRegistry for service cleanup on plugin unload
g_plugin_loader->set_service_registry(g_service_registry);
// 加载配置 / Load config
if (config_path && config_path[0]) { if (config_path && config_path[0]) {
if (g_config->load_file(config_path) != 0) { if (g_config->load_file(config_path) != 0) {
host_log(DSTALK_LOG_WARN, "Failed to load config: %s", config_path); host_log(DSTALK_LOG_WARN, "Failed to load config: %s", config_path);
} }
} }
// 扫描插件目录 // 扫描插件目录 / Scan plugin directories
const char* plugin_dir = g_config->get("plugin_dir"); // 优先扫描三级插件目录,回退到单一 plugins/ 目录(构建输出)
if (!plugin_dir) plugin_dir = "plugins"; // Prefer three-tier plugin dirs, fall back to single plugins/ dir (build output)
load_plugins_from_directory(plugin_dir); const char* dirs[] = {
"plugins_base", "plugins_middle", "plugins_upper",
"../plugins_base", "../plugins_middle", "../plugins_upper",
"plugins", "../plugins",
nullptr
};
int loaded = 0;
for (int i = 0; dirs[i]; ++i) {
int n = load_plugins_from_directory(dirs[i]);
if (n > 0) loaded += n;
}
if (loaded <= 0) {
host_log(DSTALK_LOG_WARN, "No plugins found in any plugin directory");
}
// 初始化所有插件 // 初始化所有插件 / Initialize all plugins
if (g_plugin_loader->initialize_all(&g_host_api) != 0) { if (g_plugin_loader->initialize_all(&g_host_api) != 0) {
host_log(DSTALK_LOG_WARN, "Some plugins failed to initialize"); host_log(DSTALK_LOG_WARN, "Some plugins failed to initialize");
} }
@@ -207,6 +271,8 @@ DSTALK_API int dstalk_init(const char* config_path)
} }
} }
// 关闭 dstalk 主机:关闭插件、销毁单例、释放资源。
// Shutdown the dstalk host: shutdown plugins, destroy singletons, release resources.
DSTALK_API void dstalk_shutdown(void) DSTALK_API void dstalk_shutdown(void)
{ {
std::lock_guard<std::mutex> lock(g_init_mutex); std::lock_guard<std::mutex> lock(g_init_mutex);
@@ -227,6 +293,8 @@ DSTALK_API void dstalk_shutdown(void)
g_initialized = false; g_initialized = false;
} }
// 从给定路径加载单个插件 DLL 并初始化。返回插件 ID失败返回 -1。
// Load a single plugin DLL from the given path and initialize it. Returns plugin ID or -1.
DSTALK_API int dstalk_plugin_load(const char* path) DSTALK_API int dstalk_plugin_load(const char* path)
{ {
if (!g_initialized || !g_plugin_loader) return -1; if (!g_initialized || !g_plugin_loader) return -1;
@@ -237,12 +305,16 @@ DSTALK_API int dstalk_plugin_load(const char* path)
return id; return id;
} }
// 按 ID 卸载插件:调用 on_shutdown卸载 DLL从注册表中移除。成功返回 0。
// Unload a plugin by ID: call on_shutdown, unload DLL, remove from registry. Returns 0 on success.
DSTALK_API int dstalk_plugin_unload(int plugin_id) DSTALK_API int dstalk_plugin_unload(int plugin_id)
{ {
if (!g_initialized || !g_plugin_loader) return -1; if (!g_initialized || !g_plugin_loader) return -1;
return g_plugin_loader->unload_plugin(plugin_id); return g_plugin_loader->unload_plugin(plugin_id);
} }
// 以 JSON 字符串列出所有已加载插件。调用者必须用 dstalk_free 释放 *output_json。
// List all loaded plugins as a JSON string. Caller must free *output_json with dstalk_free.
DSTALK_API int dstalk_plugin_list(char** output_json) DSTALK_API int dstalk_plugin_list(char** output_json)
{ {
if (!g_initialized || !g_plugin_loader || !output_json) return -1; if (!g_initialized || !g_plugin_loader || !output_json) return -1;
@@ -250,12 +322,16 @@ DSTALK_API int dstalk_plugin_list(char** output_json)
return *output_json ? 0 : -1; return *output_json ? 0 : -1;
} }
// 按名称和最低版本从全局服务注册表查询服务 vtable。
// Query a service vtable by name and minimum version from the global service registry.
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version) DSTALK_API void* dstalk_service_query(const char* service_name, int min_version)
{ {
if (!g_initialized || !g_service_registry) return nullptr; if (!g_initialized || !g_service_registry) return nullptr;
return g_service_registry->query_service(service_name, min_version); return g_service_registry->query_service(service_name, min_version);
} }
// 订阅回调到事件类型。返回订阅 ID失败返回 -1。
// Subscribe a callback to an event type. Returns a subscription ID or -1.
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata) DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata)
{ {
if (!g_initialized || !g_event_bus || !handler) return -1; if (!g_initialized || !g_event_bus || !handler) return -1;
@@ -263,30 +339,40 @@ DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn ha
[handler, userdata](int type, const void* data) { handler(type, data, userdata); }); [handler, userdata](int type, const void* data) { handler(type, data, userdata); });
} }
// 向订阅了该事件类型的所有处理函数发送事件。
// Emit an event to all handlers subscribed to the given event type.
DSTALK_API int dstalk_event_emit(int event_type, const void* data) DSTALK_API int dstalk_event_emit(int event_type, const void* data)
{ {
if (!g_initialized || !g_event_bus) return -1; if (!g_initialized || !g_event_bus) return -1;
return g_event_bus->emit(event_type, data); return g_event_bus->emit(event_type, data);
} }
// 按订阅 ID 取消注册之前的事件处理函数。
// Unsubscribe a previously registered event handler by subscription ID.
DSTALK_API void dstalk_event_unsubscribe(int subscription_id) DSTALK_API void dstalk_event_unsubscribe(int subscription_id)
{ {
if (!g_initialized || !g_event_bus) return; if (!g_initialized || !g_event_bus) return;
g_event_bus->unsubscribe(subscription_id); g_event_bus->unsubscribe(subscription_id);
} }
// 按键读取配置值。返回指向内部存储的指针(请勿释放)。
// Read a configuration value by key. Returns pointer to internal storage (do not free).
DSTALK_API const char* dstalk_config_get(const char* key) DSTALK_API const char* dstalk_config_get(const char* key)
{ {
if (!g_initialized || !g_config) return nullptr; if (!g_initialized || !g_config) return nullptr;
return g_config->get(key); return g_config->get(key);
} }
// 设置配置键值对。成功返回 0。
// Set a configuration key/value pair. Returns 0 on success.
DSTALK_API int dstalk_config_set(const char* key, const char* value) DSTALK_API int dstalk_config_set(const char* key, const char* value)
{ {
if (!g_initialized || !g_config) return -1; if (!g_initialized || !g_config) return -1;
return g_config->set(key, value); return g_config->set(key, value);
} }
// 在给定级别记录消息printf 风格)。写入 stderr 并转发到诊断回调。
// Log a message at the given level (printf-style). Writes to stderr and forwards to diag callback.
DSTALK_API void dstalk_log(int level, const char* fmt, ...) DSTALK_API void dstalk_log(int level, const char* fmt, ...)
{ {
va_list args; va_list args;
@@ -295,24 +381,33 @@ DSTALK_API void dstalk_log(int level, const char* fmt, ...)
va_end(args); va_end(args);
} }
// 通过 malloc 分配内存(为插件 ABI 一致性提供) / Allocate memory via malloc (provided for plugin ABI consistency).
DSTALK_API void* dstalk_alloc(size_t size) { return malloc(size); } DSTALK_API void* dstalk_alloc(size_t size) { return malloc(size); }
// 释放通过 dstalk_alloc 分配的内存(为插件 ABI 一致性提供) / Free memory allocated via dstalk_alloc (provided for plugin ABI consistency).
DSTALK_API void dstalk_free(void* ptr) { free(ptr); } DSTALK_API void dstalk_free(void* ptr) { free(ptr); }
// 使用 dstalk_alloc 复制 C 字符串(调用者必须 dstalk_free / Duplicate a C string using dstalk_alloc (caller must dstalk_free).
DSTALK_API char* dstalk_strdup(const char* s) { return host_strdup(s); } DSTALK_API char* dstalk_strdup(const char* s) { return host_strdup(s); }
// 注册接收所有日志消息的诊断回调(传入 null 可取消设置)。
// Register a diagnostic callback that receives all log messages (may be null to unset).
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb) { DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb) {
g_diag_callback = cb; g_diag_callback.store(cb, std::memory_order_release);
} }
// ============================================================ // ============================================================
// LSP 便捷函数 (委托给 "lsp" 服务插件) // LSP 便捷函数 (委托给 "lsp" 服务插件) / LSP convenience functions (delegated to "lsp" service plugin)
// ============================================================ // ============================================================
// 从全局服务注册表获取 "lsp" 服务 vtable不可用则返回 null。
// Retrieve the "lsp" service vtable from the global service registry, or null if unavailable.
static const dstalk_lsp_service_t* get_lsp_service() { static const dstalk_lsp_service_t* get_lsp_service() {
if (!g_initialized || !g_service_registry) return nullptr; if (!g_initialized || !g_service_registry) return nullptr;
return static_cast<const dstalk_lsp_service_t*>( return static_cast<const dstalk_lsp_service_t*>(
g_service_registry->query_service("lsp", 1)); g_service_registry->query_service("lsp", 1));
} }
// 为给定的命令和语言启动语言服务器进程。
// Start a language server process for the given command and language.
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language) DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
@@ -320,12 +415,16 @@ DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language)
return svc->start(server_cmd, language); return svc->start(server_cmd, language);
} }
// 停止当前正在运行的语言服务器进程。
// Stop the currently running language server process.
DSTALK_API void dstalk_lsp_stop(void) DSTALK_API void dstalk_lsp_stop(void)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
if (svc && svc->stop) svc->stop(); if (svc && svc->stop) svc->stop();
} }
// 在 LSP 服务器中打开文档以供分析didOpen 通知)。
// Open a document in the LSP server for analysis (didOpen notification).
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content, const char* language_id) DSTALK_API int dstalk_lsp_open(const char* uri, const char* content, const char* language_id)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
@@ -333,6 +432,8 @@ DSTALK_API int dstalk_lsp_open(const char* uri, const char* content, const char*
return svc->open_document(uri, content, language_id); return svc->open_document(uri, content, language_id);
} }
// 在 LSP 服务器中关闭文档didClose 通知)。
// Close a document in the LSP server (didClose notification).
DSTALK_API int dstalk_lsp_close(const char* uri) DSTALK_API int dstalk_lsp_close(const char* uri)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
@@ -340,6 +441,8 @@ DSTALK_API int dstalk_lsp_close(const char* uri)
return svc->close_document(uri); return svc->close_document(uri);
} }
// 检索文档的当前诊断信息。调用者必须用 dstalk_free 释放 *output。
// Retrieve current diagnostics for a document. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output) DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
@@ -347,6 +450,8 @@ DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output)
return svc->get_diagnostics(uri, output); return svc->get_diagnostics(uri, output);
} }
// 请求文档位置处的悬停信息。调用者必须用 dstalk_free 释放 *output。
// Request hover information at a document position. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character, char** output) DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character, char** output)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();
@@ -354,6 +459,8 @@ DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character, char**
return svc->get_hover(uri, line, character, output); return svc->get_hover(uri, line, character, output);
} }
// 请求文档位置处的补全项。调用者必须用 dstalk_free 释放 *output。
// Request completion items at a document position. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character, char** output) DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character, char** output)
{ {
auto* svc = get_lsp_service(); auto* svc = get_lsp_service();

View File

@@ -0,0 +1,617 @@
/*
* @file plugin_loader.cpp
* @brief PluginLoader implementation: DLL load/unload, path validation, Kahn topological sort, lifecycle management.
* PluginLoader 实现DLL 加载/卸载、路径验证、Kahn 拓扑排序、生命周期管理。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "plugin_loader.hpp"
#include "service_registry.hpp"
#include <boost/json.hpp>
#ifdef _WIN32
#include <windows.h>
#else
#include <dlfcn.h>
#endif
#include <algorithm>
#include <cctype>
#include <exception>
#include <filesystem>
#include <queue>
#include <stdexcept>
#include <unordered_set>
namespace dstalk {
namespace json = boost::json;
namespace fs = std::filesystem;
// 析构函数:调用 shutdown_all 释放所有插件并释放 DLL 句柄。
// Destructor: calls shutdown_all to release all plugins and free DLL handles.
PluginLoader::~PluginLoader()
{
shutdown_all();
}
// 加载插件 DLL验证路径扩展名、目录遍历、目录加载库
// 解析 dstalk_plugin_init验证 API 版本,解析依赖,分配 ID。
// Load a plugin DLL: validate path (extension, traversal, directory), load library,
// resolve dstalk_plugin_init, verify API version, parse dependencies, assign ID.
int PluginLoader::load_plugin(const char* path)
{
if (!path) return -1;
// === 路径验证 (F-18.3-3) / Path validation (F-18.3-3) ===
{
fs::path p = fs::absolute(fs::path(path)).lexically_normal();
// 扩展名检查(大小写不敏感) / Extension check (case-insensitive)
std::string ext = p.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
bool valid_ext = false;
#ifdef _WIN32
valid_ext = (ext == ".dll");
#elif defined(__APPLE__)
valid_ext = (ext == ".dylib" || ext == ".so");
#else
valid_ext = (ext == ".so");
#endif
if (!valid_ext) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': invalid extension '%s', expected .dll/.so/.dylib",
path, ext.c_str());
}
return -1;
}
// 目录遍历检查 / Directory traversal check
bool has_dotdot = false;
bool in_plugins_dir = false;
for (const auto& comp : p) {
if (comp == "..") {
has_dotdot = true;
break;
}
std::string comp_str = comp.string();
if (comp_str == "plugins" ||
comp_str.substr(0, 8) == "plugins_") {
in_plugins_dir = true;
}
}
if (has_dotdot) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': directory traversal rejected", path);
}
return -1;
}
// 目录约束:必须位于 'plugins' 或 'plugins_*' 目录下,或为纯文件名
// Directory constraint: must be under a 'plugins' or 'plugins_*' directory, or be a plain filename
if (!in_plugins_dir && p.has_parent_path()) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': path not under a 'plugins' directory", path);
}
return -1;
}
}
// 加载DLL / Load DLL
#ifdef _WIN32
void* handle = LoadLibraryA(path);
#else
void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
#endif
if (!handle) {
if (host_api_) {
#ifdef _WIN32
DWORD err = GetLastError();
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': LoadLibraryA failed (error %lu)", path, (unsigned long)err);
#else
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': dlopen failed: %s", path, dlerror());
#endif
}
return -1;
}
// 获取入口函数 / Resolve entry function
#ifdef _WIN32
auto init_fn = (dstalk_plugin_init_fn)GetProcAddress(
(HMODULE)handle, "dstalk_plugin_init");
#else
auto init_fn = (dstalk_plugin_init_fn)dlsym(handle, "dstalk_plugin_init");
#endif
if (!init_fn) {
if (host_api_) {
#ifdef _WIN32
DWORD err = GetLastError();
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': GetProcAddress(dstalk_plugin_init) failed (error %lu)",
path, (unsigned long)err);
#else
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': dlsym(dstalk_plugin_init) failed: %s",
path, dlerror());
#endif
}
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 调用入口函数获取插件信息 / Call entry function to get plugin info
dstalk_plugin_info_t* info = nullptr;
try {
info = init_fn();
} catch (const std::exception& e) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] %s: init_fn threw: %s", path, e.what());
} catch (...) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] %s: init_fn threw unknown exception", path);
}
if (!info) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': dstalk_plugin_init returned null", path);
}
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 检查API版本兼容性 / Check API version compatibility
if (info->api_version != DSTALK_API_VERSION) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] '%s': API version mismatch (got %d, expected %d)",
path, info->api_version, DSTALK_API_VERSION);
}
#ifdef _WIN32
FreeLibrary((HMODULE)handle);
#else
dlclose(handle);
#endif
return -1;
}
// 创建插件信息 / Create plugin info
int id = next_id_++;
PluginInfo plugin;
plugin.id = id;
plugin.name = info->name ? info->name : "";
plugin.version = info->version ? info->version : "";
plugin.description = info->description ? info->description : "";
plugin.api_version = info->api_version;
plugin.handle = handle;
plugin.info = info;
plugin.initialized = false;
// 解析依赖 / Parse dependencies
for (int i = 0; i < DSTALK_MAX_DEPS && info->dependencies[i]; i++) {
plugin.dependencies.push_back(info->dependencies[i]);
}
plugins_[id] = std::move(plugin);
return id;
}
// 按 ID 卸载插件:若已初始化则调用 on_shutdown释放 DLL 句柄,从 map 中移除。
// Unload a plugin by ID: call on_shutdown if initialized, free the DLL handle, erase from map.
int PluginLoader::unload_plugin(int plugin_id)
{
auto it = plugins_.find(plugin_id);
if (it == plugins_.end()) return -1;
PluginInfo& plugin = it->second;
// 调用关闭回调 / Call shutdown callback
if (plugin.initialized && plugin.info->on_shutdown) {
try {
plugin.info->on_shutdown();
} catch (const std::exception& e) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' on_shutdown threw: %s",
plugin.name.c_str(), e.what());
} catch (...) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' on_shutdown threw unknown exception",
plugin.name.c_str());
}
}
// 清理该插件在服务注册表中的条目,避免 dll 卸载后 vtable 悬空指针 / Clean up service registry entries to avoid dangling vtable pointers after DLL unload
if (service_registry_) {
auto svc_it = plugin_services_.find(plugin.name);
if (svc_it != plugin_services_.end()) {
for (const auto& svc_name : svc_it->second) {
service_registry_->unregister_service(svc_name.c_str());
}
plugin_services_.erase(svc_it);
}
}
// 卸载DLL / Unload DLL
#ifdef _WIN32
FreeLibrary((HMODULE)plugin.handle);
#else
dlclose(plugin.handle);
#endif
plugins_.erase(it);
return 0;
}
// 将所有已加载插件序列化为 JSON 数组字符串。
// Serialize all loaded plugins into a JSON array string.
std::string PluginLoader::list_plugins() const
{
json::array arr;
for (const auto& [id, plugin] : plugins_) {
json::object obj;
obj["id"] = id;
obj["name"] = plugin.name;
obj["version"] = plugin.version;
obj["description"] = plugin.description;
obj["api_version"] = plugin.api_version;
obj["initialized"] = plugin.initialized;
json::array deps;
for (const auto& dep : plugin.dependencies) {
deps.push_back(json::value(dep));
}
obj["dependencies"] = std::move(deps);
arr.push_back(std::move(obj));
}
return json::serialize(arr);
}
// 使用 Kahn 算法计算依赖顺序的插件 ID 列表。
// 若检测到循环依赖则抛出 std::runtime_error。
// Compute dependency-ordered plugin IDs using Kahn's algorithm.
// Throws std::runtime_error if a circular dependency is detected.
std::vector<int> PluginLoader::topological_sort() const
{
// 构建名称到ID的映射 / Build name-to-ID map
std::unordered_map<std::string, int> name_to_id;
for (const auto& [id, plugin] : plugins_) {
name_to_id[plugin.name] = id;
}
// 计算入度 / Calculate in-degrees
std::unordered_map<int, int> in_degree;
std::unordered_map<int, std::vector<int>> dependents;
for (const auto& [id, plugin] : plugins_) {
in_degree[id] = 0;
}
for (const auto& [id, plugin] : plugins_) {
for (const auto& dep_name : plugin.dependencies) {
auto it = name_to_id.find(dep_name);
if (it != name_to_id.end()) {
int dep_id = it->second;
dependents[dep_id].push_back(id);
in_degree[id]++;
}
}
}
// 拓扑排序Kahn算法 / Topological sort (Kahn's algorithm)
std::queue<int> queue;
for (const auto& [id, degree] : in_degree) {
if (degree == 0) {
queue.push(id);
}
}
std::vector<int> sorted;
while (!queue.empty()) {
int id = queue.front();
queue.pop();
sorted.push_back(id);
for (int dependent : dependents[id]) {
if (--in_degree[dependent] == 0) {
queue.push(dependent);
}
}
}
// 检查循环依赖 / Check for circular dependency
if (sorted.size() != plugins_.size()) {
throw std::runtime_error("Circular dependency detected");
}
return sorted;
}
// 验证依赖:检查缺失依赖和循环依赖。
// 成功返回 0发现错误返回 -1错误通过 host_api_ 记录)。
// Validate dependencies: checks for missing dependencies and circular dependencies.
// Returns 0 on success, -1 if any errors found (errors are logged via host_api_).
int PluginLoader::validate_dependencies() const
{
int error_count = 0;
// 构建名称到ID的映射 / Build name-to-ID map
std::unordered_map<std::string, int> name_to_id;
for (const auto& [id, plugin] : plugins_) {
name_to_id[plugin.name] = id;
}
// 检查1缺失依赖deps 引用的插件未加载) / Check 1: missing dependencies (deps reference plugins not loaded)
for (const auto& [id, plugin] : plugins_) {
for (const auto& dep_name : plugin.dependencies) {
if (name_to_id.find(dep_name) == name_to_id.end()) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] Plugin '%s': dependency '%s' not found (plugin not loaded)",
plugin.name.c_str(), dep_name.c_str());
}
error_count++;
}
}
}
// 检查2循环依赖拓扑排序失败 / Check 2: circular dependency (topological sort fails)
try {
topological_sort();
} catch (const std::runtime_error&) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
"[plugin_loader] Circular dependency detected among loaded plugins");
}
error_count++;
}
return error_count > 0 ? -1 : 0;
}
// 按依赖顺序初始化所有未初始化的插件。
// 无效依赖或失败初始化会标记插件名,避免级联失败。
// 返回初始化失败的插件数量,严重错误返回 -1。
// Initialize all uninitialized plugins in dependency order.
// Invalid dependencies or failed inits mark the plugin name, avoiding cascading failures.
// Returns the number of plugins that failed to initialize, or -1 on critical error.
int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
{
if (!host_api) return -1;
host_api_ = host_api;
// 依赖合法性校验log 错误但不 crash继续初始化流程
// Validate dependencies (log errors but don't crash, continue initialization)
if (validate_dependencies() != 0) {
host_api->log(DSTALK_LOG_WARN,
"[plugin_loader] Dependency validation failed; initialization may be incomplete");
}
try {
std::vector<int> order = topological_sort();
std::unordered_set<std::string> failed_names;
int failed_count = 0;
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (plugin.initialized) continue;
// 检查依赖是否已失败 / Check if dependency has already failed
bool dep_unavailable = false;
for (const auto& dep_name : plugin.dependencies) {
if (failed_names.count(dep_name)) {
dep_unavailable = true;
break;
}
}
if (dep_unavailable) {
host_api->log(DSTALK_LOG_WARN, "[plugin_loader] Plugin '%s' skipped: dependency unavailable",
plugin.name.c_str());
failed_names.insert(plugin.name);
failed_count++;
continue;
}
if (plugin.info->on_init) {
int result;
// 记录 on_init 前的服务名称快照,用于 on_init 后 diff 出该插件注册的服务 / Snapshot service names before on_init to discover newly registered services after
std::vector<std::string> before_svcs;
if (service_registry_) {
before_svcs = service_registry_->list_service_names();
}
try {
result = plugin.info->on_init(host_api);
} catch (const std::exception& e) {
host_api->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' init threw: %s",
plugin.name.c_str(), e.what());
failed_names.insert(plugin.name);
failed_count++;
continue;
} catch (...) {
host_api->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' init threw unknown exception",
plugin.name.c_str());
failed_names.insert(plugin.name);
failed_count++;
continue;
}
if (result != 0) {
host_api->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' init failed (code %d)",
plugin.name.c_str(), result);
failed_names.insert(plugin.name);
failed_count++;
continue;
}
// 将 on_init 期间新注册的服务归因到当前插件 / Attribute newly registered services to this plugin
if (service_registry_) {
std::vector<std::string> after_svcs = service_registry_->list_service_names();
std::unordered_set<std::string> before_set(before_svcs.begin(), before_svcs.end());
for (const auto& svc_name : after_svcs) {
if (before_set.find(svc_name) == before_set.end()) {
plugin_services_[plugin.name].push_back(svc_name);
}
}
}
}
plugin.initialized = true;
}
return failed_count;
} catch (const std::runtime_error&) {
// 循环依赖 / Circular dependency
return -1;
} catch (const std::exception&) {
return -1;
}
}
// 仅初始化尚未初始化的插件(用于增量/按需加载)。
// 返回新初始化的插件数量,失败返回 -1。
// Initialize only plugins that haven't been initialized yet (used for incremental/on-demand loading).
// Returns the number of newly initialized plugins, or -1 on failure.
int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
{
host_api_ = host_api;
try {
std::vector<int> order = topological_sort();
int count = 0;
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (plugin.initialized) continue;
if (plugin.info->on_init) {
int result;
// 记录 on_init 前的服务名称快照,用于 on_init 后 diff 出该插件注册的服务 / Snapshot service names before on_init to discover newly registered services after
std::vector<std::string> before_svcs;
if (service_registry_) {
before_svcs = service_registry_->list_service_names();
}
try {
result = plugin.info->on_init(host_api);
} catch (const std::exception& e) {
if (host_api) host_api->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' init threw: %s",
plugin.name.c_str(), e.what());
return -1;
} catch (...) {
if (host_api) host_api->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' init threw unknown exception",
plugin.name.c_str());
return -1;
}
if (result != 0) {
return -1;
}
// 将 on_init 期间新注册的服务归因到当前插件 / Attribute newly registered services to this plugin
if (service_registry_) {
std::vector<std::string> after_svcs = service_registry_->list_service_names();
std::unordered_set<std::string> before_set(before_svcs.begin(), before_svcs.end());
for (const auto& svc_name : after_svcs) {
if (before_set.find(svc_name) == before_set.end()) {
plugin_services_[plugin.name].push_back(svc_name);
}
}
}
}
plugin.initialized = true;
count++;
}
return count;
} catch (const std::exception&) {
return -1;
}
}
// 按逆依赖顺序关闭所有插件,然后释放所有 DLL 句柄并清空 map。
// Shutdown all plugins in reverse dependency order, then free all DLL handles and clear the map.
void PluginLoader::shutdown_all()
{
// 按逆序关闭 / Shutdown in reverse order
std::vector<int> order;
try {
order = topological_sort();
std::reverse(order.begin(), order.end());
} catch (...) {
// 如果排序失败,按任意顺序关闭 / If sorting fails, shutdown in arbitrary order
for (const auto& [id, _] : plugins_) {
order.push_back(id);
}
}
for (int id : order) {
auto it = plugins_.find(id);
if (it == plugins_.end()) continue;
PluginInfo& plugin = it->second;
if (plugin.initialized && plugin.info->on_shutdown) {
try {
plugin.info->on_shutdown();
} catch (const std::exception& e) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' shutdown threw: %s",
plugin.name.c_str(), e.what());
} catch (...) {
if (host_api_) host_api_->log(DSTALK_LOG_ERROR, "[plugin_loader] Plugin '%s' shutdown threw unknown exception",
plugin.name.c_str());
}
}
plugin.initialized = false;
}
// 清空服务注册表,避免后续 DLL 卸载后 vtable 悬空指针 / Clear the service registry to prevent dangling vtable pointers after DLL unload
if (service_registry_) {
service_registry_->clear();
}
plugin_services_.clear();
// 释放所有 DLL 句柄 / Free all DLL handles
for (auto& [id, plugin] : plugins_) {
if (plugin.handle) {
#ifdef _WIN32
FreeLibrary((HMODULE)plugin.handle);
#else
dlclose(plugin.handle);
#endif
plugin.handle = nullptr;
}
}
plugins_.clear();
}
// 按 ID 查找插件。返回 PluginInfo 指针,未找到则返回 nullptr。
// Look up a plugin by ID. Returns pointer to PluginInfo, or nullptr if not found.
const PluginInfo* PluginLoader::get_plugin(int plugin_id) const
{
auto it = plugins_.find(plugin_id);
if (it == plugins_.end()) return nullptr;
return &it->second;
}
} // namespace dstalk

View File

@@ -0,0 +1,86 @@
/*
* @file plugin_loader.hpp
* @brief DLL plugin loader with topological sort for dependency-ordered initialization.
* DLL 插件加载器,使用拓扑排序实现按依赖顺序初始化。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include "dstalk/dstalk_host.h"
#include <atomic>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dstalk {
class ServiceRegistry;
// 描述单个已加载插件标识、DLL 句柄、信息 vtable 和初始化状态。
// Describes a single loaded plugin: identity, DLL handle, info vtable, and init state.
struct PluginInfo {
int id;
std::string name;
std::string version;
std::string description;
int api_version;
std::vector<std::string> dependencies;
void* handle; // DLL 句柄 / DLL handle
dstalk_plugin_info_t* info;
bool initialized;
};
// 管理基于 DLL 的插件生命周期:加载、卸载、验证依赖、
// 拓扑排序初始化、关闭和 JSON 列表。
// Manages the lifecycle of DLL-based plugins: load, unload, validate dependencies,
// topological-sort initialization, shutdown, and JSON listing.
class PluginLoader {
public:
PluginLoader() = default;
~PluginLoader();
// 加载插件返回插件ID失败返回-1 / Load plugin (returns plugin ID, -1 on failure)
int load_plugin(const char* path);
// 卸载插件 / Unload plugin
int unload_plugin(int plugin_id);
// 获取插件列表JSON格式 / Get plugin list (JSON format)
std::string list_plugins() const;
// 按依赖顺序初始化所有插件 / Initialize all plugins in dependency order
int initialize_all(const dstalk_host_api_t* host_api);
// 仅初始化尚未初始化的插件(增量加载场景) / Initialize only uninitialized plugins (incremental loading scenario)
int initialize_pending(const dstalk_host_api_t* host_api);
// 关闭所有插件 / Shutdown all plugins
void shutdown_all();
// 获取插件信息 / Get plugin info
const PluginInfo* get_plugin(int plugin_id) const;
// 设置服务注册表引用,供卸载插件时清理服务 / Set service registry reference for service cleanup during plugin unload
void set_service_registry(ServiceRegistry* sr) { service_registry_ = sr; }
private:
// 拓扑排序(按依赖顺序) / Topological sort (by dependency order)
std::vector<int> topological_sort() const;
// 依赖合法性校验(缺失依赖 + 循环依赖),返回 0 成功 / -1 失败
// Validate dependencies (missing + circular), returns 0 success / -1 failure
int validate_dependencies() const;
std::unordered_map<int, PluginInfo> plugins_;
std::atomic<int> next_id_{1};
const dstalk_host_api_t* host_api_ = nullptr;
ServiceRegistry* service_registry_ = nullptr; // 用于卸载时清理服务 / for service cleanup during unload
// 插件名称 -> 该插件注册的服务名称列表 / plugin name -> list of service names registered by that plugin
std::unordered_map<std::string, std::vector<std::string>> plugin_services_;
};
} // namespace dstalk

View File

@@ -0,0 +1,70 @@
/* @file service_registry.cpp
* @brief ServiceRegistry implementation: register, query, unregister with reader-writer locking.
* ServiceRegistry 实现:基于读写锁的 register、query、unregister。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "service_registry.hpp"
namespace dstalk {
// 注册指定版本的命名服务 / Register a named service at a given version. 参数为空返回 -1已注册返回 -2 / Returns -1 on null args, -2 if already registered.
int ServiceRegistry::register_service(const char* name, int version, void* vtable)
{
if (!name || !vtable) return -1;
std::unique_lock<std::shared_mutex> lock(mutex_);
// 检查是否已注册 / Check if already registered
if (services_.find(name) != services_.end()) {
return -2; // 已存在 / already registered
}
services_[name] = {name, version, vtable};
return 0;
}
// 按名称和最低版本查询服务 / Query a service by name and minimum version. 返回 vtable 指针或 nullptr / Returns vtable pointer or nullptr if not found.
void* ServiceRegistry::query_service(const char* name, int min_version) const
{
if (!name) return nullptr;
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = services_.find(name);
if (it == services_.end()) return nullptr;
if (it->second.version < min_version) return nullptr;
return it->second.vtable;
}
// 注销指定名称的服务name 为空或未找到时无操作)/ Unregister a named service (no-op if name is null or not found).
void ServiceRegistry::unregister_service(const char* name)
{
if (!name) return;
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.erase(name);
}
// 列出当前所有已注册服务名称shared_lock/ List all currently registered service names (shared_lock).
std::vector<std::string> ServiceRegistry::list_service_names() const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
std::vector<std::string> names;
names.reserve(services_.size());
for (const auto& [name, _] : services_) {
names.push_back(name);
}
return names;
}
// 移除所有已注册服务unique_lock/ Remove all registered services (unique_lock).
void ServiceRegistry::clear()
{
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.clear();
}
} // namespace dstalk

View File

@@ -0,0 +1,53 @@
/* @file service_registry.hpp
* @brief Name-versioned service registry for decoupled plugin communication.
* 基于名称+版本的服务注册表,用于插件间解耦通信。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <mutex>
#include <shared_mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace dstalk {
// 名称 + 最低版本服务目录 / Name + minimum-version service directory.
// 插件注册 vtable消费者按名称和版本约束查询 /
// Plugins register vtables; consumers query by name and version constraint.
// 读取query使用 shared_lock写入register/unregister使用 unique_lock /
// Reads (query) use shared_lock; writes (register/unregister) use unique_lock.
class ServiceRegistry {
public:
ServiceRegistry() = default;
~ServiceRegistry() = default;
// 注册服务 / Register a named service at a given version
int register_service(const char* name, int version, void* vtable);
// 查询服务(返回 vtable 指针,或 nullptr/ Query a service by name and minimum version
void* query_service(const char* name, int min_version) const;
// 注销服务 / Unregister a named service
void unregister_service(const char* name);
// 列出所有已注册服务名称(用于 diff/遍历)/ List all currently registered service names (for diff / iteration)
std::vector<std::string> list_service_names() const;
// 清空所有注册服务 / Remove all registered services
void clear();
private:
struct ServiceEntry {
std::string name;
int version;
void* vtable;
};
mutable std::shared_mutex mutex_; // 读写锁query 用 sharedregister/unregister 用 unique / RW lock: shared for query, unique for register/unregister
std::unordered_map<std::string, ServiceEntry> services_;
};
} // namespace dstalk

View File

@@ -0,0 +1,17 @@
# ============================================================
# dstalk_frontend_common — 前端公共初始化静态库
# ============================================================
add_library(dstalk_frontend_common STATIC
src/frontend_common.cpp
)
target_include_directories(dstalk_frontend_common
PUBLIC include
)
target_link_libraries(dstalk_frontend_common
PUBLIC dstalk
)
target_compile_features(dstalk_frontend_common PUBLIC cxx_std_20)

View File

@@ -0,0 +1,66 @@
// ============================================================================
// dstalk_frontend_common — 前端公共初始化模块
// ============================================================================
// 提供所有前端CLI / GUI / Web共享的启动逻辑
// - 配置文件发现argv / 默认路径 / 平台 fopen
// - dstalk_init() 调用
// - 常用服务查询ai / endpoint_mgr / session / file_io / tools / context
// - AI 服务默认配置(从 config 读取,带 fallback
// ============================================================================
#ifndef DSTALK_FRONTEND_COMMON_HPP
#define DSTALK_FRONTEND_COMMON_HPP
#include <string>
#include "dstalk/dstalk_host.h"
struct FrontendServices {
const dstalk_ai_service_t* ai = nullptr;
const dstalk_session_service_t* session = nullptr;
const dstalk_file_io_service_t* file_io = nullptr;
const dstalk_tools_service_t* tools = nullptr;
const dstalk_ai_endpoint_mgr_t* endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
std::string provider; // "ai.deepseek" / "ai.openai" / "ai.anthropic"
std::string model; // e.g. "deepseek-v4-pro"
std::string base_url; // e.g. "https://api.deepseek.com/v1"
std::string api_key;
// 是否已成功初始化 dstalk 核心
bool initialized = false;
};
// ---- 前端公共初始化 ----
//
// 功能:
// 1. 发现配置文件:优先 argv[1](跳过已知标志),其次 default_config如 "config.toml"
// 2. 调用 dstalk_init(config_path)
// 3. 查询常用插件服务ai / endpoint_mgr / session / file_io / tools
// 4. 用 dstalk_config_get 读取 api.* 键并调用 ai->configure() 设置旧单 provider 默认值
//
// 参数:
// svc - [out] 填入查询到的服务指针和配置信息
// argc/argv - 命令行参数(可为 0/nullptr例如 GUI 没有命令行参数)
// default_cfg- 默认配置文件名(如 "config.toml"),当 argv 未提供时使用
// skip_flags - 以 NULL 结尾的字符串数组argv 扫描时跳过这些标志及其下一个参数
//
// 返回值:
// 0 - 成功svc.initialized == true至少 ai + session 已就绪
// 1 - dstalk_init 失败
// 2 - AI 服务未找到
// 3 - Session 服务未找到
//
int dstalk_frontend_init(FrontendServices& svc,
int argc = 0, char* argv[] = nullptr,
const char* default_cfg = "config.toml",
const char* const* skip_flags = nullptr);
// ---- 便捷辅助 ----
// 将 dstalk_message_t 数组的内容追加到 session 服务(一次一条)。
// 常用于 Ctrl+O 加载会话后重建前端消息列表。
// 返回实际追加的条数。
int dstalk_frontend_replay_history(FrontendServices& svc);
#endif // DSTALK_FRONTEND_COMMON_HPP

View File

@@ -0,0 +1,134 @@
// ============================================================================
// dstalk_frontend_common — 实现
// ============================================================================
#include "dstalk_frontend_common.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
// ---- 配置文件发现 ----
static const char* discover_config(int argc, char* argv[],
const char* default_cfg,
const char* const* skip_flags)
{
// 1) 从 argv 中查找首个非标志参数
if (argc >= 2 && argv) {
for (int i = 1; i < argc; ++i) {
bool is_skip = false;
if (skip_flags) {
for (int k = 0; skip_flags[k]; ++k) {
if (std::strcmp(argv[i], skip_flags[k]) == 0) {
is_skip = true;
// 若该标志有值参数则多跳一个
if (i + 1 < argc && argv[i + 1][0] != '-') ++i;
break;
}
}
}
if (!is_skip) return argv[i];
}
}
// 2) 回退:尝试默认配置文件
if (!default_cfg || default_cfg[0] == '\0') return nullptr;
const char* candidates[] = { default_cfg, nullptr };
for (int i = 0; candidates[i]; ++i) {
FILE* f = nullptr;
#ifdef _WIN32
if (fopen_s(&f, candidates[i], "r") == 0 && f) {
#else
f = std::fopen(candidates[i], "r");
if (f) {
#endif
std::fclose(f);
return candidates[i];
}
}
return nullptr;
}
// ---- 主初始化 ----
int dstalk_frontend_init(FrontendServices& svc,
int argc, char* argv[],
const char* default_cfg,
const char* const* skip_flags)
{
// (1) 发现并加载配置
const char* cfg = discover_config(argc, argv, default_cfg, skip_flags);
if (dstalk_init(cfg) != 0) {
std::fprintf(stderr, "[dstalk] 初始化失败\n");
return 1;
}
// (2) 查询 AI 服务
const char* provider = dstalk_config_get("ai.provider");
if (!provider || provider[0] == '\0') provider = "ai.deepseek";
svc.provider = provider;
svc.ai = static_cast<const dstalk_ai_service_t*>(
dstalk_service_query(provider, 1));
svc.session = static_cast<const dstalk_session_service_t*>(
dstalk_service_query("session", 1));
svc.file_io = static_cast<const dstalk_file_io_service_t*>(
dstalk_service_query("file_io", 1));
svc.tools = static_cast<const dstalk_tools_service_t*>(
dstalk_service_query("tools", 1));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
svc.endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
const dstalk_context_service_t* ctx_svc =
static_cast<const dstalk_context_service_t*>(
dstalk_service_query("context", 1));
(void)ctx_svc; // 不强制使用,保留以备将来使用
// endpoint_mgr 可作为 AI 后端;缺少旧 ai provider 时仍允许多 endpoint 配置工作。
// endpoint_mgr can serve as the AI backend; allow multi-endpoint config even when legacy ai provider is absent.
const bool endpoint_mgr_ready = svc.endpoint_mgr && svc.endpoint_mgr->count() > 0;
if (!svc.ai && !endpoint_mgr_ready) {
std::fprintf(stderr, "[dstalk] AI 服务未找到(请检查插件目录)\n");
return 2;
}
if (!svc.session) {
std::fprintf(stderr, "[dstalk] Session 服务未找到\n");
return 3;
}
// (3) 配置 AI 服务的默认值endpoint_mgr 路径由 endpoint.<name>.* 配置驱动。
// Configure legacy AI defaults; endpoint_mgr is driven by endpoint.<name>.* config.
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url || base_url[0] == '\0') base_url = "https://api.deepseek.com/v1";
if (!model || model[0] == '\0') model = "deepseek-v4-pro";
svc.base_url = base_url;
svc.api_key = api_key ? api_key : "";
svc.model = model;
if (svc.ai) {
svc.ai->configure(provider, base_url,
api_key ? api_key : "",
model, 4096, 0.7);
}
svc.initialized = true;
return 0;
}
// ---- 会话历史回放 ----
int dstalk_frontend_replay_history(FrontendServices& svc)
{
if (!svc.session) return 0;
int count = 0;
const dstalk_message_t* msgs = svc.session->history(&count);
return count; // 调用方自行遍历并重建前端消息列表
}

View File

@@ -1,22 +1,21 @@
# ============================================================ # ============================================================
# dstalk-gui 图形化前端 (SDL3) # dstalk_gui 图形化前端 (SDL3)
# ============================================================ # ============================================================
# DSTALK_BUILD_GUI=ON deps/conanfile.txt sdl # DSTALK_BUILD_GUI=ON deps/conanfile.txt sdl
find_package(SDL3 REQUIRED CONFIG) find_package(SDL3 REQUIRED CONFIG)
add_executable(dstalk-gui add_executable(dstalk_gui
src/main.cpp src/main.cpp
) )
target_link_libraries(dstalk-gui set_target_properties(dstalk_gui PROPERTIES
PRIVATE RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
dstalk
SDL3::SDL3
) )
add_custom_command(TARGET dstalk-gui POST_BUILD target_link_libraries(dstalk_gui
COMMAND ${CMAKE_COMMAND} -E copy_if_different PRIVATE
$<TARGET_FILE:dstalk> dstalk
$<TARGET_FILE_DIR:dstalk-gui> dstalk_frontend_common
SDL3::SDL3
) )

View File

@@ -1,11 +1,14 @@
// ============================================================================ /*
// dstalk-gui — SDL3 聊天客户端 * @file main.cpp
// ============================================================================ * @brief SDL3-based GUI frontend for dstalk (stub/minimal implementation).
// 使用 SDL3 内置的 SDL_RenderDebugText() 渲染文本8x8 像素), * dstalk SDL3
// 通过 SDL_SetRenderScale 2 倍缩放至有效的 16x16 像素。 * Copyright (c) 2026 dstalk contributors. GPLv3.
// *
// 该文件是独立的——不需要额外的源文件。 * Uses SDL3's built-in SDL_RenderDebugText() for 8x8 pixel text, scaled 2x to
// ============================================================================ * effective 16x16 pixels via SDL_SetRenderScale. Self-contained single-file GUI.
* 使 SDL3 SDL_RenderDebugText() 8x8 SDL_SetRenderScale
* 2 16x16 GUI
*/
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
@@ -19,46 +22,49 @@
#include "dstalk/dstalk_host.h" #include "dstalk/dstalk_host.h"
// ---- 服务 vtable 指针 ---- // ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to service vtables queried from the host on startup.
// 在启动时从主机查询获取的服务 vtable 全局指针。
static const dstalk_ai_service_t* g_ai_svc = nullptr; static const dstalk_ai_service_t* g_ai_svc = nullptr;
static const dstalk_session_service_t* g_session_svc = nullptr; static const dstalk_session_service_t* g_session_svc = nullptr;
static const dstalk_ai_endpoint_mgr_t* g_endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
// ---- 常量 ---- // ---- 常量 / Constants ----
static constexpr int WINDOW_W = 1024; static constexpr int WINDOW_W = 1024;
static constexpr int WINDOW_H = 768; static constexpr int WINDOW_H = 768;
static constexpr float RENDER_SCALE = 2.0f; static constexpr float RENDER_SCALE = 2.0f;
// 逻辑坐标尺寸(物理像素 / RENDER_SCALE // 逻辑坐标尺寸(物理像素 / RENDER_SCALE / Logical coordinate dimensions (physical pixels / RENDER_SCALE)
static constexpr int LOGICAL_W = WINDOW_W / 2; // 512 static constexpr int LOGICAL_W = WINDOW_W / 2; // 512
static constexpr int LOGICAL_H = WINDOW_H / 2; // 384 static constexpr int LOGICAL_H = WINDOW_H / 2; // 384
static constexpr int CHAR_W = 8; // SDL_RenderDebugText 原生字符宽度(逻辑像素) static constexpr int CHAR_W = 8; // SDL_RenderDebugText 原生字符宽度(逻辑像素) / native char width (logical pixels)
static constexpr int CHAR_H = 8; // 原生字符高度(逻辑像素) static constexpr int CHAR_H = 8; // 原生字符高度(逻辑像素) / native char height (logical pixels)
static constexpr int TITLE_H = 16; // 标题栏高度(逻辑像素) static constexpr int TITLE_H = 16; // 标题栏高度(逻辑像素) / title bar height (logical pixels)
static constexpr int PADDING = 4; // 内边距(逻辑像素) static constexpr int PADDING = 4; // 内边距(逻辑像素) / padding (logical pixels)
// 侧边栏 // 侧边栏 / Sidebar
static constexpr int SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素) static constexpr int SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素) / sidebar width (logical, renders as 160 physical px)
// 状态栏 // 状态栏 / Status bar
static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素) static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素) / status bar height (logical, renders as 40 physical px)
// 输入区域动态高度 // 输入区域动态高度 / Input area dynamic height
static constexpr int INPUT_H_MIN = 40; // 最小高度(逻辑像素) static constexpr int INPUT_H_MIN = 40; // 最小高度(逻辑像素) / min height (logical pixels)
static constexpr int INPUT_H_MAX = 120; // 最大高度(逻辑像素) static constexpr int INPUT_H_MAX = 120; // 最大高度(逻辑像素) / max height (logical pixels)
// 消息区域Y 起点不变,宽度和高度动态计算) // 消息区域Y 起点不变,宽度和高度动态计算) / Message area (Y origin fixed, width and height calculated dynamically)
static constexpr int MSG_Y = TITLE_H; static constexpr int MSG_Y = TITLE_H;
// 颜色ARGB 格式,用于 SDL_SetRenderDrawColor // 颜色ARGB 格式,用于 SDL_SetRenderDrawColor / Colors (ARGB format, for SDL_SetRenderDrawColor)
static constexpr SDL_Color COL_BG = {0x1E, 0x1E, 0x2E, 0xFF}; static constexpr SDL_Color COL_BG = {0x1E, 0x1E, 0x2E, 0xFF};
static constexpr SDL_Color COL_TITLE_BG = {0x2D, 0x2D, 0x44, 0xFF}; static constexpr SDL_Color COL_TITLE_BG = {0x2D, 0x2D, 0x44, 0xFF};
static constexpr SDL_Color COL_INPUT_BG = {0x2A, 0x2A, 0x3E, 0xFF}; static constexpr SDL_Color COL_INPUT_BG = {0x2A, 0x2A, 0x3E, 0xFF};
static constexpr SDL_Color COL_USER = {0x00, 0xFF, 0xFF, 0xFF}; // 青色 static constexpr SDL_Color COL_USER = {0x00, 0xFF, 0xFF, 0xFF}; // 青色 / cyan
static constexpr SDL_Color COL_AI = {0x00, 0xFF, 0x80, 0xFF}; // 绿色 static constexpr SDL_Color COL_AI = {0x00, 0xFF, 0x80, 0xFF}; // 绿色 / green
static constexpr SDL_Color COL_SYS = {0xFF, 0xFF, 0x00, 0xFF}; // 黄色 static constexpr SDL_Color COL_SYS = {0xFF, 0xFF, 0x00, 0xFF}; // 黄色 / yellow
static constexpr SDL_Color COL_BTN = {0x50, 0x50, 0x80, 0xFF}; // 按钮 static constexpr SDL_Color COL_BTN = {0x50, 0x50, 0x80, 0xFF}; // 按钮 / button
static constexpr SDL_Color COL_WHITE = {0xFF, 0xFF, 0xFF, 0xFF}; static constexpr SDL_Color COL_WHITE = {0xFF, 0xFF, 0xFF, 0xFF};
static constexpr SDL_Color COL_CURSOR = {0xFF, 0xFF, 0xFF, 0xFF}; static constexpr SDL_Color COL_CURSOR = {0xFF, 0xFF, 0xFF, 0xFF};
static constexpr SDL_Color COL_SEP = {0x50, 0x50, 0x70, 0xFF}; static constexpr SDL_Color COL_SEP = {0x50, 0x50, 0x70, 0xFF};
@@ -68,8 +74,9 @@ static constexpr SDL_Color COL_SIDEBAR_BTN = {0x40, 0x40, 0x68, 0xFF};
static constexpr SDL_Color COL_STATUSBAR_BG= {0x2D, 0x2D, 0x44, 0xFF}; static constexpr SDL_Color COL_STATUSBAR_BG= {0x2D, 0x2D, 0x44, 0xFF};
static constexpr SDL_Color COL_DIM = {0x80, 0x80, 0x80, 0xFF}; static constexpr SDL_Color COL_DIM = {0x80, 0x80, 0x80, 0xFF};
// ---- 数据结构 ---- // ---- 数据结构 / Data structures ----
// 单条聊天消息 / Represents a single chat message with role and text content.
struct ChatMessage { struct ChatMessage {
enum Role { USER, ASSISTANT, SYSTEM } role; enum Role { USER, ASSISTANT, SYSTEM } role;
std::string content; std::string content;
@@ -77,62 +84,66 @@ struct ChatMessage {
ChatMessage(Role r, std::string c) : role(r), content(std::move(c)) {} ChatMessage(Role r, std::string c) : role(r), content(std::move(c)) {}
}; };
// 保存所有可变 UI 状态 / Holds all mutable UI state: message list, input buffer, scroll, streaming flag, etc.
struct GuiState { struct GuiState {
std::vector<ChatMessage> messages; std::vector<ChatMessage> messages;
std::string inputBuffer; std::string inputBuffer;
int scrollOffset = 0; // 从底部滚动的逻辑像素 int scrollOffset = 0; // 从底部滚动的逻辑像素 / logical pixels scrolled from bottom
bool streaming = false; bool streaming = false;
bool running = true; bool running = true;
int cursorPos = 0; // 输入缓冲区中的光标位置 int cursorPos = 0; // 输入缓冲区中的光标位置 / cursor position in input buffer
bool cursorVisible = true; bool cursorVisible = true;
Uint64 lastCursorBlink = 0; Uint64 lastCursorBlink = 0;
float maxScroll = 0; // 可用的最大滚动距离(逻辑像素) float maxScroll = 0; // 可用的最大滚动距离(逻辑像素) / max available scroll distance (logical pixels)
// P0 新增字段 // P0 新增字段 / P0 new fields
std::vector<std::string> input_history; // 输入历史(最多 20 条) std::vector<std::string> input_history; // 输入历史(最多 20 条) / input history (max 20 entries)
int history_index = -1; // 当前历史位置(-1 = 新输入) int history_index = -1; // 当前历史位置(-1 = 新输入) / current history position (-1 = new input)
std::string saved_input; // 浏览历史时暂存当前输入 std::string saved_input; // 浏览历史时暂存当前输入 / saved current input while browsing history
bool sidebar_visible = true; // 侧边栏可见性 bool sidebar_visible = true; // 侧边栏可见性 / sidebar visibility
std::string model_name = "deepseek-chat";// 当前模型名 std::string model_name = "gpt-4o";// 当前模型名 / current model name
}; };
// 持有上下文指针,用于将回调传递给流式 API // 将 GuiState 与 SDL 窗口/渲染器句柄及逐帧标志打包。
// 作为 userdata 传递给流式回调,使其可以更新缓冲区并重新渲染。
// Bundles GuiState with SDL window/renderer handles and per-frame flags.
// Passed as userdata to the streaming callback so it can update the buffer and re-render.
struct AppContext { struct AppContext {
GuiState state; GuiState state;
SDL_Window* window = nullptr; SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr; SDL_Renderer* renderer = nullptr;
bool sendPending = false; // 按下 Enter 后设置为 true bool sendPending = false; // 按下 Enter 后设置为 true / set to true after pressing Enter
std::string streamBuffer; // 存储当前流式消息 std::string streamBuffer; // 存储当前流式消息 / stores current streaming message
}; };
// ---- 辅助函数 ---- // ---- 辅助函数 / Helper functions ----
// 获取一个逻辑坐标的 SDL 矩形 // 在逻辑坐标系中创建 SDL_FRect / Create an SDL_FRect in logical coordinates.
static SDL_FRect mkRect(float x, float y, float w, float h) { static SDL_FRect mkRect(float x, float y, float w, float h) {
SDL_FRect r; SDL_FRect r;
r.x = x; r.y = y; r.w = w; r.h = h; r.x = x; r.y = y; r.w = w; r.h = h;
return r; return r;
} }
// 使用给定的颜色设置绘制颜色 // 使用 SDL_Color 设置渲染器的绘制颜色 / Set the renderer's draw color from an SDL_Color.
static void setColor(SDL_Renderer* r, SDL_Color c) { static void setColor(SDL_Renderer* r, SDL_Color c) {
SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a); SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a);
} }
// 使用颜色绘制填充矩形 // 以纯色填充矩形(逻辑坐标) / Fill a rectangle with a solid color (logical coordinates).
static void fillRect(SDL_Renderer* r, SDL_FRect rect, SDL_Color c) { static void fillRect(SDL_Renderer* r, SDL_FRect rect, SDL_Color c) {
setColor(r, c); setColor(r, c);
SDL_RenderFillRect(r, &rect); SDL_RenderFillRect(r, &rect);
} }
// 在给定位置(逻辑坐标)绘制一个调试文本字符串,并设定颜色 // 在指定逻辑位置以指定颜色绘制调试文本 / Draw a debug-text string at a given logical position with the specified color.
static void drawText(SDL_Renderer* r, float x, float y, static void drawText(SDL_Renderer* r, float x, float y,
const char* text, SDL_Color color) { const char* text, SDL_Color color) {
setColor(r, color); setColor(r, color);
SDL_RenderDebugText(r, x, y, text); SDL_RenderDebugText(r, x, y, text);
} }
// 绘制一个可见的调试文本字符,避免为空字符串调用 SDL_RenderDebugText // 仅当字符串非空时绘制调试文本(避免 SDL_RenderDebugText 问题) / Draw debug text only if the string is non-empty (avoids SDL_RenderDebugText issues).
static void drawTextSafe(SDL_Renderer* r, float x, float y, static void drawTextSafe(SDL_Renderer* r, float x, float y,
const char* text) { const char* text) {
if (text && text[0] != '\0') { if (text && text[0] != '\0') {
@@ -140,7 +151,7 @@ static void drawTextSafe(SDL_Renderer* r, float x, float y,
} }
} }
// 计算输入区域的动态高度(根据输入内容中的换行数) // 根据换行符数量计算输入区域的动态高度 / Compute the dynamic height of the input area based on the number of newlines.
static int calcInputHeight(const std::string& input) { static int calcInputHeight(const std::string& input) {
int lines = 1; int lines = 1;
for (char ch : input) { for (char ch : input) {
@@ -150,14 +161,13 @@ static int calcInputHeight(const std::string& input) {
std::max(INPUT_H_MIN, lines * CHAR_H + PADDING * 2)); std::max(INPUT_H_MIN, lines * CHAR_H + PADDING * 2));
} }
// ---- 文本换行 ---- // ---- 文本换行 / Text wrapping ----
// 将一段文本按字符数换行。保留嵌入的 '\n',并在单词边界处尽可能按字符数换行。 // 按 maxChars 换行文本,保留嵌入的换行符 / Word-wrap text to fit within maxChars per line, respecting embedded newlines.
// 返回逻辑文本行列表。
static std::vector<std::string> wrapText(const std::string& text, int maxChars) { static std::vector<std::string> wrapText(const std::string& text, int maxChars) {
std::vector<std::string> lines; std::vector<std::string> lines;
// 首先按嵌入的换行符分割 // 首先按嵌入的换行符分割 / First split by embedded newlines
std::string remaining = text; std::string remaining = text;
while (!remaining.empty()) { while (!remaining.empty()) {
std::string segment; std::string segment;
@@ -170,13 +180,13 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
remaining.clear(); remaining.clear();
} }
// 将片段按单词换行以适应 maxChars // 将片段按单词换行以适应 maxChars / Wrap segment by word to fit maxChars
while (!segment.empty()) { while (!segment.empty()) {
if (static_cast<int>(segment.size()) <= maxChars) { if (static_cast<int>(segment.size()) <= maxChars) {
lines.push_back(segment); lines.push_back(segment);
break; break;
} }
// 在 maxChars 位置寻找空格/单词边界 // 在 maxChars 位置寻找空格/单词边界 / Find space/word boundary at maxChars position
int splitAt = maxChars; int splitAt = maxChars;
for (int i = maxChars; i > 0; --i) { for (int i = maxChars; i > 0; --i) {
char ch = segment[i]; char ch = segment[i];
@@ -187,7 +197,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
break; break;
} }
if ((ch & 0x80) != 0) { if ((ch & 0x80) != 0) {
// UTF-8 多字节字符——不在中间分割 // UTF-8 多字节字符——不在中间分割 / UTF-8 multi-byte char — don't split in the middle
} }
} }
if (splitAt <= 0 || splitAt > maxChars) { if (splitAt <= 0 || splitAt > maxChars) {
@@ -195,7 +205,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
} }
lines.push_back(segment.substr(0, splitAt)); lines.push_back(segment.substr(0, splitAt));
// 去除下一行的前导空格 // 去除下一行的前导空格 / Trim leading spaces for the next line
size_t start = splitAt; size_t start = splitAt;
while (start < segment.size() && while (start < segment.size() &&
(segment[start] == ' ' || segment[start] == '\t')) { (segment[start] == ' ' || segment[start] == '\t')) {
@@ -207,8 +217,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
return lines; return lines;
} }
// 计算所有消息的总渲染高度(逻辑像素) // 计算所有消息在换行后的总渲染高度(逻辑像素) / Calculate the total rendered height (in logical pixels) of all messages after wrapping.
// 注意:这使用当前的侧边栏状态来决定宽度;调用者应在侧边栏宽度正确时调用。
static int calcTotalMsgHeight(GuiState& state, int charsPerLine) { static int calcTotalMsgHeight(GuiState& state, int charsPerLine) {
int totalH = 0; int totalH = 0;
for (auto& msg : state.messages) { for (auto& msg : state.messages) {
@@ -219,8 +228,10 @@ static int calcTotalMsgHeight(GuiState& state, int charsPerLine) {
return totalH; return totalH;
} }
// ---- 侧边栏渲染 ---- // ---- 侧边栏渲染 / Sidebar rendering ----
// 渲染左侧边栏:背景、会话列表和"+ New Chat"按钮。
// Render the left sidebar: background, session list, and "+ New Chat" button.
static void renderSidebar(AppContext& ctx) { static void renderSidebar(AppContext& ctx) {
GuiState& gs = ctx.state; GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer; SDL_Renderer* r = ctx.renderer;
@@ -228,32 +239,34 @@ static void renderSidebar(AppContext& ctx) {
float sbY = static_cast<float>(TITLE_H); float sbY = static_cast<float>(TITLE_H);
float sbH = static_cast<float>(LOGICAL_H) - TITLE_H - STATUS_H; float sbH = static_cast<float>(LOGICAL_H) - TITLE_H - STATUS_H;
// 背景 // 背景 / Background
fillRect(r, mkRect(0, sbY, sbW, sbH), COL_SIDEBAR_BG); fillRect(r, mkRect(0, sbY, sbW, sbH), COL_SIDEBAR_BG);
// 右侧分隔线 // 右侧分隔线 / Right separator line
setColor(r, COL_SEP); setColor(r, COL_SEP);
SDL_RenderLine(r, sbW, sbY, sbW, sbY + sbH); SDL_RenderLine(r, sbW, sbY, sbW, sbY + sbH);
// "Chats" 标题 // "Chats" 标题 / "Chats" title
drawText(r, static_cast<float>(PADDING), sbY + PADDING, "Chats", COL_WHITE); drawText(r, static_cast<float>(PADDING), sbY + PADDING, "Chats", COL_WHITE);
// 会话列表(当前只有 "default" // 会话列表(当前只有 "default" / Session list (currently only "default")
float listY = sbY + TITLE_H; float listY = sbY + TITLE_H;
// "default" 条目(活动状态高亮) // "default" 条目(活动状态高亮) / "default" entry (active state highlighted)
float itemH = static_cast<float>(CHAR_H + PADDING); float itemH = static_cast<float>(CHAR_H + PADDING);
fillRect(r, mkRect(PADDING, listY, sbW - PADDING * 2, itemH), COL_SIDEBAR_ACT); fillRect(r, mkRect(PADDING, listY, sbW - PADDING * 2, itemH), COL_SIDEBAR_ACT);
drawText(r, PADDING * 2.0f, listY + PADDING / 2.0f, "default", COL_AI); drawText(r, PADDING * 2.0f, listY + PADDING / 2.0f, "default", COL_AI);
// "+ New Chat" 按钮(侧边栏底部) // "+ New Chat" 按钮(侧边栏底部) / "+ New Chat" button (sidebar bottom)
float btnY = sbY + sbH - CHAR_H - PADDING * 2; float btnY = sbY + sbH - CHAR_H - PADDING * 2;
float btnH = static_cast<float>(CHAR_H + PADDING); float btnH = static_cast<float>(CHAR_H + PADDING);
fillRect(r, mkRect(PADDING, btnY, sbW - PADDING * 2, btnH), COL_SIDEBAR_BTN); fillRect(r, mkRect(PADDING, btnY, sbW - PADDING * 2, btnH), COL_SIDEBAR_BTN);
drawText(r, PADDING * 2.0f, btnY + PADDING / 2.0f, "+ New Chat", COL_WHITE); drawText(r, PADDING * 2.0f, btnY + PADDING / 2.0f, "+ New Chat", COL_WHITE);
} }
// ---- 状态栏渲染 ---- // ---- 状态栏渲染 / Status bar rendering ----
// 渲染底部状态栏:模型名、消息数和流式状态。
// Render the bottom status bar: model name, message count, and streaming state.
static void renderStatusBar(AppContext& ctx) { static void renderStatusBar(AppContext& ctx) {
GuiState& gs = ctx.state; GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer; SDL_Renderer* r = ctx.renderer;
@@ -261,30 +274,41 @@ static void renderStatusBar(AppContext& ctx) {
float lh = static_cast<float>(LOGICAL_H); float lh = static_cast<float>(LOGICAL_H);
float barY = lh - STATUS_H; float barY = lh - STATUS_H;
// 背景 // 背景 / Background
fillRect(r, mkRect(0, barY, lw, static_cast<float>(STATUS_H)), COL_STATUSBAR_BG); fillRect(r, mkRect(0, barY, lw, static_cast<float>(STATUS_H)), COL_STATUSBAR_BG);
// 顶部分隔线 // 顶部分隔线 / Top separator line
setColor(r, COL_SEP); setColor(r, COL_SEP);
SDL_RenderLine(r, 0, barY, lw, barY); SDL_RenderLine(r, 0, barY, lw, barY);
// 统计消息数(排除系统消息) // 统计消息数(排除系统消息) / Count messages (excluding system messages)
int msgCount = 0; int msgCount = 0;
for (auto& msg : gs.messages) { for (auto& msg : gs.messages) {
if (msg.role != ChatMessage::SYSTEM) msgCount++; if (msg.role != ChatMessage::SYSTEM) msgCount++;
} }
// 状态文本:模型名 | 消息条数 | 流式状态 // 状态文本:模型名 | 消息条数 | 流式状态 / Status text: model name | message count | streaming state
// I08: 添加 endpoint manager 信息(如果可用)/ add endpoint manager info (if available)
char buf[256]; char buf[256];
const char* active_ep = nullptr;
if (g_endpoint_mgr) active_ep = g_endpoint_mgr->get_active();
if (active_ep && active_ep[0]) {
snprintf(buf, sizeof(buf), "%s | %d messages | %s | ep:%s",
gs.model_name.c_str(), msgCount,
gs.streaming ? "streaming" : "ready", active_ep);
} else {
snprintf(buf, sizeof(buf), "%s | %d messages | %s", snprintf(buf, sizeof(buf), "%s | %d messages | %s",
gs.model_name.c_str(), msgCount, gs.model_name.c_str(), msgCount,
gs.streaming ? "streaming" : "ready"); gs.streaming ? "streaming" : "ready");
}
drawText(r, static_cast<float>(PADDING), drawText(r, static_cast<float>(PADDING),
barY + (STATUS_H - CHAR_H) / 2.0f, buf, COL_WHITE); barY + (STATUS_H - CHAR_H) / 2.0f, buf, COL_WHITE);
} }
// ---- 主渲染 ---- // ---- 主渲染 / Main rendering ----
// 渲染一帧:标题栏、侧边栏、消息区(滚动)、输入区、光标、发送按钮、状态栏。
// Render one full frame: title bar, sidebar, message area (with scrolling), input area, cursor, send button, status bar.
static void renderFrame(AppContext& ctx) { static void renderFrame(AppContext& ctx) {
GuiState& gs = ctx.state; GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer; SDL_Renderer* r = ctx.renderer;
@@ -301,33 +325,33 @@ static void renderFrame(AppContext& ctx) {
int charsPerLine = std::max(20, int charsPerLine = std::max(20,
static_cast<int>(msgAreaW - PADDING * 2) / CHAR_W); static_cast<int>(msgAreaW - PADDING * 2) / CHAR_W);
// 1. 设置渲染缩放以获得 2 倍文本大小 // 1. 设置渲染缩放以获得 2 倍文本大小 / Set render scale for 2x text size
SDL_SetRenderScale(r, RENDER_SCALE, RENDER_SCALE); SDL_SetRenderScale(r, RENDER_SCALE, RENDER_SCALE);
// 2. 清除背景 // 2. 清除背景 / Clear background
setColor(r, COL_BG); setColor(r, COL_BG);
SDL_RenderClear(r); SDL_RenderClear(r);
// 3. 标题栏(全宽) // 3. 标题栏(全宽)/ Title bar (full width)
fillRect(r, mkRect(0, 0, lw, static_cast<float>(TITLE_H)), COL_TITLE_BG); fillRect(r, mkRect(0, 0, lw, static_cast<float>(TITLE_H)), COL_TITLE_BG);
drawText(r, static_cast<float>(PADDING), static_cast<float>(PADDING), drawText(r, static_cast<float>(PADDING), static_cast<float>(PADDING),
"dstalk - AI Chat", COL_WHITE); "dstalk - AI Chat", COL_WHITE);
// 右侧的状态指示器 // 右侧的状态指示器 / Status indicator on the right
const char* status = gs.streaming ? "[streaming...]" : "[ready]"; const char* status = gs.streaming ? "[streaming...]" : "[ready]";
float statusW = static_cast<float>(strlen(status)) * CHAR_W + PADDING; float statusW = static_cast<float>(strlen(status)) * CHAR_W + PADDING;
drawText(r, lw - statusW, static_cast<float>(PADDING), status, COL_WHITE); drawText(r, lw - statusW, static_cast<float>(PADDING), status, COL_WHITE);
// 4. 标题栏分隔线 // 4. 标题栏分隔线 / Title bar separator line
setColor(r, COL_SEP); setColor(r, COL_SEP);
SDL_RenderLine(r, 0, static_cast<float>(TITLE_H), SDL_RenderLine(r, 0, static_cast<float>(TITLE_H),
lw, static_cast<float>(TITLE_H)); lw, static_cast<float>(TITLE_H));
// 5. 侧边栏(可折叠) // 5. 侧边栏(可折叠)/ Sidebar (collapsible)
if (gs.sidebar_visible) { if (gs.sidebar_visible) {
renderSidebar(ctx); renderSidebar(ctx);
} }
// 6. 消息区域(带滚动) // 6. 消息区域(带滚动)/ Message area (with scrolling)
SDL_Rect msgClip; SDL_Rect msgClip;
msgClip.x = static_cast<int>(msgAreaX * RENDER_SCALE); msgClip.x = static_cast<int>(msgAreaX * RENDER_SCALE);
msgClip.y = static_cast<int>(msgAreaY * RENDER_SCALE); msgClip.y = static_cast<int>(msgAreaY * RENDER_SCALE);
@@ -335,13 +359,13 @@ static void renderFrame(AppContext& ctx) {
msgClip.h = static_cast<int>(msgAreaH * RENDER_SCALE); msgClip.h = static_cast<int>(msgAreaH * RENDER_SCALE);
SDL_SetRenderClipRect(r, &msgClip); SDL_SetRenderClipRect(r, &msgClip);
// 计算总消息高度和滚动限制 // 计算总消息高度和滚动限制 / Calculate total message height and scroll limits
int totalMsgH = calcTotalMsgHeight(gs, charsPerLine); int totalMsgH = calcTotalMsgHeight(gs, charsPerLine);
gs.maxScroll = std::max(0.0f, static_cast<float>(totalMsgH) - msgAreaH); gs.maxScroll = std::max(0.0f, static_cast<float>(totalMsgH) - msgAreaH);
if (gs.scrollOffset < 0) gs.scrollOffset = 0; if (gs.scrollOffset < 0) gs.scrollOffset = 0;
if (gs.scrollOffset > gs.maxScroll) gs.scrollOffset = static_cast<int>(gs.maxScroll); if (gs.scrollOffset > gs.maxScroll) gs.scrollOffset = static_cast<int>(gs.maxScroll);
// 绘制消息:起始 Y 从消息区域顶部减去 scrollOffset // 绘制消息:起始 Y 从消息区域顶部减去 scrollOffset / Draw messages: start Y from message area top minus scrollOffset
float drawY = msgAreaY - gs.scrollOffset; float drawY = msgAreaY - gs.scrollOffset;
float unusedSpace = msgAreaH - static_cast<float>(totalMsgH); float unusedSpace = msgAreaH - static_cast<float>(totalMsgH);
float bottomOffset = std::max(0.0f, unusedSpace); float bottomOffset = std::max(0.0f, unusedSpace);
@@ -359,7 +383,7 @@ static void renderFrame(AppContext& ctx) {
default: col = COL_SYS; prefix = "Sys> "; break; default: col = COL_SYS; prefix = "Sys> "; break;
} }
// 如果该消息可见,则绘制 // 如果该消息可见,则绘制 / Draw if this message is visible
float msgBottom = drawY + msgH; float msgBottom = drawY + msgH;
if (msgBottom > msgAreaY && drawY < msgAreaY + msgAreaH) { if (msgBottom > msgAreaY && drawY < msgAreaY + msgAreaH) {
float lineY = drawY + 2; float lineY = drawY + 2;
@@ -383,14 +407,14 @@ static void renderFrame(AppContext& ctx) {
SDL_SetRenderClipRect(r, nullptr); SDL_SetRenderClipRect(r, nullptr);
// 7. 输入区域分隔线 // 7. 输入区域分隔线 / Input area separator line
setColor(r, COL_SEP); setColor(r, COL_SEP);
SDL_RenderLine(r, msgAreaX, inputY, lw, inputY); SDL_RenderLine(r, msgAreaX, inputY, lw, inputY);
// 8. 输入区域背景 // 8. 输入区域背景 / Input area background
fillRect(r, mkRect(msgAreaX, inputY, msgAreaW, static_cast<float>(inputH)), COL_INPUT_BG); fillRect(r, mkRect(msgAreaX, inputY, msgAreaW, static_cast<float>(inputH)), COL_INPUT_BG);
// 9. 输入文本(支持多行显示) // 9. 输入文本(支持多行显示)/ Input text (multi-line support)
if (!gs.inputBuffer.empty()) { if (!gs.inputBuffer.empty()) {
std::string remaining = gs.inputBuffer; std::string remaining = gs.inputBuffer;
int lineIdx = 0; int lineIdx = 0;
@@ -416,7 +440,7 @@ static void renderFrame(AppContext& ctx) {
textY, "Type here..."); textY, "Type here...");
} }
// 10. 光标(多行感知) // 10. 光标(多行感知)/ Cursor (multi-line aware)
if (!gs.streaming) { if (!gs.streaming) {
Uint64 now = SDL_GetTicks(); Uint64 now = SDL_GetTicks();
if (now - gs.lastCursorBlink > 530) { if (now - gs.lastCursorBlink > 530) {
@@ -424,7 +448,7 @@ static void renderFrame(AppContext& ctx) {
gs.lastCursorBlink = now; gs.lastCursorBlink = now;
} }
if (gs.cursorVisible && gs.cursorPos <= static_cast<int>(gs.inputBuffer.size())) { if (gs.cursorVisible && gs.cursorPos <= static_cast<int>(gs.inputBuffer.size())) {
// 计算光标所在行和列 // 计算光标所在行和列 / Calculate cursor line and column
int curLine = 0; int curLine = 0;
int charsBeforeLine = 0; int charsBeforeLine = 0;
for (int i = 0; i < gs.cursorPos; i++) { for (int i = 0; i < gs.cursorPos; i++) {
@@ -444,7 +468,7 @@ static void renderFrame(AppContext& ctx) {
} }
} }
// 11. 发送/停止按钮 // 11. 发送/停止按钮 / Send/Stop button
float btnW = 5 * CHAR_W + PADDING; float btnW = 5 * CHAR_W + PADDING;
float btnH = CHAR_H + PADDING; float btnH = CHAR_H + PADDING;
float btnX = lw - btnW - PADDING; float btnX = lw - btnW - PADDING;
@@ -458,26 +482,27 @@ static void renderFrame(AppContext& ctx) {
drawText(r, btnTextX, btnTextY, "[Send]", COL_WHITE); drawText(r, btnTextX, btnTextY, "[Send]", COL_WHITE);
} }
// 12. 状态栏 // 12. 状态栏 / Status bar
renderStatusBar(ctx); renderStatusBar(ctx);
// 13. Present // 13. Present / Present
SDL_RenderPresent(r); SDL_RenderPresent(r);
} }
// ---- 事件处理 ---- // ---- 事件处理 / Event handling ----
// 尝试发送当前输入缓冲区的内容;返回 true 表示消息已排队 // 验证当前输入缓冲区并将其作为用户消息加入队列;成功发送则返回 true。
// Validate and queue the current input buffer as a user message; returns true if sent.
static bool trySendMessage(GuiState& gs) { static bool trySendMessage(GuiState& gs) {
std::string text = gs.inputBuffer; std::string text = gs.inputBuffer;
// 去除前导/尾随空白,但保留内容空白 // 去除前导/尾随空白,但保留内容空白 / Trim leading/trailing whitespace but preserve content whitespace
size_t start = text.find_first_not_of(" \t\r\n"); size_t start = text.find_first_not_of(" \t\r\n");
size_t end = text.find_last_not_of(" \t\r\n"); size_t end = text.find_last_not_of(" \t\r\n");
if (start == std::string::npos) return false; // 空输入 if (start == std::string::npos) return false; // 空输入 / empty input
text = text.substr(start, end - start + 1); text = text.substr(start, end - start + 1);
if (text.empty()) return false; if (text.empty()) return false;
// 保存原始输入到历史(最多保留 20 条) // 保存原始输入到历史(最多保留 20 条) / Save original input to history (max 20 entries)
gs.input_history.push_back(gs.inputBuffer); gs.input_history.push_back(gs.inputBuffer);
if (gs.input_history.size() > 20) if (gs.input_history.size() > 20)
gs.input_history.erase(gs.input_history.begin()); gs.input_history.erase(gs.input_history.begin());
@@ -489,7 +514,8 @@ static bool trySendMessage(GuiState& gs) {
return true; return true;
} }
// 如果输入区域中的 Send/Stop 按钮被点击,返回 true // 检查物理像素坐标是否落在发送/停止按钮区域内。
// Return true if the given physical-pixel coordinates fall within the Send/Stop button.
static bool isSendButtonHit(AppContext& ctx, float physX, float physY) { static bool isSendButtonHit(AppContext& ctx, float physX, float physY) {
float lx = physX / RENDER_SCALE; float lx = physX / RENDER_SCALE;
float ly = physY / RENDER_SCALE; float ly = physY / RENDER_SCALE;
@@ -506,8 +532,10 @@ static bool isSendButtonHit(AppContext& ctx, float physX, float physY) {
ly >= btnY && ly <= btnY + btnH; ly >= btnY && ly <= btnY + btnH;
} }
// ---- 流式回调 ---- // ---- 流式回调 / Streaming callback ----
// 流式 token 回调:将 token 追加到 streamBuffer更新最后一条助手消息然后重新渲染。
// Streaming token callback: appends token to streamBuffer, updates last assistant message, then re-renders.
static int streamTokenCallback(const char* token, void* userdata) { static int streamTokenCallback(const char* token, void* userdata) {
AppContext* ctx = static_cast<AppContext*>(userdata); AppContext* ctx = static_cast<AppContext*>(userdata);
GuiState& gs = ctx->state; GuiState& gs = ctx->state;
@@ -520,7 +548,7 @@ static int streamTokenCallback(const char* token, void* userdata) {
} }
} }
// 泵送事件以保持窗口响应 // 泵送事件以保持窗口响应 / Pump events to keep the window responsive
SDL_PumpEvents(); SDL_PumpEvents();
SDL_Event ev; SDL_Event ev;
@@ -547,15 +575,17 @@ static int streamTokenCallback(const char* token, void* userdata) {
} }
} }
// 重新渲染以显示进度的令牌 // 重新渲染以显示进度的令牌 / Re-render to show the token progress
gs.scrollOffset = 0; gs.scrollOffset = 0;
renderFrame(*ctx); renderFrame(*ctx);
return 0; return 0;
} }
// ---- 主事件处理函数 ---- // ---- 主事件处理函数 / Main event processing function ----
// 分发单个 SDL 事件以更新 GuiState键盘输入、鼠标点击、滚动、文本输入
// Dispatch a single SDL event to update GuiState (keyboard input, mouse clicks, scroll, text input).
static void processEvent(AppContext& ctx, SDL_Event& ev) { static void processEvent(AppContext& ctx, SDL_Event& ev) {
GuiState& gs = ctx.state; GuiState& gs = ctx.state;
@@ -571,23 +601,23 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
bool shift = (mod & SDL_KMOD_SHIFT) != 0; bool shift = (mod & SDL_KMOD_SHIFT) != 0;
if (gs.streaming) { if (gs.streaming) {
// 流式传输期间,按 Escape 键取消 // 流式传输期间,按 Escape 键取消 / While streaming, press Escape to cancel
if (key == SDLK_ESCAPE) { if (key == SDLK_ESCAPE) {
gs.streaming = false; gs.streaming = false;
} }
break; break;
} }
// Tab 切换侧边栏显示/隐藏 // Tab 切换侧边栏显示/隐藏 / Tab toggles sidebar visibility
if (key == SDLK_TAB) { if (key == SDLK_TAB) {
gs.sidebar_visible = !gs.sidebar_visible; gs.sidebar_visible = !gs.sidebar_visible;
break; break;
} }
// 输入历史浏览(↑/↓) // 输入历史浏览(↑/↓) / Input history browsing (Up/Down)
if (key == SDLK_UP && !gs.input_history.empty()) { if (key == SDLK_UP && !gs.input_history.empty()) {
if (gs.history_index == -1) { if (gs.history_index == -1) {
// 首次进入历史浏览,保存当前输入 // 首次进入历史浏览,保存当前输入 / First time browsing history, save current input
gs.saved_input = gs.inputBuffer; gs.saved_input = gs.inputBuffer;
gs.history_index = static_cast<int>(gs.input_history.size()) - 1; gs.history_index = static_cast<int>(gs.input_history.size()) - 1;
} else if (gs.history_index > 0) { } else if (gs.history_index > 0) {
@@ -606,7 +636,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
if (gs.history_index >= 0) { if (gs.history_index >= 0) {
gs.inputBuffer = gs.input_history[gs.history_index]; gs.inputBuffer = gs.input_history[gs.history_index];
} else { } else {
// 回到新输入,恢复暂存的输入 // 回到新输入,恢复暂存的输入 / Back to new input, restore saved input
gs.inputBuffer = gs.saved_input; gs.inputBuffer = gs.saved_input;
} }
gs.cursorPos = static_cast<int>(gs.inputBuffer.size()); gs.cursorPos = static_cast<int>(gs.inputBuffer.size());
@@ -620,7 +650,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDLK_RETURN: case SDLK_RETURN:
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
if (shift) { if (shift) {
// Shift+Enter插入换行符不发送 // Shift+Enter插入换行符不发送 / Shift+Enter: insert newline (don't send)
gs.inputBuffer.insert(gs.cursorPos, "\n"); gs.inputBuffer.insert(gs.cursorPos, "\n");
gs.cursorPos++; gs.cursorPos++;
gs.cursorVisible = true; gs.cursorVisible = true;
@@ -670,7 +700,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break; break;
case SDLK_V: case SDLK_V:
if (ctrl) { if (ctrl) {
// Ctrl+V从剪贴板粘贴 // Ctrl+V从剪贴板粘贴 / Ctrl+V: paste from clipboard
if (SDL_HasClipboardText()) { if (SDL_HasClipboardText()) {
char* clip = SDL_GetClipboardText(); char* clip = SDL_GetClipboardText();
if (clip) { if (clip) {
@@ -685,7 +715,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break; break;
case SDLK_C: case SDLK_C:
if (ctrl) { if (ctrl) {
// Ctrl+C复制到剪贴板复制最后一条助手消息 // Ctrl+C复制到剪贴板复制最后一条助手消息 / Ctrl+C: copy to clipboard (copy last assistant message)
if (!gs.messages.empty()) { if (!gs.messages.empty()) {
for (int i = static_cast<int>(gs.messages.size()) - 1; i >= 0; --i) { for (int i = static_cast<int>(gs.messages.size()) - 1; i >= 0; --i) {
if (gs.messages[i].role != ChatMessage::USER) { if (gs.messages[i].role != ChatMessage::USER) {
@@ -701,7 +731,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break; break;
case SDLK_L: case SDLK_L:
if (ctrl) { if (ctrl) {
// Ctrl+L清除聊天 // Ctrl+L清除聊天 / Ctrl+L: clear chat
if (g_session_svc) g_session_svc->clear(); if (g_session_svc) g_session_svc->clear();
gs.messages.clear(); gs.messages.clear();
gs.messages.push_back(ChatMessage( gs.messages.push_back(ChatMessage(
@@ -711,7 +741,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break; break;
case SDLK_S: case SDLK_S:
if (ctrl) { if (ctrl) {
// Ctrl+S保存会话 // Ctrl+S保存会话 / Ctrl+S: save session
if (g_session_svc && g_session_svc->save("session.json") == 0) { if (g_session_svc && g_session_svc->save("session.json") == 0) {
gs.messages.push_back(ChatMessage( gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session saved to session.json")); ChatMessage::SYSTEM, "Session saved to session.json"));
@@ -726,6 +756,21 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
if (ctrl) { if (ctrl) {
// Ctrl+O加载会话 // Ctrl+O加载会话
if (g_session_svc && g_session_svc->load("session.json") == 0) { if (g_session_svc && g_session_svc->load("session.json") == 0) {
// BUGFIX: 从 session 历史重建 GUI 消息列表
int hcount = 0;
const dstalk_message_t* history = g_session_svc->history(&hcount);
gs.messages.clear();
for (int i = 0; i < hcount; ++i) {
ChatMessage::Role r;
if (std::strcmp(history[i].role, "user") == 0)
r = ChatMessage::USER;
else if (std::strcmp(history[i].role, "assistant") == 0)
r = ChatMessage::ASSISTANT;
else
r = ChatMessage::SYSTEM;
gs.messages.push_back(ChatMessage(r,
history[i].content ? history[i].content : ""));
}
gs.messages.push_back(ChatMessage( gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session loaded from session.json")); ChatMessage::SYSTEM, "Session loaded from session.json"));
} else { } else {
@@ -743,7 +788,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDL_EVENT_TEXT_INPUT: case SDL_EVENT_TEXT_INPUT:
if (!gs.streaming) { if (!gs.streaming) {
// 将文本插入光标位置 // 将文本插入光标位置 / Insert text at cursor position
gs.inputBuffer.insert(gs.cursorPos, ev.text.text); gs.inputBuffer.insert(gs.cursorPos, ev.text.text);
gs.cursorPos += static_cast<int>(strlen(ev.text.text)); gs.cursorPos += static_cast<int>(strlen(ev.text.text));
gs.cursorVisible = true; gs.cursorVisible = true;
@@ -772,6 +817,8 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDL_EVENT_WINDOW_RESIZED: { case SDL_EVENT_WINDOW_RESIZED: {
// 当窗口大小改变时,不更新我们的常量——保持 1024x768 的逻辑尺寸。 // 当窗口大小改变时,不更新我们的常量——保持 1024x768 的逻辑尺寸。
// SDL 将自动缩放输出。 // SDL 将自动缩放输出。
// When window resizes, don't update our constants — keep 1024x768 logical size.
// SDL will auto-scale the output.
break; break;
} }
@@ -780,23 +827,28 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
} }
} }
// ---- 入口 ---- // ---- 入口 / Entry point ----
// 入口:初始化 dstalk host 和 SDL3运行主事件/渲染循环,然后清理。
// Entry point: initializes dstalk host and SDL3, runs the main event/render loop, then cleans up.
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
// ----- 初始化 dstalk ----- // ----- 初始化 dstalk / Initialize dstalk -----
if (dstalk_init(nullptr) != 0) { if (dstalk_init(nullptr) != 0) {
std::fprintf(stderr, "[dstalk] Init failed\n"); std::fprintf(stderr, "[dstalk] Init failed\n");
return 1; return 1;
} }
const char* ai_provider = dstalk_config_get("ai.provider"); const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek"; if (!ai_provider) ai_provider = "ai_openai";
g_ai_svc = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1)); g_ai_svc = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session_svc = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1)); g_session_svc = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
g_endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)"); if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)");
if (!g_session_svc) dstalk_log(3, "Session service not found"); if (!g_session_svc) dstalk_log(3, "Session service not found");
// ----- 初始化 SDL ----- // ----- 初始化 SDL / Initialize SDL -----
if (!SDL_Init(SDL_INIT_VIDEO)) { if (!SDL_Init(SDL_INIT_VIDEO)) {
std::fprintf(stderr, "[dstalk] SDL init failed: %s\n", SDL_GetError()); std::fprintf(stderr, "[dstalk] SDL init failed: %s\n", SDL_GetError());
dstalk_shutdown(); dstalk_shutdown();
@@ -822,10 +874,10 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
// 启用文本输入事件 // 启用文本输入事件 / Enable text input events
SDL_StartTextInput(window); SDL_StartTextInput(window);
// ----- 应用程序状态 ----- // ----- 应用程序状态 / Application state -----
AppContext ctx; AppContext ctx;
ctx.window = window; ctx.window = window;
ctx.renderer = renderer; ctx.renderer = renderer;
@@ -834,45 +886,77 @@ int main(int argc, char* argv[]) {
"Ctrl+L clear, Ctrl+S save, Ctrl+O load. " "Ctrl+L clear, Ctrl+S save, Ctrl+O load. "
"Shift+Enter for newline, Up/Down for history, Tab toggle sidebar.")); "Shift+Enter for newline, Up/Down for history, Tab toggle sidebar."));
// ----- 主循环 ----- // ----- 主循环 / Main loop -----
SDL_Event event; SDL_Event event;
while (ctx.state.running) { while (ctx.state.running) {
// 处理所有待处理事件 // 处理所有待处理事件 / Process all pending events
while (SDL_PollEvent(&event)) { while (SDL_PollEvent(&event)) {
processEvent(ctx, event); processEvent(ctx, event);
if (!ctx.state.running) break; if (!ctx.state.running) break;
} }
if (!ctx.state.running) break; if (!ctx.state.running) break;
// 检查待发送的消息 // 检查待发送的消息 / Check for pending message to send
if (ctx.sendPending && !ctx.state.streaming) { if (ctx.sendPending && !ctx.state.streaming) {
ctx.sendPending = false; ctx.sendPending = false;
if (trySendMessage(ctx.state)) { if (trySendMessage(ctx.state)) {
// 开始流式传输 // 开始流式传输 / Start streaming
ctx.state.streaming = true; ctx.state.streaming = true;
ctx.streamBuffer.clear(); ctx.streamBuffer.clear();
// 为流式响应添加占位消息 // 为流式响应添加占位消息 / Add placeholder message for streaming response
ctx.state.messages.push_back( ctx.state.messages.push_back(
ChatMessage(ChatMessage::ASSISTANT, "")); ChatMessage(ChatMessage::ASSISTANT, ""));
ctx.state.scrollOffset = 0; ctx.state.scrollOffset = 0;
// 对最后一条消息调用流式 API通过插件服务 vtable // 对最后一条消息调用流式 API通过插件服务 vtable / Call streaming API for the last message (via plugin service vtable)
std::string& userMsg = std::string& userMsg =
ctx.state.messages[ctx.state.messages.size() - 2].content; ctx.state.messages[ctx.state.messages.size() - 2].content;
int rc = -1; int rc = -1;
if (g_ai_svc) { // I08: 优先通过 endpoint_mgr 路由fallback 到 g_ai_svc / prefer endpoint_mgr, fallback to g_ai_svc
const bool use_mgr = (g_endpoint_mgr && g_endpoint_mgr->count() > 0);
if (use_mgr || g_ai_svc) {
int hcount = 0; int hcount = 0;
const dstalk_message_t* history = g_session_svc const dstalk_message_t* history = g_session_svc
? g_session_svc->history(&hcount) : nullptr; ? g_session_svc->history(&hcount) : nullptr;
dstalk_chat_result_t result = g_ai_svc->chat_stream( dstalk_chat_result_t result;
if (use_mgr) {
result = g_endpoint_mgr->chat_stream(
nullptr, history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx);
} else {
result = g_ai_svc->chat_stream(
history, hcount, userMsg.c_str(), history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx); streamTokenCallback, &ctx);
}
rc = result.ok ? 0 : -1; rc = result.ok ? 0 : -1;
g_ai_svc->free_result(&result); if (use_mgr) g_endpoint_mgr->free_result(&result);
else g_ai_svc->free_result(&result);
} }
// 流式传输完成(或被取消) // 流式传输完成(或被取消)
if (rc != 0) { if (rc == 0) {
// BUGFIX: 将用户消息和 AI 回复持久化到 session 服务
if (g_session_svc) {
const std::string& userContent =
ctx.state.messages[ctx.state.messages.size() - 2].content;
const std::string& aiContent =
ctx.state.messages.back().content;
dstalk_message_t user_msg = {
"user",
userContent.c_str(),
nullptr, nullptr
};
g_session_svc->add(&user_msg);
dstalk_message_t ai_msg = {
"assistant",
aiContent.c_str(),
nullptr, nullptr
};
g_session_svc->add(&ai_msg);
}
} else {
if (!ctx.state.messages.empty() && if (!ctx.state.messages.empty() &&
ctx.state.messages.back().role == ChatMessage::ASSISTANT) { ctx.state.messages.back().role == ChatMessage::ASSISTANT) {
if (ctx.state.messages.back().content.empty()) { if (ctx.state.messages.back().content.empty()) {
@@ -884,14 +968,14 @@ int main(int argc, char* argv[]) {
} }
} }
// 渲染当前帧 // 渲染当前帧 / Render current frame
renderFrame(ctx); renderFrame(ctx);
// 短暂休眠以降低 CPU 使用率 // 短暂休眠以降低 CPU 使用率 / Brief sleep to reduce CPU usage
SDL_Delay(16); // ~60 FPS SDL_Delay(16); // ~60 FPS
} }
// ----- 清理 ----- // ----- 清理 / Cleanup -----
SDL_StopTextInput(window); SDL_StopTextInput(window);
SDL_DestroyRenderer(renderer); SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window); SDL_DestroyWindow(window);

27
dstalk_web/CMakeLists.txt Normal file
View File

@@ -0,0 +1,27 @@
# ============================================================
# dstalk_web — Web 前端 / Web frontend (Boost.Beast HTTP + SSE)
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_executable(dstalk_web
src/main.cpp
)
set_target_properties(dstalk_web PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
target_compile_features(dstalk_web PRIVATE cxx_std_20)
target_link_libraries(dstalk_web
PRIVATE
dstalk
boost::boost
dstalk_boost_config
)
# Windows: Boost.Asio 需要 Winsock / Boost.Asio requires Winsock
if(WIN32)
target_link_libraries(dstalk_web PRIVATE ws2_32)
endif()

597
dstalk_web/src/main.cpp Normal file
View File

@@ -0,0 +1,597 @@
/*
* @file main.cpp
* @brief Boost.Beast HTTP server frontend for dstalk_web: SSE streaming chat, embedded web UI, CORS support.
* dstalk_web 的 Boost.Beast HTTP 服务端SSE 流式对话、嵌入式网页界面、CORS 支持。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "web_ui.hpp"
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/asio.hpp>
#include <boost/json.hpp>
#include <boost/json/src.hpp>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <windows.h>
#else
#include <signal.h>
#endif
// ---- 命名空间别名 / Namespace aliases ----
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// ---- 前置声明 / Forward declarations ----
class SseSession;
// ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to plugin service vtables, queried from the host on startup.
// 插件服务 vtable 的全局指针,在启动时从主机查询获取。
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static const dstalk_ai_endpoint_mgr_t* g_endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
// ---- 运行时状态 / Runtime state ----
// g_quit signals the main loop to exit (set by Ctrl+C).
// g_ioc is the io_context pointer for use by signal handlers to stop the event loop.
// g_quit 通知主循环退出(由 Ctrl+C 设置)。
// g_ioc 供信号处理函数调用 stop() 的 io_context 指针。
static std::atomic<bool> g_quit{false};
static asio::io_context* g_ioc = nullptr;
// ---- Ctrl+C 信号处理 / Ctrl+C signal handlers ----
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
// Windows 控制台事件处理CTRL_C_EVENT / CTRL_BREAK_EVENT
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) {
g_quit = true;
if (g_ioc) g_ioc->stop();
return TRUE;
}
return FALSE;
}
// Unix signal handler (SIGINT).
// Unix 信号处理SIGINT
#else
static void on_signal(int /*sig*/)
{
g_quit = true;
if (g_ioc) g_ioc->stop();
}
#endif
// ========================================================================
// SseSession — 管理一个 SSE 流式响应连接 / Manages one SSE streaming response
// ========================================================================
// 持有从 HttpSession 转移过来的 tcp::socket以 SSE 格式流式发送 AI 回复。
// 所有公开方法均在 io_context 线程上被调用,因此无需互斥锁。
// Owns the tcp::socket transferred from HttpSession; streams AI response as SSE.
// All public methods are called on the io_context thread, so no mutex is needed.
class SseSession : public std::enable_shared_from_this<SseSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit SseSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 发送 SSE HTTP 响应头并准备接收数据帧 / Send SSE HTTP response headers and prepare for data frames.
void start() {
writing_ = true; // 阻止数据写入,等待头部发送完成 / Block data writes until headers are sent
std::string header =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\n"
"Connection: keep-alive\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n";
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(header),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
if (!ec && !self->pending_.empty()) {
self->do_write();
}
// 写入失败则让 socket 随 SseSession 析构自然关闭 / On error, let socket close via destructor
});
}
// 将 token 加入待发送队列,若未在写入则启动写链 / Push token to pending queue; start write chain if idle.
void send_token(const std::string& token) {
if (closed_) return;
// 换行符会破坏 SSE 帧结构,替换为空格 / Newlines break SSE frame structure; replace with spaces
std::string t = token;
for (auto& c : t) if (c == '\n' || c == '\r') c = ' ';
if (t.empty()) return;
pending_.push_back("data: " + t + "\n\n");
if (!writing_) do_write();
}
// 发送完成事件后关闭连接 / Send done event then close the connection.
void send_done(bool ok, const std::string& content) {
if (closed_) return;
closed_ = true;
(void)ok;
(void)content;
// JS 客户端忽略 [DONE] token流自然结束触发最终渲染 / JS client ignores [DONE] token; stream end triggers final render
pending_.push_back("event: done\ndata: [DONE]\n\n");
if (!writing_) do_write();
}
// 发送错误事件后关闭连接 / Send error event then close the connection.
void send_error(const std::string& msg) {
if (closed_) return;
closed_ = true;
std::string m = msg;
for (auto& c : m) if (c == '\n' || c == '\r') c = ' ';
pending_.push_back("event: error\ndata: " + m + "\n\n");
if (!writing_) do_write();
}
private:
// 从待发送队列头部取出并异步写入 / Pop front of pending queue and async-write it.
void do_write() {
if (writing_ || pending_.empty()) return;
writing_ = true;
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(pending_.front()),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
self->pending_.pop_front();
if (!ec) {
if (!self->pending_.empty()) {
self->do_write();
} else if (self->closed_) {
// 队列已空且会话已关闭 → 关闭 socket / Queue drained and session closed → close socket
beast::error_code ignored;
self->socket_.shutdown(tcp::socket::shutdown_both, ignored);
self->socket_.close(ignored);
}
}
// 写入错误时 socket 随 SseSession 析构 / On write error, socket closes with SseSession
});
}
tcp::socket socket_;
std::deque<std::string> pending_;
bool writing_ = false;
bool closed_ = false;
};
// ========================================================================
// run_chat_worker — 在独立线程中执行流式 AI 聊天 / Execute streaming AI chat in a dedicated thread
// ========================================================================
// 将用户消息加入会话,调用 g_ai->chat_stream(),通过 asio::post 将 token 投递到 io_context。
// Add user message to session, call g_ai->chat_stream(), post tokens to io_context via asio::post.
static void run_chat_worker(
std::string user_input,
std::weak_ptr<SseSession> weak_sse,
asio::io_context& ioc)
{
// 将用户消息加入会话 / Add user message to session
dstalk_message_t user_msg = {"user", user_input.c_str(), nullptr, nullptr};
g_session->add(&user_msg);
// 获取会话历史 / Get session history
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
// 流式回调上下文 / Streaming callback context
struct CallbackData {
std::weak_ptr<SseSession> sse;
asio::io_context* ioc;
};
CallbackData cb_data{weak_sse, &ioc};
// 流式 token 回调:将 token 投递到 io_context 线程 / Streaming token callback: post token to io_context thread
auto token_cb = [](const char* token, void* userdata) -> int {
auto* data = static_cast<CallbackData*>(userdata);
if (auto sse = data->sse.lock()) {
std::string t(token);
asio::post(*data->ioc, [sse, t = std::move(t)]() {
sse->send_token(t);
});
}
return 0;
};
// 调用流式 AI 聊天 / Call streaming AI chat
// I08: 优先通过 endpoint_mgr 路由fallback 到 g_ai / prefer endpoint_mgr, fallback to g_ai
dstalk_chat_result_t result = {};
const bool use_mgr = (g_endpoint_mgr && g_endpoint_mgr->count() > 0);
if (use_mgr) {
result = g_endpoint_mgr->chat_stream(
nullptr, history, history_count, nullptr, token_cb, &cb_data);
} else if (g_ai) {
result = g_ai->chat_stream(
history, history_count, nullptr, token_cb, &cb_data);
} else {
result.ok = 0;
result.error = dstalk_strdup("AI service unavailable");
}
// 将 AI 回复加入会话 / Add AI reply to session
if (result.ok) {
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
}
// 将完成/错误事件投递到 io_context 线程 / Post completion/error event to io_context thread
bool ok = result.ok;
std::string content_copy = result.content ? result.content : "";
std::string error_copy = result.error ? result.error : "";
// I08: 根据路由来源释放 result / free result based on routing source
if (use_mgr) g_endpoint_mgr->free_result(&result);
else if (g_ai) g_ai->free_result(&result);
else if (result.error) dstalk_free((void*)result.error);
asio::post(ioc, [weak_sse, ok, content_copy, error_copy]() {
if (auto sse = weak_sse.lock()) {
if (ok) {
sse->send_done(true, content_copy);
} else {
sse->send_error(error_copy.empty() ? "unknown error" : error_copy);
}
}
});
}
// ========================================================================
// HttpSession — 处理单个 HTTP 请求 / Handles one HTTP request
// ========================================================================
// 使用 Beast 解析请求,按 method + target 路由到相应处理器。
// Uses Beast to parse the request, routing by method + target to the appropriate handler.
class HttpSession : public std::enable_shared_from_this<HttpSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit HttpSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 开始读取请求 / Start reading the request.
void start() { do_read(); }
private:
// 异步读取 HTTP 请求 / Asynchronously read the HTTP request.
void do_read() {
auto self = shared_from_this();
http::async_read(socket_, buffer_, request_,
[self](beast::error_code ec, size_t) {
if (ec) return; // 客户端断开或读取错误 / Client disconnected or read error
self->handle_request();
});
}
// 路由 HTTP 请求到相应的处理器 / Route the HTTP request to the appropriate handler.
void handle_request() {
auto const method = request_.method();
auto const target = std::string(request_.target());
// GET / — 返回嵌入式网页界面 / Return embedded web UI
if (method == http::verb::get && target == "/") {
serve_web_ui();
return;
}
// POST /chat — SSE 流式聊天 / SSE streaming chat
if (method == http::verb::post && target == "/chat") {
handle_chat();
return;
}
// OPTIONS /chat — CORS 预检请求 / CORS preflight request
if (method == http::verb::options && target == "/chat") {
serve_cors_preflight();
return;
}
// POST /clear — 清除会话 / Clear session
if (method == http::verb::post && target == "/clear") {
handle_clear();
return;
}
// POST /status — 返回运行状态 / Return runtime status
if (method == http::verb::post && target == "/status") {
handle_status();
return;
}
// 未知路由 — 404 / Unknown route — 404
serve_404();
}
// 返回 HTML 网页界面 / Serve the HTML web UI.
void serve_web_ui() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set(http::field::content_type, "text/html; charset=utf-8");
res.set(http::field::cache_control, "no-cache");
res.set("Access-Control-Allow-Origin", "*");
res.body() = kWebUiHtml;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 解析 JSON body、创建 SseSession、启动工作线程 / Parse JSON body, create SseSession, spawn worker thread.
void handle_chat() {
// 解析 JSON body / Parse JSON body
boost::system::error_code ec;
auto jv = boost::json::parse(request_.body(), ec);
if (ec || !jv.is_object()) {
serve_bad_request("Invalid JSON body");
return;
}
auto const& obj = jv.as_object();
auto it = obj.find("message");
if (it == obj.end() || !it->value().is_string()) {
serve_bad_request("Missing or invalid 'message' field");
return;
}
std::string user_input = boost::json::value_to<std::string>(it->value());
// 创建 SseSession 并转移 socket 所有权 / Create SseSession and transfer socket ownership
auto sse = std::make_shared<SseSession>(std::move(socket_));
sse->start();
// 在独立线程中执行聊天chat_stream 是阻塞调用) / Execute chat in dedicated thread (chat_stream is blocking)
std::thread worker([user_input = std::move(user_input), sse]() {
run_chat_worker(user_input, sse, *g_ioc);
});
worker.detach();
}
// 返回 CORS 预检响应头 / Return CORS preflight response headers.
void serve_cors_preflight() {
auto self = shared_from_this();
http::response<http::empty_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type");
res.set("Access-Control-Max-Age", "86400");
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 清除当前会话 / Clear the current session.
void handle_clear() {
if (g_session) g_session->clear();
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = "{\"ok\":true}";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回运行状态 JSON / Return runtime status as JSON.
void handle_status() {
boost::json::object st;
if (g_session) {
int count = 0;
g_session->history(&count);
st["messages"] = count;
st["tokens"] = g_session->token_count();
}
const char* model = dstalk_config_get("api.model");
if (model) st["model"] = std::string(model);
st["status"] = "running";
// I08/I09: endpoint manager 状态 / endpoint manager status
if (g_endpoint_mgr) {
st["endpoint_mgr_available"] = true;
st["endpoint_count"] = g_endpoint_mgr->count();
const char* active = g_endpoint_mgr->get_active();
if (active) st["active_endpoint"] = std::string(active);
char* list_json = g_endpoint_mgr->list_json();
if (list_json) {
boost::system::error_code ec;
auto jv = boost::json::parse(list_json, ec);
if (!ec) st["endpoints"] = std::move(jv);
dstalk_free(list_json);
}
} else {
st["endpoint_mgr_available"] = false;
}
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = boost::json::serialize(st);
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 400 Bad Request / Return 400 Bad Request.
void serve_bad_request(const std::string& msg) {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::bad_request, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = msg;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 404 Not Found / Return 404 Not Found.
void serve_404() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::not_found, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = "404 Not Found";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
tcp::socket socket_;
beast::flat_buffer buffer_;
http::request<http::string_body> request_;
};
// ========================================================================
// Listener — 接受 TCP 连接并创建 HttpSession / Accepts TCP connections and creates HttpSessions
// ========================================================================
// 异步接受循环:每个进入的连接包装为 HttpSession 并由 io_context 驱动其生命周期。
// Async accept loop: each inbound connection is wrapped in an HttpSession driven by the io_context.
class Listener {
public:
// 构造函数:打开 acceptor、绑定地址、开始监听 / Constructor: open acceptor, bind, start listening.
Listener(asio::io_context& ioc, const tcp::endpoint& ep)
: acceptor_(ioc)
{
beast::error_code ec;
acceptor_.open(ep.protocol(), ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.open: %s\n", ec.message().c_str());
return;
}
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
acceptor_.bind(ep, ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.bind: %s\n", ec.message().c_str());
return;
}
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.listen: %s\n", ec.message().c_str());
return;
}
}
// 启动接受循环 / Start the accept loop.
void run() { do_accept(); }
private:
// 异步接受一个连接,创建 HttpSession 并继续监听 / Async-accept one connection, create HttpSession, keep listening.
void do_accept() {
acceptor_.async_accept(
[this](beast::error_code ec, tcp::socket socket) {
if (!ec) {
// 为每个入站连接创建新的 HttpSession / Create a new HttpSession for each inbound connection
std::make_shared<HttpSession>(std::move(socket))->start();
}
// 继续接受下一个连接(除非已发出退出信号) / Keep accepting (unless quit has been signaled)
if (!g_quit) do_accept();
});
}
tcp::acceptor acceptor_;
};
// ========================================================================
// main — 入口点 / Entry point
// ========================================================================
// 初始化 dstalk host查询 AI/Session 服务,配置 HTTP 监听,运行 io_context 事件循环。
// Initialize dstalk host, query AI/Session services, configure HTTP listener, run io_context event loop.
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码 + 安装 Ctrl+C 处理器 / Windows: enable ANSI escape codes + install Ctrl+C handler
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
GetConsoleMode(hOut, &mode);
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件路径 / Locate config file path
const char* config_path = nullptr;
if (argc >= 2) {
config_path = argv[1];
}
if (!config_path) {
const char* default_configs[] = {"config.toml", nullptr};
for (int i = 0; default_configs[i]; i++) {
FILE* f = nullptr;
#ifdef _WIN32
fopen_s(&f, default_configs[i], "r");
#else
f = fopen(default_configs[i], "r");
#endif
if (f) {
fclose(f);
config_path = default_configs[i];
break;
}
}
}
// 初始化 dstalk 主机(加载配置 + 自动扫描 plugins_base/middle/upper 目录) / Init dstalk host (load config + auto-scan plugins_base/middle/upper)
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, "[dstalk_web] dstalk_init failed\n");
return 3;
}
// 查询插件服务 / Query plugin services
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai_openai";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
g_endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
if (!g_ai) {
std::fprintf(stderr, "[dstalk_web] AI service not found (check plugins directory)\n");
}
if (!g_session) {
std::fprintf(stderr, "[dstalk_web] Session service not found\n");
}
// 从配置自动加载 AI 设置 / Auto-load AI settings from config
if (g_ai) {
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.openai.com/v1";
if (!model) model = "gpt-4o";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
}
// 读取 web 服务配置 / Read web server config
const char* web_host = dstalk_config_get("web.host");
if (!web_host || !web_host[0]) web_host = "127.0.0.1";
const char* web_port_str = dstalk_config_get("web.port");
unsigned short web_port = 8080;
if (web_port_str && web_port_str[0]) {
web_port = static_cast<unsigned short>(std::strtoul(web_port_str, nullptr, 10));
}
// 创建 io_context 并启动监听 / Create io_context and start listener
asio::io_context ioc;
g_ioc = &ioc;
tcp::endpoint ep(asio::ip::make_address(web_host), web_port);
Listener listener(ioc, ep);
listener.run();
// 打印启动信息 / Print startup message
std::printf("[dstalk_web] running at http://%s:%u\n", web_host, web_port);
std::printf("[dstalk_web] Press Ctrl+C to stop\n");
// 运行事件循环(阻塞直到 g_ioc->stop() 被信号处理函数调用) / Run event loop (blocks until g_ioc->stop() called by signal handler)
ioc.run();
// 清理 / Cleanup
g_ioc = nullptr;
dstalk_shutdown();
std::printf("[dstalk_web] stopped\n");
return 0;
}

226
dstalk_web/src/web_ui.hpp Normal file
View File

@@ -0,0 +1,226 @@
/*
* @file web_ui.hpp
* @brief Embedded HTML/JS chat UI served by dstalk_web.
* 嵌入的 HTML/JS 聊天界面,由 dstalk_web 提供。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_WEB_UI_HPP
#define DSTALK_WEB_UI_HPP
// 深色主题单页聊天界面 — 通过 fetch ReadableStream 实现 SSE 流式传输
// Dark-themed single-page chat UI — SSE streaming via fetch ReadableStream
static const char kWebUiHtml[] = R"html(<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>dstalk Web</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:#1a1a2e;color:#eaeaea;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
#header{display:flex;align-items:center;justify-content:space-between;padding:8px 18px;background:#16162a;border-bottom:1px solid #2a2a4a;flex-shrink:0}
#header h1{font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px;color:#a6b8e0}
#header .status-row{display:flex;align-items:center;gap:14px;font-size:.75rem;color:#7a7a9a}
#dot{width:9px;height:9px;border-radius:50%;background:#555;flex-shrink:0;transition:background .3s}
#dot.connected{background:#4caf50}
#dot.streaming{background:#f06292;animation:pulse .8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
#clearBtn{font-size:.72rem;padding:3px 10px;border:1px solid #f06292;border-radius:4px;color:#f06292;background:transparent;cursor:pointer;transition:background .2s}
#clearBtn:hover{background:#f0629218}
#messages{flex:1;overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth}
.bubble{max-width:82%;padding:10px 14px;border-radius:10px;font-size:.9rem;line-height:1.55;word-wrap:break-word;animation:fadeIn .2s}
.bubble.user{align-self:flex-end;background:#2a3f6e;border-bottom-right-radius:3px}
.bubble.assistant{align-self:flex-start;background:#1e1e38;border:1px solid #2a2a4a;border-bottom-left-radius:3px}
.bubble pre{background:#0d1117;padding:9px 12px;border-radius:6px;overflow-x:auto;margin:6px 0;font-size:.8rem;white-space:pre-wrap}
.bubble code{font-family:"Fira Code","Cascadia Code",Consolas,monospace;font-size:.8rem}
.bubble strong{color:#f06292}
.bubble .lang{display:block;color:#8b949e;font-size:.68rem;margin-bottom:3px;text-transform:uppercase;letter-spacing:.4px}
#typing{display:none;align-self:flex-start;padding:10px 14px;background:#1e1e38;border:1px solid #2a2a4a;border-radius:10px;border-bottom-left-radius:3px}
#typing.active{display:block}
#typing span{display:inline-block;width:6px;height:6px;border-radius:50%;background:#f06292;margin:0 2px;animation:bounce 1.2s infinite}
#typing span:nth-child(2){animation-delay:.15s}
#typing span:nth-child(3){animation-delay:.3s}
@keyframes bounce{0%,60%,100%{transform:translateY(0);opacity:.3}30%{transform:translateY(-6px);opacity:1}}
#inputBar{display:flex;gap:8px;padding:10px 16px;background:#16162a;border-top:1px solid #2a2a4a;flex-shrink:0}
#inputBar textarea{flex:1;resize:none;background:#1a1a2e;color:#eaeaea;border:1px solid #2a2a4a;border-radius:8px;padding:9px 12px;font-size:.88rem;font-family:inherit;outline:none;transition:border .2s,box-shadow .2s;min-height:40px;max-height:120px;rows:1}
#inputBar textarea:focus{border-color:#f06292;box-shadow:0 0 8px #f0629230}
#sendBtn,#stopBtn{padding:9px 16px;border:none;border-radius:8px;font-size:.88rem;font-weight:600;cursor:pointer;transition:background .2s,opacity .2s}
#sendBtn{background:#f06292;color:#fff}
#sendBtn:hover{background:#d4517a}
#sendBtn:disabled{opacity:.45;cursor:not-allowed}
#stopBtn{display:none;background:#4a4a6a;color:#ccc}
#stopBtn:hover{background:#5a5a7a}
#stopBtn.visible{display:inline-block}
.emptyState{text-align:center;color:#4a4a6a;margin-top:20vh;font-size:.92rem;line-height:1.7}
.emptyState .logo{font-size:2rem;margin-bottom:8px}
@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
@media(max-width:600px){.bubble{max-width:92%}#inputBar{padding:8px 10px;gap:6px}#sendBtn,#stopBtn{padding:8px 14px;font-size:.82rem}}
</style>
</head>
<body>
<div id="header">
<h1>&#9670; dstalk Web <span id="dot"></span></h1>
<div class="status-row">
<span id="lblModel">-</span>
<button id="clearBtn" title="Clear conversation / 清空对话">Clear</button>
</div>
</div>
<div id="messages"><div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div></div>
<div id="typing"><span></span><span></span><span></span></div>
<div id="inputBar">
<textarea id="msgInput" placeholder="输入消息... (Enter 发送 / Shift+Enter 换行)" rows="1"></textarea>
<button id="sendBtn">Send</button>
<button id="stopBtn">Stop</button>
</div>
<script>
const msgs=document.getElementById('messages'),input=document.getElementById('msgInput'),
sendBtn=document.getElementById('sendBtn'),stopBtn=document.getElementById('stopBtn'),
dot=document.getElementById('dot'),typing=document.getElementById('typing'),
lblModel=document.getElementById('lblModel');
let abortCtrl=null,streaming=false,lastAiBubble=null,tokenBuf='';
function scrollDown(){msgs.scrollTop=msgs.scrollHeight}
function clearEmptyState(){const e=msgs.querySelector('.emptyState');if(e)e.remove()}
function addBubble(role,text){
clearEmptyState();
const d=document.createElement('div');
d.className='bubble '+role;
d.innerHTML=renderMD(text);
msgs.appendChild(d);
scrollDown();
return d;
}
function renderMD(t){
t=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// 代码块 / code fences
t=t.replace(/```(\w*)\n?([\s\S]*?)```/g,(_,lang,code)=>{
const label=lang?'<span class="lang">'+lang+'</span>':'';
return '<pre><code>'+label+code.trim()+'</code></pre>';
});
// 行内代码 / inline code
t=t.replace(/`([^`]+)`/g,'<code>$1</code>');
// 粗体 / bold
t=t.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
// 换行 / newlines
t=t.replace(/\n/g,'<br>');
return t;
}
function setStreaming(s){
streaming=s;
dot.classList.toggle('streaming',s);
dot.classList.toggle('connected',!s);
typing.classList.toggle('active',s&&!lastAiBubble);
sendBtn.disabled=s;
stopBtn.classList.toggle('visible',s);
if(s){
if(!lastAiBubble){lastAiBubble=addBubble('assistant','');typing.classList.remove('active')}
tokenBuf='';
}else{
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
lastAiBubble=null;tokenBuf='';abortCtrl=null;
}
}
// 解析 SSE 帧 / Parse SSE frames (separated by double-newline)
function parseSSE(text,callback){
let idx;
while((idx=text.indexOf('\n\n'))!==-1){
const frame=text.slice(0,idx);
text=text.slice(idx+2);
const lines=frame.split('\n');
let event='message',data='';
for(const line of lines){
if(line.startsWith('event: '))event=line.slice(7).trim();
else if(line.startsWith('data: '))data+=line.slice(6);
}
callback(event,data);
}
return text;
}
async function send(){
const text=input.value.trim();
if(!text||streaming)return;
input.value='';input.style.height='auto';
addBubble('user',text);
setStreaming(true);
abortCtrl=new AbortController();
try{
const res=await fetch('/chat',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text}),signal:abortCtrl.signal
});
if(!res.ok)throw new Error('HTTP '+res.status);
const reader=res.body.getReader(),decoder=new TextDecoder();
let leftover='';
while(true){
const{value,done}=await reader.read();
if(done)break;
leftover+=decoder.decode(value,{stream:true});
leftover=parseSSE(leftover,(event,data)=>{
if(event==='error'){
if(lastAiBubble)lastAiBubble.innerHTML=renderMD(tokenBuf||'');
const errEl=addBubble('assistant','[Error] '+data);
errEl.style.borderColor='#f06292';
}else if(event==='done'){
/* stream complete */
}else{
tokenBuf+=data;
if(lastAiBubble){lastAiBubble.innerHTML=renderMD(tokenBuf);scrollDown()}
}
});
}
// 处理残留在 leftover 中的非帧数据 / handle leftover non-frame data
if(leftover.trim()){
const trimmed=leftover.trim();
if(trimmed.startsWith('data: '))tokenBuf+=trimmed.slice(6);
}
}catch(e){
if(e.name!=='AbortError'){
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
}
}
setStreaming(false);
loadStatus();
}
function stop(){if(abortCtrl){abortCtrl.abort();setStreaming(false)}}
input.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
setTimeout(()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,120)+'px'},0);
});
sendBtn.addEventListener('click',send);
stopBtn.addEventListener('click',stop);
async function loadStatus(){
try{
const r=await fetch('/status',{method:'POST'});
if(!r.ok)return;
const s=await r.json();
lblModel.textContent=(s.model||'-')+' | '+(s.provider||'-');
dot.classList.toggle('connected',!!s.ai);
}catch(e){}
}
document.getElementById('clearBtn').addEventListener('click',async()=>{
try{await fetch('/clear',{method:'POST'})}catch(e){}
msgs.innerHTML='<div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div>';
lastAiBubble=null;tokenBuf='';
if(streaming)setStreaming(false);
loadStatus();
});
// 页面加载时检查后端状态 / Check backend status on load
loadStatus();
</script>
</body>
</html>)html";
#endif // DSTALK_WEB_UI_HPP

View File

@@ -1,7 +1,10 @@
/* /*
* example_plugin.cpp - Minimal dstalk plugin demonstrating the API contract. * @file example_plugin.cpp
* @brief Example plugin demonstrating the dstalk plugin API contract.
* 示例插件:演示 dstalk 插件 API 契约。
* Copyright (c) 2026 dstalk contributors. GPLv3.
* *
* Build instructions (conceptual): * Build instructions (conceptual) / 构建说明(概念性):
* *
* Linux / macOS: * Linux / macOS:
* g++ -std=c++20 -shared -fPIC -fvisibility=hidden \ * g++ -std=c++20 -shared -fPIC -fvisibility=hidden \
@@ -14,6 +17,7 @@
* /Fe:example_plugin.dll example_plugin.cpp * /Fe:example_plugin.dll example_plugin.cpp
* *
* The resulting `.so` / `.dylib` / `.dll` can be loaded with: * The resulting `.so` / `.dylib` / `.dll` can be loaded with:
* 生成的 .so / .dylib / .dll 可通过以下方式加载:
* *
* int id = dstalk_plugin_load("./example_plugin.so"); * int id = dstalk_plugin_load("./example_plugin.so");
*/ */
@@ -25,11 +29,12 @@
#include <cstring> /* strlen, strcmp */ #include <cstring> /* strlen, strcmp */
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Private state (one instance per plugin load) * 私有状态(每个插件加载实例一份) / Private state (one instance per plugin load)
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* *
* In a more complex plugin this struct would hold open database * In a more complex plugin this struct would hold open database
* connections, configuration, etc. * connections, configuration, etc.
* 在更复杂的插件中,此结构体可包含打开的数据库连接、配置等。
*/ */
struct ExampleState { struct ExampleState {
@@ -37,31 +42,33 @@ struct ExampleState {
}; };
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Stored host API table so callbacks can use host services. * 保存主机 API 表,以便回调函数使用主机服务 / Stored host API table so callbacks can use host services.
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
static const dstalk_host_api_t* g_host = nullptr; static const dstalk_host_api_t* g_host = nullptr;
static ExampleState g_state; /* not heap-allocated: stays valid static ExampleState g_state; /* 非堆分配:在库映射期间持续有效 / not heap-allocated: stays valid
while the library is mapped */ while the library is mapped */
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* on_init (was on_load) * on_init原 on_load / on_init (was on_load)
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
// 插件初始化:保存主机指针,重置调用计数,记录加载消息 / Plugin init: store host pointer, reset call count, log loaded message.
static int my_on_init(const dstalk_host_api_t* host) static int my_on_init(const dstalk_host_api_t* host)
{ {
g_host = host; g_host = host;
g_state.call_count = 0; g_state.call_count = 0;
/* TODO: real plugins would initialise resources here: /* TODO: 真实插件应在此处初始化资源 / real plugins would initialise resources here:
* - parse a plugin-specific config file via host->config_get * - 通过 host->config_get 解析插件专属配置文件 / parse a plugin-specific config file via host->config_get
* - open a log file * - 打开日志文件 / open a log file
* - connect to a local service * - 连接到本地服务 / connect to a local service
* - register services via host->register_service * - 通过 host->register_service 注册服务 / register services via host->register_service
* *
* Return non-zero to signal a fatal initialisation error to the * Return non-zero to signal a fatal initialisation error to the
* host, which will then unload the plugin immediately. * host, which will then unload the plugin immediately.
* 返回非零值以向主机报告致命初始化错误,主机将立即卸载该插件。
*/ */
if (host) { if (host) {
@@ -73,12 +80,13 @@ static int my_on_init(const dstalk_host_api_t* host)
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* on_shutdown (was on_unload) * on_shutdown原 on_unload / on_shutdown (was on_unload)
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
// 插件关闭:记录调用次数,释放资源 / Plugin shutdown: log call count, release any resources.
static void my_on_shutdown(void) static void my_on_shutdown(void)
{ {
/* TODO: release any resources allocated in on_init. After this /* TODO: 释放 on_init 中分配的所有资源。此函数返回后主机将卸载共享库。 / release any resources allocated in on_init. After this
* function returns the host will unmap the shared library. */ * function returns the host will unmap the shared library. */
if (g_host) { if (g_host) {
@@ -92,20 +100,21 @@ static void my_on_shutdown(void)
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* on_event (was on_message) * on_event原 on_message / on_event (was on_message)
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
// 插件事件处理:记录消息事件,忽略其他事件类型 / Plugin event handler: log message events, ignore other event types.
static void my_on_event(int event_type, const void* data) static void my_on_event(int event_type, const void* data)
{ {
if (event_type == DSTALK_EVENT_MESSAGE && data) { if (event_type == DSTALK_EVENT_MESSAGE && data) {
const auto* msg = static_cast<const dstalk_message_t*>(data); const auto* msg = static_cast<const dstalk_message_t*>(data);
g_state.call_count++; g_state.call_count++;
/* A real plugin might: /* 真实插件可能: / A real plugin might:
* - log the conversation to a file * - 将对话记录到文件 / log the conversation to a file
* - apply content moderation * - 实施内容审核 / apply content moderation
* - translate messages on the fly * - 实时翻译消息 / translate messages on the fly
* - enrich messages with external data * - 用外部数据丰富消息 / enrich messages with external data
*/ */
if (g_host) { if (g_host) {
@@ -117,19 +126,19 @@ static void my_on_event(int event_type, const void* data)
msg->role, std::strlen(msg->content)); msg->role, std::strlen(msg->content));
} }
} }
/* Other event types (DSTALK_EVENT_SESSION_CLEAR, DSTALK_EVENT_CONFIG_CHANGED, /* 其他事件类型 / Other event types (DSTALK_EVENT_SESSION_CLEAR, DSTALK_EVENT_CONFIG_CHANGED,
DSTALK_EVENT_PLUGIN_LOADED, DSTALK_EVENT_PLUGIN_UNLOADED, DSTALK_EVENT_CUSTOM+) DSTALK_EVENT_PLUGIN_LOADED, DSTALK_EVENT_PLUGIN_UNLOADED, DSTALK_EVENT_CUSTOM+)
are silently ignored by this minimal plugin. */ 此最小化插件静默忽略 / are silently ignored by this minimal plugin. */
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Plugin descriptor (static -- lives for the lifetime of the .so) * 插件描述符(静态 —— 在 .so 的生命周期内有效) / Plugin descriptor (static -- lives for the lifetime of the .so)
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
static dstalk_plugin_info_t g_info = { static dstalk_plugin_info_t g_info = {
/* .name = */ "example-plugin", /* .name = */ "example-plugin",
/* .version = */ "1.0.0", /* .version = */ "1.0.0",
/* .description = */ "An example plugin for dstalk", /* .description = */ "An example plugin for dstalk / dstalk 示例插件",
/* .api_version = */ DSTALK_API_VERSION, /* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ {nullptr}, /* .dependencies = */ {nullptr},
/* .on_init = */ my_on_init, /* .on_init = */ my_on_init,
@@ -138,13 +147,16 @@ static dstalk_plugin_info_t g_info = {
}; };
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Mandatory entry point * 必须入口点 / Mandatory entry point
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* *
* The host looks for this symbol via dlsym / GetProcAddress. * The host looks for this symbol via dlsym / GetProcAddress.
* 主机通过 dlsym / GetProcAddress 查找此符号。
* It MUST be declared extern "C" so the name is not mangled. * It MUST be declared extern "C" so the name is not mangled.
* 必须声明为 extern "C" 以避免名称修饰。
*/ */
// 返回插件描述符给主机加载器 / Returns the plugin descriptor to the host loader.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{ {
return &g_info; return &g_info;

View File

@@ -1,18 +0,0 @@
# ============================================================
# 插件目录 — 所有功能插件
# ============================================================
# 基础插件(无外部服务依赖)
add_subdirectory(config)
add_subdirectory(file-io)
add_subdirectory(network)
# 中间插件(依赖基础插件)
add_subdirectory(session)
add_subdirectory(context)
# 上层插件(依赖中间插件)
add_subdirectory(deepseek)
add_subdirectory(anthropic)
add_subdirectory(tools)
add_subdirectory(lsp)

View File

@@ -1,32 +0,0 @@
cmake_minimum_required(VERSION 3.21)
# ============================================================
# plugin-anthropic — Anthropic Claude AI 服务
# 依赖: http 服务 (查询), config 服务 (查询)
# ============================================================
add_library(plugin-anthropic SHARED
src/anthropic_plugin.cpp
)
target_include_directories(plugin-anthropic PRIVATE
${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_link_libraries(plugin-anthropic PRIVATE dstalk)
# Boost.JSON 用于构建/解析请求和响应
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-anthropic PRIVATE boost::boost)
target_compile_definitions(plugin-anthropic PRIVATE
BOOST_ALL_NO_LIB
BOOST_ERROR_CODE_HEADER_ONLY
BOOST_JSON_HEADER_ONLY
)
set_target_properties(plugin-anthropic PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,486 +0,0 @@
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/json.hpp>
#include <cstring>
#include <string>
namespace json = boost::json;
// ============================================================================
// 全局指针
// ============================================================================
static const dstalk_host_api_t* g_host = nullptr;
static dstalk_http_service_t* g_http = nullptr;
static dstalk_config_service_t* g_config = nullptr;
// ============================================================================
// 配置数据
// ============================================================================
struct PluginConfig {
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
static PluginConfig g_cfg;
// ============================================================================
// 辅助:提取 host / target
// ============================================================================
static bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out)
{
size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos) return false;
scheme_out = url.substr(0, scheme_end);
std::string rest = url.substr(scheme_end + 3);
size_t slash = rest.find('/');
std::string authority = (slash != std::string::npos) ? rest.substr(0, slash) : rest;
target_out = (slash != std::string::npos) ? rest.substr(slash) : "/";
size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
host_out = authority.substr(0, colon);
port_out = authority.substr(colon + 1);
} else {
host_out = authority;
port_out = (scheme_out == "https") ? "443" : "80";
}
return true;
}
// ============================================================================
// 构建 Anthropic headers JSON
// ============================================================================
static std::string build_headers_json()
{
json::object h;
h["x-api-key"] = g_cfg.api_key;
h["anthropic-version"] = "2023-06-01";
return json::serialize(h);
}
// ============================================================================
// 构建 Anthropic JSON 请求体
// ============================================================================
static std::string build_request_json(
const dstalk_message_t* history, int history_len,
const std::string& user_input,
bool stream)
{
json::object root;
root["model"] = g_cfg.model;
root["max_tokens"] = g_cfg.max_tokens;
root["stream"] = stream;
// 提取 system 消息作为顶层字段
std::string system_prompt;
json::array msgs;
for (int i = 0; i < history_len; ++i) {
const auto& m = history[i];
if (m.role && std::strcmp(m.role, "system") == 0) {
if (!system_prompt.empty()) system_prompt += "\n\n";
system_prompt += m.content ? m.content : "";
continue;
}
json::object obj;
obj["role"] = m.role ? m.role : "";
obj["content"] = m.content ? m.content : "";
msgs.push_back(obj);
}
// 追加当前用户输入
{
json::object obj;
obj["role"] = "user";
obj["content"] = user_input;
msgs.push_back(obj);
}
root["messages"] = msgs;
if (!system_prompt.empty()) {
root["system"] = system_prompt;
}
if (g_cfg.temperature >= 0.0 && g_cfg.temperature <= 1.0) {
root["temperature"] = g_cfg.temperature;
}
return json::serialize(root);
}
// ============================================================================
// 解析非流式响应
// ============================================================================
static void parse_response(const char* body, int http_status,
dstalk_chat_result_t& r)
{
r.http_status = http_status;
if (http_status < 200 || http_status >= 300) {
r.ok = 0;
try {
auto jv = json::parse(body ? body : "{}");
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = g_host->strdup(
json::value_to<std::string>(err["message"]).c_str());
}
} catch (...) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = g_host->strdup(msg.c_str());
}
if (!r.error) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = g_host->strdup(msg.c_str());
}
r.content = nullptr;
r.tool_calls_json = nullptr;
return;
}
try {
auto jv = json::parse(body ? body : "{}");
auto obj = jv.as_object();
auto content = obj["content"].as_array();
if (!content.empty()) {
// 取第一个 text block
for (const auto& block : content) {
auto bobj = block.as_object();
if (bobj.contains("type") &&
json::value_to<std::string>(bobj["type"]) == "text") {
std::string text = json::value_to<std::string>(bobj["text"]);
r.content = g_host->strdup(text.c_str());
r.ok = 1;
r.error = nullptr;
r.tool_calls_json = nullptr;
return;
}
}
r.ok = 0;
r.error = g_host->strdup("no text content block found");
} else {
r.ok = 0;
r.error = g_host->strdup("empty response");
}
r.content = nullptr;
r.tool_calls_json = nullptr;
} catch (std::exception& e) {
r.ok = 0;
std::string msg = std::string("json parse: ") + e.what();
r.error = g_host->strdup(msg.c_str());
r.content = nullptr;
r.tool_calls_json = nullptr;
} catch (...) {
r.ok = 0;
r.error = g_host->strdup("json parse error");
r.content = nullptr;
r.tool_calls_json = nullptr;
}
}
// ============================================================================
// SSE 事件解析Anthropic 格式: event/content_block_delta)
// ============================================================================
// 状态机:记录当前正在处理的事件类型
// 简化版:直接从 data: 行解析,不依赖 event: 行
static bool parse_sse_data(const std::string& data, std::string& token_out)
{
try {
auto jv = json::parse(data);
auto obj = jv.as_object();
auto* type_ptr = obj.if_contains("type");
if (!type_ptr || !type_ptr->is_string()) return false;
std::string type = json::value_to<std::string>(*type_ptr);
if (type == "content_block_delta") {
auto* delta = obj.if_contains("delta");
if (!delta || !delta->is_object()) return false;
auto& dobj = delta->as_object();
auto* dtype = dobj.if_contains("type");
if (!dtype || !dtype->is_string()) return false;
std::string delta_type = json::value_to<std::string>(*dtype);
if (delta_type == "text_delta") {
auto* text = dobj.if_contains("text");
if (text && text->is_string()) {
token_out = json::value_to<std::string>(*text);
return true;
}
}
} else if (type == "message_stop") {
token_out.clear();
return true; // 流结束
}
// 忽略: message_start, content_block_start, content_block_stop, ping, message_delta
} catch (...) {
// 解析失败忽略
}
return false;
}
// ============================================================================
// configure
// ============================================================================
static int my_configure(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature)
{
if (provider) g_cfg.provider = provider;
if (base_url) g_cfg.base_url = base_url;
if (api_key) g_cfg.api_key = api_key;
if (model) g_cfg.model = model;
g_cfg.max_tokens = max_tokens;
g_cfg.temperature = temperature;
if (g_host) {
g_host->log(DSTALK_LOG_INFO,
"[anthropic] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
g_cfg.model.c_str(), g_cfg.base_url.c_str(),
g_cfg.max_tokens, g_cfg.temperature);
}
return 0;
}
// ============================================================================
// chat
// ============================================================================
static dstalk_chat_result_t my_chat(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* /*tools_json*/)
{
dstalk_chat_result_t r = {};
r.ok = 0;
if (!g_http) {
r.error = g_host->strdup("http service not available");
return r;
}
std::string scheme, host, port, target;
extract_host_port(g_cfg.base_url, scheme, host, port, target);
std::string target_path = target + "/v1/messages";
std::string body = build_request_json(history, history_len,
user_input ? user_input : "", false);
std::string headers_json = build_headers_json();
char* response_body = nullptr;
int status_code = 0;
int ret = g_http->post_json(
host.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(), &response_body, &status_code);
if (ret != 0) {
r.error = g_host->strdup("http request failed");
return r;
}
parse_response(response_body, status_code, r);
if (response_body) {
g_host->free(response_body);
}
return r;
}
// ============================================================================
// chat_stream
// ============================================================================
struct StreamContext {
const dstalk_host_api_t* host;
dstalk_stream_cb user_cb;
void* userdata;
std::string accumulated;
bool saw_data_line = false;
};
// 行回调
static int sse_line_callback(const char* line, void* userdata)
{
auto* ctx = static_cast<StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续
std::string line_str(line);
// SSE 格式: "data: <json>"
if (line_str.rfind("data: ", 0) == 0) {
std::string data = line_str.substr(6);
std::string token;
if (parse_sse_data(data, token)) {
ctx->saw_data_line = true;
if (token.empty()) {
// message_stop
return 0;
}
ctx->accumulated += token;
if (ctx->user_cb) {
return ctx->user_cb(token.c_str(), ctx->userdata);
}
}
}
// "event: ..." 行和其他 -> 忽略
return 1;
}
static dstalk_chat_result_t my_chat_stream(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata)
{
dstalk_chat_result_t r = {};
r.ok = 0;
if (!g_http) {
r.error = g_host->strdup("http service not available");
return r;
}
std::string scheme, host, port, target;
extract_host_port(g_cfg.base_url, scheme, host, port, target);
std::string target_path = target + "/v1/messages";
std::string body = build_request_json(history, history_len,
user_input ? user_input : "", true);
std::string headers_json = build_headers_json();
StreamContext ctx;
ctx.host = g_host;
ctx.user_cb = cb;
ctx.userdata = userdata;
ctx.saw_data_line = false;
char* response_body = nullptr;
int status_code = 0;
int ret = g_http->post_stream(
host.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(),
sse_line_callback, &ctx,
&response_body, &status_code);
r.http_status = status_code;
// 检查错误状态
if (status_code < 200 || status_code >= 300) {
r.ok = 0;
if (response_body && response_body[0]) {
try {
auto jv = json::parse(response_body);
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = g_host->strdup(
json::value_to<std::string>(err["message"]).c_str());
}
} catch (...) {}
}
if (!r.error) {
if (status_code <= 0)
r.error = g_host->strdup("transport error");
else
r.error = g_host->strdup(
("HTTP " + std::to_string(status_code)).c_str());
}
if (response_body) g_host->free(response_body);
r.content = nullptr;
r.tool_calls_json = nullptr;
return r;
}
if (response_body) g_host->free(response_body);
if (ctx.accumulated.empty() && !ctx.saw_data_line) {
r.ok = 0;
r.error = g_host->strdup("no content received");
r.content = nullptr;
r.tool_calls_json = nullptr;
} else {
r.ok = 1;
r.error = nullptr;
r.content = g_host->strdup(ctx.accumulated.c_str());
r.tool_calls_json = nullptr;
}
return r;
}
// ============================================================================
// free_result
// ============================================================================
static void my_free_result(dstalk_chat_result_t* result)
{
if (!result || !g_host) return;
if (result->content) { g_host->free((void*)result->content); result->content = nullptr; }
if (result->error) { g_host->free((void*)result->error); result->error = nullptr; }
if (result->tool_calls_json) { g_host->free((void*)result->tool_calls_json); result->tool_calls_json = nullptr; }
}
// ============================================================================
// 服务 vtable
// ============================================================================
static dstalk_ai_service_t g_service = {
&my_configure,
&my_chat,
&my_chat_stream,
&my_free_result,
};
// ============================================================================
// 生命周期
// ============================================================================
static int on_init(const dstalk_host_api_t* host)
{
g_host = host;
g_http = (dstalk_http_service_t*)host->query_service("http", 1);
g_config = (dstalk_config_service_t*)host->query_service("config", 1);
if (!g_http) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[anthropic] http service not found");
return -1;
}
if (g_host) g_host->log(DSTALK_LOG_INFO, "[anthropic] initializing Anthropic AI plugin");
return host->register_service("ai.anthropic", 1, &g_service);
}
static void on_shutdown()
{
if (g_host) g_host->log(DSTALK_LOG_INFO, "[anthropic] shutdown");
g_http = nullptr;
g_config = nullptr;
g_host = nullptr;
}
// ============================================================================
// 插件描述符
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "anthropic-ai",
/* .version = */ "1.0.0",
/* .description = */ "Anthropic Claude AI provider (Messages API)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { "http", "config", NULL },
/* .on_init = */ on_init,
/* .on_shutdown = */ on_shutdown,
/* .on_event = */ nullptr,
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;
}

View File

@@ -1,13 +0,0 @@
add_library(plugin-config SHARED src/config_plugin.cpp)
target_include_directories(plugin-config PRIVATE
${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_link_libraries(plugin-config PRIVATE dstalk)
set_target_properties(plugin-config PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,146 +0,0 @@
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <string>
#include <unordered_map>
#include <mutex>
#include <fstream>
#include <sstream>
#include <cstdio>
// ============================================================
// ConfigStore - independent TOML key-value store
// ============================================================
namespace {
class ConfigStore {
public:
int load_file(const char* path) {
if (!path) return -1;
std::ifstream file(path);
if (!file.is_open()) return -1;
std::stringstream ss;
ss << file.rdbuf();
std::string data = ss.str();
std::string current_section;
size_t pos = 0;
while (pos < data.size()) {
while (pos < data.size() && (data[pos] == ' ' || data[pos] == '\t'))
pos++;
if (pos >= data.size()) break;
size_t nl = data.find('\n', pos);
std::string line = (nl != std::string::npos)
? data.substr(pos, nl - pos) : data.substr(pos);
pos = (nl != std::string::npos) ? nl + 1 : data.size();
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
line.pop_back();
if (line.empty() || line[0] == '#') continue;
if (line[0] == '[' && line.back() == ']') {
current_section = line.substr(1, line.size() - 2);
continue;
}
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
while (!key.empty() && key.back() == ' ') key.pop_back();
if (key.empty()) continue;
std::string val = line.substr(eq + 1);
while (!val.empty() && (val.front() == ' ' || val.front() == '\t'))
val.erase(0, 1);
if (val.size() >= 2 && val.front() == '"' && val.back() == '"')
val = val.substr(1, val.size() - 2);
std::lock_guard<std::mutex> lock(mutex_);
std::string full_key = current_section.empty()
? key : current_section + "." + key;
data_[full_key] = val;
}
return 0;
}
const char* get(const char* key) const {
if (!key) return nullptr;
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
return it->second.c_str();
}
int set(const char* key, const char* value) {
if (!key || !value) return -1;
std::lock_guard<std::mutex> lock(mutex_);
data_[key] = value;
return 0;
}
private:
mutable std::mutex mutex_;
std::unordered_map<std::string, std::string> data_;
};
} // anonymous namespace
// ============================================================
// Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static ConfigStore g_config;
// ============================================================
// Service implementations
// ============================================================
static const char* config_get(const char* key) {
return g_config.get(key);
}
static int config_set(const char* key, const char* value) {
return g_config.set(key, value);
}
static int config_load_file(const char* path) {
return g_config.load_file(path);
}
static dstalk_config_service_t g_service = {
config_get,
config_set,
config_load_file
};
// ============================================================
// Plugin lifecycle
// ============================================================
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
return host->register_service("config", 1, &g_service);
}
static void on_shutdown() {
// nothing to clean up
}
static dstalk_plugin_info_t g_info = {
"config", // name
"1.0.0", // version
"Configuration service with TOML file support", // description
DSTALK_API_VERSION, // api_version
{nullptr}, // dependencies (none)
on_init, // on_init
on_shutdown, // on_shutdown
nullptr // on_event
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -1,13 +0,0 @@
add_library(plugin-context SHARED src/context_plugin.cpp)
target_include_directories(plugin-context PRIVATE
${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_link_libraries(plugin-context PRIVATE dstalk)
set_target_properties(plugin-context PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,289 +0,0 @@
// plugin-context: 上下文管理服务插件
// 提供 dstalk_context_service_t vtable 实现
// 依赖: session (获取历史消息做 token 计数)
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_types.h"
#include "dstalk/dstalk_services.h"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
// ============================================================
// 全局状态
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static size_t g_max_tokens = 4096;
// ============================================================
// 内部 C++ 辅助token 计数
// ============================================================
static bool cjk_is_ascii(unsigned char c) { return c < 0x80; }
static bool cjk_starts_cjk(unsigned char c) {
// U+4E00-U+9FFF 在 UTF-8 中编码为 0xE4-0xE9 开头的三字节
return c >= 0xE4 && c <= 0xE9;
}
static size_t count_tokens_one_message(const dstalk_message_t& msg) {
const char* text = msg.content;
if (!text) return 4; // 只有 overhead
size_t ascii_chars = 0;
size_t chinese_chars = 0;
size_t other_chars = 0;
size_t i = 0;
while (text[i] != '\0') {
unsigned char c = static_cast<unsigned char>(text[i]);
if (cjk_is_ascii(c)) {
ascii_chars++;
i += 1;
} else if (cjk_starts_cjk(c)) {
chinese_chars++;
i += 3;
} else if (c >= 0xC0 && c < 0xE0) {
other_chars++;
i += 2;
} else if (c >= 0xE0 && c < 0xF0) {
other_chars++;
i += 3;
} else if (c >= 0xF0 && c < 0xF8) {
other_chars++;
i += 4;
} else {
other_chars++;
i += 1;
}
}
size_t content_tokens = (ascii_chars / 4) + (chinese_chars / 2) + (other_chars / 3);
return content_tokens + 4; // +4 条消息开销 (role + separators)
}
static size_t count_tokens_all(const dstalk_message_t* msgs, int count) {
size_t total = 0;
for (int i = 0; i < count; ++i) {
total += count_tokens_one_message(msgs[i]);
}
return total;
}
// ============================================================
// 内部 trim 逻辑
// ============================================================
// 为 trim 操作将 C 消息数组复制到内部 struct
struct TrimMessage {
std::string role;
std::string content;
std::string tool_call_id;
std::string tool_calls_json;
};
static size_t count_tokens_trim(const TrimMessage& msg) {
if (msg.content.empty()) return 4;
const std::string& text = msg.content;
size_t ascii_chars = 0, chinese_chars = 0, other_chars = 0;
size_t i = 0;
while (i < text.size()) {
unsigned char c = static_cast<unsigned char>(text[i]);
if (cjk_is_ascii(c)) { ascii_chars++; i += 1; }
else if (cjk_starts_cjk(c)) { chinese_chars++; i += 3; }
else if (c >= 0xC0 && c < 0xE0) { other_chars++; i += 2; }
else if (c >= 0xE0 && c < 0xF0) { other_chars++; i += 3; }
else if (c >= 0xF0 && c < 0xF8) { other_chars++; i += 4; }
else { other_chars++; i += 1; }
}
return (ascii_chars / 4) + (chinese_chars / 2) + (other_chars / 3) + 4;
}
static size_t count_tokens_trim_vec(const std::vector<TrimMessage>& msgs) {
size_t total = 0;
for (const auto& m : msgs) total += count_tokens_trim(m);
return total;
}
static int trim_impl(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
if (!in || in_count <= 0 || !out || !out_count) return -1;
// 将 C 数组转换为内部 vector
std::vector<TrimMessage> messages;
messages.reserve(in_count);
for (int i = 0; i < in_count; ++i) {
TrimMessage tm;
if (in[i].role) tm.role = in[i].role;
if (in[i].content) tm.content = in[i].content;
if (in[i].tool_call_id) tm.tool_call_id = in[i].tool_call_id;
if (in[i].tool_calls_json) tm.tool_calls_json = in[i].tool_calls_json;
messages.push_back(std::move(tm));
}
// 如果已在限制内,直接返回完整副本
size_t current = count_tokens_trim_vec(messages);
if (current <= max_tokens) {
*out_count = in_count;
*out = static_cast<dstalk_message_t*>(g_host->alloc(sizeof(dstalk_message_t) * in_count));
if (!*out) return -1;
for (int i = 0; i < in_count; ++i) {
(*out)[i].role = messages[i].role.empty() ? nullptr : g_host->strdup(messages[i].role.c_str());
(*out)[i].content = messages[i].content.empty() ? nullptr : g_host->strdup(messages[i].content.c_str());
(*out)[i].tool_call_id = messages[i].tool_call_id.empty() ? nullptr : g_host->strdup(messages[i].tool_call_id.c_str());
(*out)[i].tool_calls_json = messages[i].tool_calls_json.empty() ? nullptr : g_host->strdup(messages[i].tool_calls_json.c_str());
}
return 0;
}
// 分离 system 消息和非 system 消息
std::vector<TrimMessage> system_msgs;
std::vector<TrimMessage> non_system_msgs;
for (const auto& msg : messages) {
if (msg.role == "system") {
system_msgs.push_back(msg);
} else {
non_system_msgs.push_back(msg);
}
}
size_t system_tokens = count_tokens_trim_vec(system_msgs);
if (system_tokens > max_tokens) {
std::fprintf(stderr, "[context] WARNING: system messages alone "
"(%zu tokens) exceed max_context_tokens (%zu)\n",
system_tokens, max_tokens);
}
// 检查是否有单条消息超过限制
for (const auto& msg : non_system_msgs) {
size_t msg_tokens = count_tokens_trim(msg);
if (msg_tokens > max_tokens) {
std::fprintf(stderr, "[context] WARNING: single message "
"(%s, %zu tokens) exceeds max_context_tokens (%zu). "
"Returning empty list.\n",
msg.role.c_str(), msg_tokens, max_tokens);
*out = nullptr;
*out_count = 0;
return -1;
}
}
// 从最早的非 system 消息开始裁剪,确保 user/assistant 成对移除
while (!non_system_msgs.empty()) {
current = system_tokens + count_tokens_trim_vec(non_system_msgs);
if (current <= max_tokens) break;
// 找第一个 "user" 消息
auto user_it = non_system_msgs.begin();
while (user_it != non_system_msgs.end() && user_it->role != "user") {
++user_it;
}
if (user_it == non_system_msgs.end()) break;
// 找下一个 "assistant"
auto assistant_it = user_it + 1;
while (assistant_it != non_system_msgs.end() && assistant_it->role != "assistant") {
++assistant_it;
}
if (assistant_it == non_system_msgs.end()) {
non_system_msgs.erase(user_it);
} else {
// 先删 assistant 再删 user 避免迭代器失效
non_system_msgs.erase(assistant_it);
user_it = non_system_msgs.begin();
while (user_it != non_system_msgs.end() && user_it->role != "user") ++user_it;
if (user_it != non_system_msgs.end()) non_system_msgs.erase(user_it);
}
}
// 组装结果
std::vector<TrimMessage> result;
result.reserve(system_msgs.size() + non_system_msgs.size());
result.insert(result.end(), system_msgs.begin(), system_msgs.end());
result.insert(result.end(), non_system_msgs.begin(), non_system_msgs.end());
int result_count = static_cast<int>(result.size());
*out_count = result_count;
*out = static_cast<dstalk_message_t*>(g_host->alloc(sizeof(dstalk_message_t) * result_count));
if (!*out) return -1;
for (int i = 0; i < result_count; ++i) {
(*out)[i].role = result[i].role.empty() ? nullptr : g_host->strdup(result[i].role.c_str());
(*out)[i].content = result[i].content.empty() ? nullptr : g_host->strdup(result[i].content.c_str());
(*out)[i].tool_call_id = result[i].tool_call_id.empty() ? nullptr : g_host->strdup(result[i].tool_call_id.c_str());
(*out)[i].tool_calls_json = result[i].tool_calls_json.empty() ? nullptr : g_host->strdup(result[i].tool_calls_json.c_str());
}
return 0;
}
// ============================================================
// Context 服务 vtable 实现
// ============================================================
static size_t context_count_tokens(const dstalk_message_t* msgs, int count) {
if (!msgs || count <= 0) return 0;
return count_tokens_all(msgs, count);
}
static int context_trim(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
return trim_impl(in, in_count, out, out_count, max_tokens);
}
static void context_set_max_tokens(size_t max) {
g_max_tokens = max;
}
static dstalk_context_service_t g_context_service = {
context_count_tokens,
context_trim,
context_set_max_tokens
};
// ============================================================
// 插件生命周期
// ============================================================
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
// 查询依赖服务: session
void* raw = host->query_service("session", 1);
if (!raw) {
host->log(DSTALK_LOG_ERROR, "[plugin-context] required service 'session' not found");
return -1;
}
g_session = static_cast<const dstalk_session_service_t*>(raw);
return host->register_service("context", 1, &g_context_service);
}
static void on_shutdown() {
g_session = nullptr;
g_host = nullptr;
}
static dstalk_plugin_info_t g_info = {
"context",
"1.0.0",
"Context management plugin with token counting and trim support",
DSTALK_API_VERSION,
{"session", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr},
on_init,
on_shutdown,
nullptr
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -1,32 +0,0 @@
cmake_minimum_required(VERSION 3.21)
# ============================================================
# plugin-deepseek — DeepSeek AI 服务 (OpenAI 兼容)
# 依赖: http 服务 (查询), config 服务 (查询)
# ============================================================
add_library(plugin-deepseek SHARED
src/deepseek_plugin.cpp
)
target_include_directories(plugin-deepseek PRIVATE
${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_link_libraries(plugin-deepseek PRIVATE dstalk)
# Boost.JSON 用于构建/解析请求和响应
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-deepseek PRIVATE boost::boost)
target_compile_definitions(plugin-deepseek PRIVATE
BOOST_ALL_NO_LIB
BOOST_ERROR_CODE_HEADER_ONLY
BOOST_JSON_HEADER_ONLY
)
set_target_properties(plugin-deepseek PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,475 +0,0 @@
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/json.hpp>
#include <cstring>
#include <string>
namespace json = boost::json;
// ============================================================================
// 全局指针:从 on_init 获取
// ============================================================================
static const dstalk_host_api_t* g_host = nullptr;
static dstalk_http_service_t* g_http = nullptr;
static dstalk_config_service_t* g_config = nullptr;
// ============================================================================
// 配置数据(由 configure() 设置)
// ============================================================================
struct PluginConfig {
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
static PluginConfig g_cfg;
// ============================================================================
// 辅助:从 base_url 提取 host 和 target
// ============================================================================
static bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out)
{
size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos) return false;
scheme_out = url.substr(0, scheme_end);
std::string rest = url.substr(scheme_end + 3);
size_t slash = rest.find('/');
std::string authority = (slash != std::string::npos) ? rest.substr(0, slash) : rest;
target_out = (slash != std::string::npos) ? rest.substr(slash) : "/";
size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
host_out = authority.substr(0, colon);
port_out = authority.substr(colon + 1);
} else {
host_out = authority;
port_out = (scheme_out == "https") ? "443" : "80";
}
return true;
}
// ============================================================================
// 辅助:构建 headers JSON 字符串
// ============================================================================
static std::string build_headers_json(const std::string& auth_header_value)
{
json::object h;
h["Authorization"] = "Bearer " + auth_header_value;
return json::serialize(h);
}
// ============================================================================
// 辅助dstalk_message_t[] -> boost::json::array
// ============================================================================
static void append_history(json::array& msgs,
const dstalk_message_t* history, int history_len)
{
for (int i = 0; i < history_len; ++i) {
const auto& m = history[i];
json::object obj;
obj["role"] = m.role ? m.role : "";
if (m.role && std::strcmp(m.role, "tool") == 0) {
obj["tool_call_id"] = m.tool_call_id ? m.tool_call_id : "";
obj["content"] = m.content ? m.content : "";
} else if (m.role && std::strcmp(m.role, "assistant") == 0 &&
m.tool_calls_json && m.tool_calls_json[0] != '\0') {
obj["content"] = m.content ? m.content : "";
obj["tool_calls"] = json::parse(m.tool_calls_json);
} else {
obj["content"] = m.content ? m.content : "";
}
msgs.push_back(obj);
}
}
// ============================================================================
// 构建 DeepSeek JSON 请求体
// ============================================================================
static std::string build_request_json(
const dstalk_message_t* history, int history_len,
const std::string& user_input,
const std::string& tools_json,
bool stream)
{
json::object root;
root["model"] = g_cfg.model;
root["max_tokens"] = g_cfg.max_tokens;
root["temperature"] = g_cfg.temperature;
root["stream"] = stream;
json::array msgs;
append_history(msgs, history, history_len);
// 追加当前用户输入
if (!user_input.empty()) {
json::object obj;
obj["role"] = "user";
obj["content"] = user_input;
msgs.push_back(obj);
}
root["messages"] = msgs;
// tools 定义
if (!tools_json.empty()) {
root["tools"] = json::parse(tools_json);
}
return json::serialize(root);
}
// ============================================================================
// 解析非流式 JSON 响应
// ============================================================================
static void parse_response(const char* body, int http_status,
dstalk_chat_result_t& r)
{
r.http_status = http_status;
if (http_status < 200 || http_status >= 300) {
r.ok = 0;
try {
auto jv = json::parse(body ? body : "{}");
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = g_host->strdup(
json::value_to<std::string>(err["message"]).c_str());
}
} catch (...) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = g_host->strdup(msg.c_str());
}
if (!r.error) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = g_host->strdup(msg.c_str());
}
r.content = nullptr;
r.tool_calls_json = nullptr;
return;
}
try {
auto jv = json::parse(body ? body : "{}");
auto obj = jv.as_object();
auto choices = obj["choices"].as_array();
if (!choices.empty()) {
auto msg = choices[0].as_object()["message"].as_object();
std::string content = json::value_to<std::string>(msg["content"]);
r.content = g_host->strdup(content.c_str());
if (msg.contains("tool_calls")) {
std::string tc = json::serialize(msg["tool_calls"]);
r.tool_calls_json = g_host->strdup(tc.c_str());
} else {
r.tool_calls_json = nullptr;
}
r.ok = 1;
r.error = nullptr;
} else {
r.ok = 0;
r.error = g_host->strdup("empty response");
r.content = nullptr;
r.tool_calls_json = nullptr;
}
} catch (std::exception& e) {
r.ok = 0;
std::string msg = std::string("json parse: ") + e.what();
r.error = g_host->strdup(msg.c_str());
r.content = nullptr;
r.tool_calls_json = nullptr;
} catch (...) {
r.ok = 0;
r.error = g_host->strdup("json parse error");
r.content = nullptr;
r.tool_calls_json = nullptr;
}
}
// ============================================================================
// SSE 行解析OpenAI 兼容格式)
// ============================================================================
static bool parse_sse_line(const std::string& line, std::string& token_out)
{
if (line.rfind("data: ", 0) != 0) return false;
std::string data = line.substr(6);
if (data == "[DONE]") {
token_out.clear();
return true; // 流结束信号
}
try {
auto jv = json::parse(data);
auto obj = jv.as_object();
auto choices = obj["choices"].as_array();
if (!choices.empty()) {
auto delta = choices[0].as_object()["delta"].as_object();
if (delta.contains("content")) {
token_out = json::value_to<std::string>(delta["content"]);
return true;
}
}
} catch (...) {
// 忽略解析失败
}
return false;
}
// ============================================================================
// configure 实现
// ============================================================================
static int my_configure(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature)
{
if (provider) g_cfg.provider = provider;
if (base_url) g_cfg.base_url = base_url;
if (api_key) g_cfg.api_key = api_key;
if (model) g_cfg.model = model;
g_cfg.max_tokens = max_tokens;
g_cfg.temperature = temperature;
if (g_host) {
g_host->log(DSTALK_LOG_INFO,
"[deepseek] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
g_cfg.model.c_str(), g_cfg.base_url.c_str(),
g_cfg.max_tokens, g_cfg.temperature);
}
return 0;
}
// ============================================================================
// chat 实现
// ============================================================================
static dstalk_chat_result_t my_chat(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json)
{
dstalk_chat_result_t r = {};
r.ok = 0;
if (!g_http) {
r.error = g_host->strdup("http service not available");
return r;
}
std::string scheme, host, port, target;
extract_host_port(g_cfg.base_url, scheme, host, port, target);
std::string target_path = target + "/chat/completions";
std::string body = build_request_json(history, history_len,
user_input ? user_input : "", tools_json ? tools_json : "", false);
std::string headers_json = build_headers_json(g_cfg.api_key);
char* response_body = nullptr;
int status_code = 0;
int ret = g_http->post_json(
host.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(), &response_body, &status_code);
if (ret != 0) {
r.error = g_host->strdup("http request failed");
return r;
}
parse_response(response_body, status_code, r);
if (response_body) {
g_host->free(response_body);
}
return r;
}
// ============================================================================
// chat_stream 实现
// ============================================================================
// 回调上下文:在流式传输中收集累积内容和最终状态
struct StreamContext {
const dstalk_host_api_t* host;
dstalk_stream_cb user_cb;
void* userdata;
std::string accumulated;
bool streaming_ok = true;
};
// 行回调:解析 SSE line将 token 传递给用户回调
static int sse_line_callback(const char* line, void* userdata)
{
auto* ctx = static_cast<StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续
std::string line_str(line);
std::string token;
if (!parse_sse_line(line_str, token)) return 1; // 非 data 行,继续
if (token.empty()) return 0; // [DONE],停止
ctx->accumulated += token;
if (ctx->user_cb) {
return ctx->user_cb(token.c_str(), ctx->userdata);
}
return 1; // 继续
}
static dstalk_chat_result_t my_chat_stream(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata)
{
dstalk_chat_result_t r = {};
r.ok = 0;
if (!g_http) {
r.error = g_host->strdup("http service not available");
return r;
}
std::string scheme, host, port, target;
extract_host_port(g_cfg.base_url, scheme, host, port, target);
std::string target_path = target + "/chat/completions";
std::string body = build_request_json(history, history_len,
user_input ? user_input : "", "", true); // stream=true, no tools
std::string headers_json = build_headers_json(g_cfg.api_key);
StreamContext ctx;
ctx.host = g_host;
ctx.user_cb = cb;
ctx.userdata = userdata;
char* response_body = nullptr;
int status_code = 0;
int ret = g_http->post_stream(
host.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(),
sse_line_callback, &ctx,
&response_body, &status_code);
r.http_status = status_code;
// 检查传输层错误或非 2xx 状态
if (status_code < 200 || status_code >= 300) {
r.ok = 0;
// 尝试从响应体提取错误信息
if (response_body && response_body[0]) {
try {
auto jv = json::parse(response_body);
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = g_host->strdup(
json::value_to<std::string>(err["message"]).c_str());
}
} catch (...) {}
}
if (!r.error) {
if (status_code <= 0)
r.error = g_host->strdup("transport error");
else
r.error = g_host->strdup(
("HTTP " + std::to_string(status_code)).c_str());
}
if (response_body) g_host->free(response_body);
r.content = nullptr;
r.tool_calls_json = nullptr;
return r;
}
if (response_body) g_host->free(response_body);
if (ctx.accumulated.empty()) {
r.ok = 0;
r.error = g_host->strdup("no content received");
r.content = nullptr;
r.tool_calls_json = nullptr;
} else {
r.ok = 1;
r.error = nullptr;
r.content = g_host->strdup(ctx.accumulated.c_str());
r.tool_calls_json = nullptr;
}
return r;
}
// ============================================================================
// free_result 实现
// ============================================================================
static void my_free_result(dstalk_chat_result_t* result)
{
if (!result || !g_host) return;
if (result->content) { g_host->free((void*)result->content); result->content = nullptr; }
if (result->error) { g_host->free((void*)result->error); result->error = nullptr; }
if (result->tool_calls_json) { g_host->free((void*)result->tool_calls_json); result->tool_calls_json = nullptr; }
}
// ============================================================================
// 服务 vtable
// ============================================================================
static dstalk_ai_service_t g_service = {
&my_configure,
&my_chat,
&my_chat_stream,
&my_free_result,
};
// ============================================================================
// 生命周期
// ============================================================================
static int on_init(const dstalk_host_api_t* host)
{
g_host = host;
g_http = (dstalk_http_service_t*)host->query_service("http", 1);
g_config = (dstalk_config_service_t*)host->query_service("config", 1);
if (!g_http) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[deepseek] http service not found");
return -1;
}
if (g_host) g_host->log(DSTALK_LOG_INFO, "[deepseek] initializing DeepSeek AI plugin");
return host->register_service("ai.deepseek", 1, &g_service);
}
static void on_shutdown()
{
if (g_host) g_host->log(DSTALK_LOG_INFO, "[deepseek] shutdown");
g_http = nullptr;
g_config = nullptr;
g_host = nullptr;
}
// ============================================================================
// 插件描述符
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "deepseek-ai",
/* .version = */ "1.0.0",
/* .description = */ "DeepSeek AI provider (OpenAI-compatible API)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { "http", "config", NULL },
/* .on_init = */ on_init,
/* .on_shutdown = */ on_shutdown,
/* .on_event = */ nullptr,
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;
}

View File

@@ -1,13 +0,0 @@
add_library(plugin-file-io SHARED src/file_io_plugin.cpp)
target_include_directories(plugin-file-io PRIVATE
${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_link_libraries(plugin-file-io PRIVATE dstalk)
set_target_properties(plugin-file-io PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,733 +0,0 @@
/*
* plugin-lsp — LSP (Language Server Protocol) 服务
*
* 自行管理语言服务器子进程,使用 JSON-RPC 2.0 over stdio 通信。
* 无外部服务依赖(不依赖 http/config 等其他插件)。
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/json.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <mutex>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
// ============================================================================
// 平台相关 — 子进程管理 (内嵌 subprocess::Process)
// ============================================================================
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <cstdlib>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace json = boost::json;
// ============================================================================
// 全局指针
// ============================================================================
static const dstalk_host_api_t* g_host = nullptr;
// ============================================================================
// 子进程封装 (内嵌 subprocess.hpp)
// ============================================================================
struct Process {
#ifdef _WIN32
HANDLE hProcess = INVALID_HANDLE_VALUE;
HANDLE hThread = INVALID_HANDLE_VALUE;
HANDLE hStdIn = INVALID_HANDLE_VALUE;
HANDLE hStdOut = INVALID_HANDLE_VALUE;
#else
pid_t pid = -1;
int stdin_fd = -1;
int stdout_fd = -1;
#endif
bool start(const char* cmd) {
if (!cmd || !cmd[0]) return false;
stop();
#ifdef _WIN32
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.bInheritHandle = TRUE;
HANDLE child_stdin_read = INVALID_HANDLE_VALUE;
HANDLE child_stdout_write = INVALID_HANDLE_VALUE;
if (!CreatePipe(&child_stdin_read, &hStdIn, &sa, 0)) goto win32_fail;
if (!SetHandleInformation(hStdIn, HANDLE_FLAG_INHERIT, 0)) goto win32_fail;
if (!CreatePipe(&hStdOut, &child_stdout_write, &sa, 0)) goto win32_fail;
if (!SetHandleInformation(hStdOut, HANDLE_FLAG_INHERIT, 0)) goto win32_fail;
{
STARTUPINFOW si = {};
si.cb = sizeof(STARTUPINFOW);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
si.hStdInput = child_stdin_read;
si.hStdOutput = child_stdout_write;
si.hStdError = child_stdout_write;
PROCESS_INFORMATION pi = {};
std::string cmd_copy(cmd);
wchar_t wcmd[4096] = {};
if (MultiByteToWideChar(CP_UTF8, 0, cmd_copy.c_str(), -1, wcmd, 4096) == 0)
goto win32_fail;
if (!CreateProcessW(nullptr, wcmd, nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
goto win32_fail;
hProcess = pi.hProcess;
hThread = pi.hThread;
}
CloseHandle(child_stdin_read);
CloseHandle(child_stdout_write);
return true;
win32_fail:
if (child_stdin_read != INVALID_HANDLE_VALUE) CloseHandle(child_stdin_read);
if (child_stdout_write != INVALID_HANDLE_VALUE) CloseHandle(child_stdout_write);
if (hStdIn != INVALID_HANDLE_VALUE) { CloseHandle(hStdIn); hStdIn = INVALID_HANDLE_VALUE; }
if (hStdOut != INVALID_HANDLE_VALUE) { CloseHandle(hStdOut); hStdOut = INVALID_HANDLE_VALUE; }
if (hProcess != INVALID_HANDLE_VALUE) { CloseHandle(hProcess); hProcess = INVALID_HANDLE_VALUE; }
if (hThread != INVALID_HANDLE_VALUE) { CloseHandle(hThread); hThread = INVALID_HANDLE_VALUE; }
return false;
#else
int pin[2] = {-1, -1};
int pout[2] = {-1, -1};
if (pipe(pin) != 0) goto posix_fail;
if (pipe(pout) != 0) goto posix_fail;
pid = fork();
if (pid < 0) goto posix_fail;
if (pid == 0) {
dup2(pin[0], STDIN_FILENO);
close(pin[0]); close(pin[1]);
dup2(pout[1], STDOUT_FILENO);
close(pout[0]); close(pout[1]);
int max_fd = static_cast<int>(sysconf(_SC_OPEN_MAX));
if (max_fd > 3) {
for (int i = 3; i < max_fd; ++i) close(i);
}
char* argv[64] = {};
int argc = 0;
char* cmd_copy = strdup(cmd);
char* token = strtok(cmd_copy, " \t");
while (token && argc < 63) {
argv[argc++] = token;
token = strtok(nullptr, " \t");
}
argv[argc] = nullptr;
execvp(argv[0], argv);
_exit(127);
}
close(pin[0]);
close(pout[1]);
stdin_fd = pin[1];
stdout_fd = pout[0];
return true;
posix_fail:
if (pin[0] != -1) close(pin[0]);
if (pin[1] != -1) close(pin[1]);
if (pout[0] != -1) close(pout[0]);
if (pout[1] != -1) close(pout[1]);
stdin_fd = -1;
stdout_fd = -1;
pid = -1;
return false;
#endif
}
void stop() {
#ifdef _WIN32
if (hProcess != INVALID_HANDLE_VALUE) {
WaitForSingleObject(hProcess, 2000);
TerminateProcess(hProcess, 1);
CloseHandle(hProcess); hProcess = INVALID_HANDLE_VALUE;
}
if (hThread != INVALID_HANDLE_VALUE) { CloseHandle(hThread); hThread = INVALID_HANDLE_VALUE; }
if (hStdIn != INVALID_HANDLE_VALUE) { CloseHandle(hStdIn); hStdIn = INVALID_HANDLE_VALUE; }
if (hStdOut != INVALID_HANDLE_VALUE) { CloseHandle(hStdOut); hStdOut = INVALID_HANDLE_VALUE; }
#else
if (pid > 0) {
kill(pid, SIGTERM);
int status = 0;
for (int i = 0; i < 20; ++i) {
if (waitpid(pid, &status, WNOHANG) > 0) break;
usleep(100000);
}
if (waitpid(pid, &status, WNOHANG) == 0) {
kill(pid, SIGKILL);
waitpid(pid, &status, 0);
}
pid = -1;
}
if (stdin_fd != -1) { close(stdin_fd); stdin_fd = -1; }
if (stdout_fd != -1) { close(stdout_fd); stdout_fd = -1; }
#endif
}
bool write(const std::string& data) {
if (data.empty()) return true;
#ifdef _WIN32
if (hStdIn == INVALID_HANDLE_VALUE) return false;
DWORD written = 0;
return WriteFile(hStdIn, data.c_str(), static_cast<DWORD>(data.size()), &written, nullptr)
&& written == data.size();
#else
if (stdin_fd < 0) return false;
size_t total = 0;
const char* buf = data.c_str();
size_t len = data.size();
while (total < len) {
ssize_t n = ::write(stdin_fd, buf + total, len - total);
if (n <= 0) return false;
total += static_cast<size_t>(n);
}
return true;
#endif
}
bool read_line(std::string& line) {
line.clear();
#ifdef _WIN32
if (hStdOut == INVALID_HANDLE_VALUE) return false;
char ch; DWORD nread = 0;
while (true) {
if (!ReadFile(hStdOut, &ch, 1, &nread, nullptr)) return false;
if (nread == 0) return false;
line += ch;
if (ch == '\n') return true;
}
#else
if (stdout_fd < 0) return false;
char ch;
while (true) {
ssize_t n = ::read(stdout_fd, &ch, 1);
if (n <= 0) return false;
line += ch;
if (ch == '\n') return true;
}
#endif
}
bool read_bytes(std::string& buf, int count) {
if (count <= 0) { buf.clear(); return true; }
#ifdef _WIN32
if (hStdOut == INVALID_HANDLE_VALUE) return false;
buf.resize(static_cast<size_t>(count) + 1);
DWORD total = 0, nread = 0;
while (total < static_cast<DWORD>(count)) {
if (!ReadFile(hStdOut, const_cast<char*>(buf.data()) + total,
static_cast<DWORD>(count) - total, &nread, nullptr))
return false;
if (nread == 0) return false;
total += nread;
}
buf[count] = '\0';
buf.resize(count);
return true;
#else
if (stdout_fd < 0) return false;
buf.resize(count);
size_t total = 0;
while (total < static_cast<size_t>(count)) {
ssize_t n = ::read(stdout_fd, const_cast<char*>(buf.data()) + total,
static_cast<size_t>(count) - total);
if (n <= 0) return false;
total += static_cast<size_t>(n);
}
return true;
#endif
}
};
// ============================================================================
// LSP 状态(静态单例)
// ============================================================================
struct LspState {
Process proc;
std::atomic<bool> running{false};
std::string language;
std::atomic<int> next_id{1};
// 响应用于同步等待
std::mutex mutex;
std::condition_variable cv;
std::unordered_map<int, std::string> pending_responses;
// 诊断缓存: URI -> JSON 字符串
std::unordered_map<std::string, std::string> diagnostics;
// 读取线程
std::thread reader_thread;
};
static LspState g_lsp;
// ============================================================================
// 辅助函数
// ============================================================================
static std::string_view trim(std::string_view sv) {
while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t' ||
sv.front() == '\r' || sv.front() == '\n'))
sv.remove_prefix(1);
while (!sv.empty() && (sv.back() == ' ' || sv.back() == '\t' ||
sv.back() == '\r' || sv.back() == '\n'))
sv.remove_suffix(1);
return sv;
}
static std::string frame_message(const std::string& body) {
std::string frame;
frame.reserve(64 + body.size());
frame += "Content-Length: ";
frame += std::to_string(body.size());
frame += "\r\n\r\n";
frame += body;
return frame;
}
static int parse_content_length(const std::string& line) {
auto sv = trim(std::string_view(line));
const char prefix[] = "Content-Length:";
const size_t prefix_len = sizeof(prefix) - 1;
if (sv.size() <= prefix_len) return -1;
for (size_t i = 0; i < prefix_len; ++i) {
if (std::tolower(static_cast<unsigned char>(sv[i])) !=
std::tolower(static_cast<unsigned char>(prefix[i])))
return -1;
}
std::string_view num_sv = sv.substr(prefix_len);
while (!num_sv.empty() && (num_sv.front() == ' ' || num_sv.front() == '\t'))
num_sv.remove_prefix(1);
try { return std::stoi(std::string(num_sv)); }
catch (...) { return -1; }
}
// ============================================================================
// JSON-RPC 消息发送
// ============================================================================
static int send_request(const std::string& method, const json::object& params) {
int id = g_lsp.next_id.fetch_add(1);
json::object msg;
msg["jsonrpc"] = "2.0";
msg["id"] = id;
msg["method"] = method;
msg["params"] = params;
std::string body = json::serialize(msg);
g_lsp.proc.write(frame_message(body));
return id;
}
static void send_notification(const std::string& method, const json::object& params) {
json::object msg;
msg["jsonrpc"] = "2.0";
msg["method"] = method;
msg["params"] = params;
std::string body = json::serialize(msg);
g_lsp.proc.write(frame_message(body));
}
// ============================================================================
// 消息处理
// ============================================================================
static void handle_message(const std::string& body) {
json::value val;
try { val = json::parse(body); }
catch (...) { return; }
json::object msg;
try { msg = val.as_object(); }
catch (...) { return; }
if (msg.contains("id") && !msg.contains("method")) {
// 响应 (有 id, 无 method)
int id = static_cast<int>(msg["id"].as_int64());
std::lock_guard<std::mutex> lock(g_lsp.mutex);
g_lsp.pending_responses[id] = body;
g_lsp.cv.notify_all();
} else if (msg.contains("method") && !msg.contains("id")) {
// 通知 (有 method, 无 id)
std::string method;
try { method = json::value_to<std::string>(msg["method"]); }
catch (...) { return; }
if (method == "textDocument/publishDiagnostics") {
if (!msg.contains("params")) return;
auto params = msg["params"].as_object();
if (!params.contains("uri")) return;
std::string uri = json::value_to<std::string>(params["uri"]);
std::string diag_json;
if (params.contains("diagnostics"))
diag_json = json::serialize(params["diagnostics"]);
else
diag_json = "[]";
std::lock_guard<std::mutex> lock(g_lsp.mutex);
g_lsp.diagnostics[uri] = diag_json;
}
}
}
// ============================================================================
// 读取线程主循环
// ============================================================================
static void reader_loop() {
while (g_lsp.running) {
std::string header_line;
if (!g_lsp.proc.read_line(header_line)) break;
int content_length = parse_content_length(header_line);
if (content_length < 0) continue;
// 跳过后续头直到空行 (\r\n 换行被视为非空行,只检查空行)
while (true) {
std::string line;
if (!g_lsp.proc.read_line(line)) break;
auto sv = trim(std::string_view(line));
if (sv.empty()) break;
}
std::string body;
if (!g_lsp.proc.read_bytes(body, content_length)) break;
handle_message(body);
}
std::lock_guard<std::mutex> lock(g_lsp.mutex);
g_lsp.running = false;
g_lsp.cv.notify_all();
}
// ============================================================================
// LSP 服务 vtable 实现 (定义在 vtable 变量之前)
// ============================================================================
static int g_lsp_impl_stop();
static int g_lsp_impl_start(const char* server_cmd, const char* language) {
if (!server_cmd || !server_cmd[0]) return -1;
// 如果已在运行, 先停止
if (g_lsp.running) {
g_lsp_impl_stop();
}
g_lsp.language = language ? language : "";
// 启动进程
if (!g_lsp.proc.start(server_cmd)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[lsp] failed to start: %s", server_cmd);
return -1;
}
// 重置 ID 计数器
g_lsp.next_id = 1;
// 启动读取线程
g_lsp.running = true;
g_lsp.reader_thread = std::thread(reader_loop);
// 构建 initialize 参数
json::object text_doc_caps;
{
json::object hover;
hover["dynamicRegistration"] = false;
text_doc_caps["hover"] = hover;
json::object completion;
completion["dynamicRegistration"] = false;
text_doc_caps["completion"] = completion;
json::object diagnostic;
diagnostic["dynamicRegistration"] = false;
text_doc_caps["diagnostic"] = diagnostic;
}
json::object capabilities;
capabilities["textDocument"] = text_doc_caps;
json::object init_params;
init_params["processId"] = nullptr;
init_params["rootUri"] = nullptr;
init_params["capabilities"] = capabilities;
// 发送 initialize 请求
int init_id = send_request("initialize", init_params);
// 等待 initialize 响应 (最多 10 秒)
{
std::unique_lock<std::mutex> lock(g_lsp.mutex);
bool got = g_lsp.cv.wait_for(lock, std::chrono::seconds(10), [init_id]() {
return !g_lsp.running || g_lsp.pending_responses.count(init_id) > 0;
});
if (!got || !g_lsp.running) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[lsp] initialize timed out");
g_lsp_impl_stop();
return -1;
}
g_lsp.pending_responses.erase(init_id);
}
// 发送 initialized 通知
send_notification("initialized", json::object{});
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] server started: %s", server_cmd);
return 0;
}
static void g_lsp_impl_stop() {
if (!g_lsp.running) return;
// 发送 shutdown 请求
int shutdown_id = send_request("shutdown", json::object{});
// 等待 shutdown 响应 (最多 2 秒)
{
std::unique_lock<std::mutex> lock(g_lsp.mutex);
g_lsp.cv.wait_for(lock, std::chrono::seconds(2), [shutdown_id]() {
return !g_lsp.running || g_lsp.pending_responses.count(shutdown_id) > 0;
});
g_lsp.pending_responses.clear();
}
// 发送 exit 通知
send_notification("exit", json::object{});
// 停止读取线程
g_lsp.running = false;
g_lsp.proc.stop();
if (g_lsp.reader_thread.joinable())
g_lsp.reader_thread.join();
g_lsp.diagnostics.clear();
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] server stopped");
}
static int g_lsp_impl_open_document(const char* uri, const char* content,
const char* lang_id) {
if (!g_lsp.running) return -1;
if (!uri || !content || !lang_id) return -1;
json::object text_doc;
text_doc["uri"] = uri;
text_doc["languageId"] = lang_id;
text_doc["version"] = 1;
text_doc["text"] = content;
json::object params;
params["textDocument"] = text_doc;
send_notification("textDocument/didOpen", params);
return 0;
}
static int g_lsp_impl_close_document(const char* uri) {
if (!g_lsp.running) return -1;
if (!uri) return -1;
json::object text_doc;
text_doc["uri"] = uri;
json::object params;
params["textDocument"] = text_doc;
send_notification("textDocument/didClose", params);
return 0;
}
static int g_lsp_impl_get_diagnostics(const char* uri, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
std::lock_guard<std::mutex> lock(g_lsp.mutex);
auto it = g_lsp.diagnostics.find(uri);
if (it == g_lsp.diagnostics.end()) {
*json_out = g_host->strdup("[]");
} else {
*json_out = g_host->strdup(it->second.c_str());
}
return 0;
}
static int g_lsp_impl_get_hover(const char* uri, int line, int col, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
json::object position;
position["line"] = line;
position["character"] = col;
json::object text_doc;
text_doc["uri"] = uri;
json::object params;
params["textDocument"] = text_doc;
params["position"] = position;
int req_id = send_request("textDocument/hover", params);
std::unique_lock<std::mutex> lock(g_lsp.mutex);
bool got = g_lsp.cv.wait_for(lock, std::chrono::seconds(10), [req_id]() {
return !g_lsp.running || g_lsp.pending_responses.count(req_id) > 0;
});
if (!got || !g_lsp.running || g_lsp.pending_responses.count(req_id) == 0) {
return -1;
}
std::string response_body = g_lsp.pending_responses[req_id];
g_lsp.pending_responses.erase(req_id);
json::value val;
try { val = json::parse(response_body); }
catch (...) { return -1; }
json::object resp;
try { resp = val.as_object(); }
catch (...) { return -1; }
if (!resp.contains("result")) return -1;
*json_out = g_host->strdup(json::serialize(resp["result"]).c_str());
return 0;
}
static int g_lsp_impl_get_completion(const char* uri, int line, int col, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
json::object position;
position["line"] = line;
position["character"] = col;
json::object text_doc;
text_doc["uri"] = uri;
json::object params;
params["textDocument"] = text_doc;
params["position"] = position;
int req_id = send_request("textDocument/completion", params);
std::unique_lock<std::mutex> lock(g_lsp.mutex);
bool got = g_lsp.cv.wait_for(lock, std::chrono::seconds(10), [req_id]() {
return !g_lsp.running || g_lsp.pending_responses.count(req_id) > 0;
});
if (!got || !g_lsp.running || g_lsp.pending_responses.count(req_id) == 0) {
return -1;
}
std::string response_body = g_lsp.pending_responses[req_id];
g_lsp.pending_responses.erase(req_id);
json::value val;
try { val = json::parse(response_body); }
catch (...) { return -1; }
json::object resp;
try { resp = val.as_object(); }
catch (...) { return -1; }
if (!resp.contains("result")) return -1;
*json_out = g_host->strdup(json::serialize(resp["result"]).c_str());
return 0;
}
// ============================================================================
// 服务 vtable
// ============================================================================
static dstalk_lsp_service_t g_service_vtable = {
&g_lsp_impl_start,
&g_lsp_impl_stop,
&g_lsp_impl_open_document,
&g_lsp_impl_close_document,
&g_lsp_impl_get_diagnostics,
&g_lsp_impl_get_hover,
&g_lsp_impl_get_completion,
};
// ============================================================================
// 生命周期回调
// ============================================================================
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] initializing LSP service plugin");
return host->register_service("lsp", 1, &g_service_vtable);
}
static void on_shutdown() {
if (g_lsp.running) {
g_lsp_impl_stop();
}
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] shutdown");
g_host = nullptr;
}
// ============================================================================
// 插件描述符
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "lsp",
/* .version = */ "1.0.0",
/* .description = */ "Language Server Protocol client (subprocess manager)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { NULL }, // 无依赖,自行管理子进程
/* .on_init = */ on_init,
/* .on_shutdown = */ on_shutdown,
/* .on_event = */ nullptr,
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

Some files were not shown because too many files have changed in this diff Show More