jfox-cli 0.3.2__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. jfox_cli-0.4.1/.claude/settings.local.json +9 -0
  2. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/PKG-INFO +1 -1
  3. jfox_cli-0.4.1/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md +135 -0
  4. jfox_cli-0.4.1/docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md +52 -0
  5. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/__init__.py +1 -1
  6. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/cli.py +115 -4
  7. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/config.py +6 -5
  8. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/daemon/__init__.py +8 -1
  9. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/daemon/process.py +116 -23
  10. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/daemon/server.py +9 -2
  11. jfox_cli-0.4.1/jfox/embedding_backend.py +174 -0
  12. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/indexer.py +2 -2
  13. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/vector_store.py +52 -1
  14. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/pyproject.toml +1 -1
  15. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-common/SKILL.md +1 -1
  16. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-session-summary/SKILL.md +32 -9
  17. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/conftest.py +11 -1
  18. jfox_cli-0.4.1/tests/test_config_set_unit.py +78 -0
  19. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_config_unit.py +4 -4
  20. jfox_cli-0.4.1/tests/test_embedding_device.py +107 -0
  21. jfox_cli-0.4.1/tests/unit/test_daemon_process.py +161 -0
  22. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_indexer_clear_before_rebuild.py +13 -13
  23. jfox_cli-0.4.1/tests/unit/test_vector_store_clear.py +213 -0
  24. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/uv.lock +1 -1
  25. jfox_cli-0.3.2/jfox/embedding_backend.py +0 -112
  26. jfox_cli-0.3.2/tests/unit/test_vector_store_clear.py +0 -76
  27. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/.githooks/pre-push +0 -0
  28. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/.github/workflows/integration-test.yml +0 -0
  29. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/.github/workflows/publish.yml +0 -0
  30. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/.gitignore +0 -0
  31. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/.python-version +0 -0
  32. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/AGENTS.md +0 -0
  33. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/CHANGELOG.md +0 -0
  34. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/CLAUDE.md +0 -0
  35. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/DEVELOPMENT_PLAN.md +0 -0
  36. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/README.md +0 -0
  37. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/SESSION.md +0 -0
  38. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/SESSION_SUMMARY.md +0 -0
  39. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/installation.md +0 -0
  40. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-bulk-import-bm25-fix.md +0 -0
  41. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-edit-command.md +0 -0
  42. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-unify-format-option.md +0 -0
  43. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md +0 -0
  44. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-edit-content-file.md +0 -0
  45. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md +0 -0
  46. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md +0 -0
  47. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-jfox-health-skill-kb-param.md +0 -0
  48. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-index-kb-param.md +0 -0
  49. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-lazy-import-perf.md +0 -0
  50. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-skill-redesign.md +0 -0
  51. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md +0 -0
  52. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  53. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md +0 -0
  54. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md +0 -0
  55. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-14-show-command-design.md +0 -0
  56. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/docs/troubleshooting.md +0 -0
  57. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jessica-jones-static-cable.md +0 -0
  58. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/__main__.py +0 -0
  59. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/bm25_index.py +0 -0
  60. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/daemon/__main__.py +0 -0
  61. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/daemon/client.py +0 -0
  62. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/formatters.py +0 -0
  63. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/git_extractor.py +0 -0
  64. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/global_config.py +0 -0
  65. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/graph.py +0 -0
  66. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/kb_manager.py +0 -0
  67. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/models.py +0 -0
  68. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/note.py +0 -0
  69. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/performance.py +0 -0
  70. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/search_engine.py +0 -0
  71. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/template.py +0 -0
  72. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/jfox/template_cli.py +0 -0
  73. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/pytest.ini +0 -0
  74. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/run_full_test.ps1 +0 -0
  75. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/README.md +0 -0
  76. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-ingest/SKILL.md +0 -0
  77. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  78. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  79. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/COVERAGE_PLAN.md +0 -0
  80. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/MIGRATION.md +0 -0
  81. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/TESTS.md +0 -0
  82. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/integration/__init__.py +0 -0
  83. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/integration/test_backlinks.py +0 -0
  84. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/performance/__init__.py +0 -0
  85. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/performance/test_performance.py +0 -0
  86. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_advanced_features.py +0 -0
  87. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_cli_format.py +0 -0
  88. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_core_workflow.py +0 -0
  89. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_hybrid_search.py +0 -0
  90. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_integration.py +0 -0
  91. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_kb_current.py +0 -0
  92. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/test_suggest_links.py +0 -0
  93. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/__init__.py +0 -0
  94. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_bm25_batch.py +0 -0
  95. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_edit.py +0 -0
  96. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_format_unify.py +0 -0
  97. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_formatters.py +0 -0
  98. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_git_extractor.py +0 -0
  99. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_global_config.py +0 -0
  100. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_index_kb_param.py +0 -0
  101. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_indexer_verify.py +0 -0
  102. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_kb_manager.py +0 -0
  103. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_lazy_import.py +0 -0
  104. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_logging_config.py +0 -0
  105. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_show.py +0 -0
  106. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_template.py +0 -0
  107. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/unit/test_template_cli.py +0 -0
  108. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/utils/__init__.py +0 -0
  109. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/utils/assertions.py +0 -0
  110. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/utils/jfox_cli.py +0 -0
  111. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/utils/note_generator.py +0 -0
  112. {jfox_cli-0.3.2 → jfox_cli-0.4.1}/tests/utils/temp_kb.py +0 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(cp \"C:/Users/zhuzh/.claude/skills/jfox-ingest/SKILL.md\" \"skills-recommend/claude-code/jfox-ingest/SKILL.md\")",
5
+ "Bash(cp \"C:/Users/zhuzh/.claude/skills/jfox-organize/SKILL.md\" \"skills-recommend/claude-code/jfox-organize/SKILL.md\")",
6
+ "Bash(cp \"C:/Users/zhuzh/.claude/skills/jfox-common/SKILL.md\" \"skills-recommend/claude-code/jfox-common/SKILL.md\")"
7
+ ]
8
+ }
9
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jfox-cli
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: JFox - Zettelkasten 知识管理 CLI 工具
5
5
  Project-URL: Homepage, https://github.com/zhuxixi/jfox
6
6
  Project-URL: Repository, https://github.com/zhuxixi/jfox
@@ -0,0 +1,135 @@
1
+ # Session Summary Confirmation Flow Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add user confirmation and note type selection steps to the jfox-session-summary skill before writing to the knowledge base.
6
+
7
+ **Architecture:** Insert two new steps (confirm content + select type) between summary generation and `jfox add`. The modification is purely in the skill Markdown document — no Python code changes.
8
+
9
+ **Tech Stack:** Skill document (Markdown), AskUserQuestion tool
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md`
12
+
13
+ ---
14
+
15
+ ### Task 1: Rewrite SKILL.md workflow section
16
+
17
+ **Files:**
18
+ - Modify: `skills-recommend/claude-code/jfox-session-summary/SKILL.md`
19
+
20
+ This is the only task. The file is a single skill document — no decomposition needed.
21
+
22
+ - [ ] **Step 1: Read current SKILL.md**
23
+
24
+ Read `skills-recommend/claude-code/jfox-session-summary/SKILL.md` to confirm current content matches expectations (3-step workflow, Step 2 hardcodes `--type fleeting`).
25
+
26
+ - [ ] **Step 2: Replace Step 2 and add new Steps 2–3**
27
+
28
+ Replace the entire `### Step 2: 写入知识库` section and everything after it (through end of file) with the new 5-step workflow. The exact replacement content:
29
+
30
+ Replace the old `### Step 2` and `### Step 3` sections (lines 42–96) with:
31
+
32
+ ```markdown
33
+ ### Step 2: 用户确认
34
+
35
+ 将生成的总结用普通文本输出,供用户阅读。然后使用 `AskUserQuestion` 询问:
36
+
37
+ - 问题:`笔记内容是否 OK?`
38
+ - 选项:
39
+ - `内容没问题` → 继续 Step 3
40
+ - `需要修改` → 用户在 "Other" 中输入修改意见,根据意见调整总结后回到 Step 2 重新展示和确认
41
+
42
+ 循环直到用户满意为止。
43
+
44
+ ### Step 3: 选择笔记类型
45
+
46
+ 用户确认内容后,使用 `AskUserQuestion` 询问笔记类型:
47
+
48
+ - 问题:`选择笔记类型`
49
+ - 选项:
50
+ - `fleeting`(推荐)— 会话记录是临时性笔记,后续可提炼为 permanent
51
+ - `literature` — 如果会话有明确的参考资料来源
52
+ - `permanent` — 如果总结已经是成熟的知识
53
+
54
+ ### Step 4: 写入知识库
55
+
56
+ 使用 Step 3 选定的笔记类型执行写入:
57
+
58
+ ```bash
59
+ jfox add "<markdown-escaped-summary>" \
60
+ --title "Session: <topic>" \
61
+ --type <Step 3 选定的类型> \
62
+ --tag session \
63
+ --kb <kb-name> \
64
+ --format json
65
+ ```
66
+
67
+ **注意**:
68
+ - 标题格式统一为 `Session: <简短主题>`
69
+ - 类型使用 Step 3 的选择结果,不再硬编码 `fleeting`
70
+ - 标签统一使用 `session`
71
+ - 内容中的双引号需要转义,或使用 `--content-file` 从临时文件读取
72
+
73
+ ### Step 5: 处理长内容
74
+
75
+ 如果总结超过 500 字或包含特殊字符,优先使用 `--content-file`:
76
+
77
+ ```bash
78
+ # 写入临时文件
79
+ cat > /tmp/session-summary.md << 'EOF'
80
+ <总结内容>
81
+ EOF
82
+
83
+ # 从文件导入
84
+ jfox add --content-file /tmp/session-summary.md \
85
+ --title "Session: <topic>" \
86
+ --type <Step 3 选定的类型> \
87
+ --tag session \
88
+ --kb <kb-name> \
89
+ --format json
90
+ ```
91
+
92
+ ## 命令参考
93
+
94
+ ```bash
95
+ # 直接添加(短内容)
96
+ jfox add "<summary>" --title "Session: <topic>" --type <type> --tag session --kb <name>
97
+
98
+ # 从文件添加(长内容或含特殊字符)
99
+ jfox add --content-file <path> --title "Session: <topic>" --type <type> --tag session --kb <name>
100
+
101
+ # 验证写入
102
+ jfox show <note_id> --format json
103
+ ```
104
+
105
+ ## 错误处理
106
+
107
+ - **"Knowledge base not found"**: 提示用户先运行 `/jfox-common` 创建知识库
108
+ - **内容过长导致 shell 解析失败**: 切换到 `--content-file` 方式
109
+ - **特殊字符转义问题**: 使用单引号包裹内容,或写入临时文件
110
+ ```
111
+
112
+ - [ ] **Step 3: Verify the file reads correctly**
113
+
114
+ Read the full updated `SKILL.md` and confirm:
115
+ 1. Step 1 (生成会话总结) is unchanged
116
+ 2. Step 2 (用户确认) has AskUserQuestion with "内容没问题" and "需要修改" options, plus loop description
117
+ 3. Step 3 (选择笔记类型) has AskUserQuestion with fleeting/literature/permanent options
118
+ 4. Step 4 (写入知识库) uses `<Step 3 选定的类型>` not hardcoded `fleeting`
119
+ 5. Step 5 (处理长内容) also uses `<Step 3 选定的类型>`
120
+ 6. 命令参考 section shows `--type <type>` not `--type fleeting`
121
+ 7. No leftover hardcoded `fleeting` in any `jfox add` example (except the Step 3 option description)
122
+
123
+ - [ ] **Step 4: Commit**
124
+
125
+ ```bash
126
+ git add skills-recommend/claude-code/jfox-session-summary/SKILL.md
127
+ git commit -m "feat(skill): add user confirmation and note type selection to session-summary
128
+
129
+ Inserts Step 2 (content confirmation via AskUserQuestion) and Step 3
130
+ (note type selection) before writing to knowledge base. Users can now
131
+ review the generated summary, request modifications, and choose between
132
+ fleeting/literature/permanent note types.
133
+
134
+ Closes #154"
135
+ ```
@@ -0,0 +1,52 @@
1
+ # jfox-session-summary 用户确认流程设计
2
+
3
+ **Date**: 2026-04-15
4
+ **Issue**: #154
5
+ **Status**: Draft
6
+
7
+ ## 背景
8
+
9
+ 当前 `jfox-session-summary` skill 生成总结后直接写入知识库,硬编码 `--type fleeting`。用户无法审查内容或选择笔记类型。
10
+
11
+ ## 改动范围
12
+
13
+ 仅修改 `skills-recommend/claude-code/jfox-session-summary/SKILL.md`。不涉及 CLI 命令或 Python 代码变更。
14
+
15
+ ## 新流程
16
+
17
+ 当前 3 步扩展为 5 步:
18
+
19
+ | Step | 内容 | 状态 |
20
+ |------|------|------|
21
+ | Step 1 | 生成会话总结 | 不变 |
22
+ | Step 2 | 展示总结 + 用户确认 | **新增** |
23
+ | Step 3 | 选择笔记类型 | **新增** |
24
+ | Step 4 | `jfox add --type <选择>` | 原 Step 2 |
25
+ | Step 5 | 处理长内容 | 原 Step 3,不变 |
26
+
27
+ ### Step 2:展示总结 + 用户确认
28
+
29
+ 1. 用普通文本输出完整总结内容,供用户阅读
30
+ 2. 使用 `AskUserQuestion` 询问「笔记内容是否 OK?」
31
+ - **内容没问题** → 继续 Step 3
32
+ - **需要修改** → 用户在 Other 中输入修改意见 → 根据意见调整总结 → 回到 Step 2 重新展示和确认
33
+ 3. 循环直到用户满意为止
34
+
35
+ ### Step 3:选择笔记类型
36
+
37
+ 使用 `AskUserQuestion` 询问「选择笔记类型」:
38
+
39
+ - **fleeting**(推荐)— 会话记录是临时性笔记,后续可提炼为 permanent
40
+ - **literature** — 会话有明确的参考资料来源
41
+ - **permanent** — 总结已经是成熟的知识
42
+
43
+ ### Step 4:写入知识库
44
+
45
+ `jfox add` 的 `--type` 参数使用 Step 3 的选择结果,不再硬编码 `fleeting`。其余参数(`--title`、`--tag session`、`--kb`)不变。
46
+
47
+ ## 不在范围内
48
+
49
+ - 不修改 `jfox add` CLI 命令本身
50
+ - 不增加新的笔记类型
51
+ - 不改变总结模板格式(Step 1 不变)
52
+ - 不做自动检测笔记类型的智能逻辑
@@ -1,5 +1,5 @@
1
1
  """JFox - Zettelkasten 知识管理工具"""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.4.1"
4
4
  __author__ = "User"
5
5
  __email__ = "user@example.com"
@@ -8,6 +8,8 @@ from datetime import datetime
8
8
  from pathlib import Path
9
9
  from typing import List, Optional
10
10
 
11
+ import yaml
12
+
11
13
  # Windows 下强制 UTF-8 输出,避免中文乱码
12
14
  if sys.platform == "win32":
13
15
  if hasattr(sys.stdout, "reconfigure"):
@@ -52,6 +54,9 @@ for _lib in (
52
54
 
53
55
  logger = logging.getLogger(__name__)
54
56
 
57
+ # config set 支持的配置项
58
+ _VALID_CONFIG_KEYS = {"device", "embedding_model", "batch_size"}
59
+
55
60
  # 创建应用
56
61
  app = typer.Typer(
57
62
  name="jfox",
@@ -569,6 +574,104 @@ def search(
569
574
  raise typer.Exit(1)
570
575
 
571
576
 
577
+ def _warn_dimension_change(new_model: str):
578
+ """切换 embedding 模型时警告用户重建索引"""
579
+ if new_model == "auto":
580
+ return # auto 模式不需要警告
581
+ try:
582
+ import chromadb
583
+
584
+ chroma_path = config.chroma_dir
585
+ if not chroma_path.exists():
586
+ return
587
+ client = chromadb.PersistentClient(path=str(chroma_path))
588
+ collection = client.get_collection("notes")
589
+ if collection.count() == 0:
590
+ return # 空索引,无需警告
591
+ except Exception:
592
+ return
593
+
594
+ # 有已有索引 + 换了模型 = 需要重建
595
+ console.print(
596
+ f"\n[yellow]⚠ 模型已更改为 {new_model}[/yellow]\n"
597
+ f"[yellow] 如果检索结果异常,请重建索引:[/yellow]\n"
598
+ f" jfox index rebuild\n"
599
+ )
600
+
601
+
602
+ def _config_set_impl(key: str, value: str):
603
+ """设置知识库配置项"""
604
+ if key not in _VALID_CONFIG_KEYS:
605
+ raise ValueError(f"不支持的配置项: {key},可选值: {', '.join(sorted(_VALID_CONFIG_KEYS))}")
606
+
607
+ config_path = config.zk_dir / "config.yaml"
608
+
609
+ # 读取现有配置
610
+ if config_path.exists():
611
+ with open(config_path, "r", encoding="utf-8") as f:
612
+ data = yaml.safe_load(f) or {}
613
+ else:
614
+ data = {}
615
+
616
+ # 类型转换
617
+ if key == "batch_size":
618
+ value = int(value)
619
+
620
+ # 写入配置
621
+ data[key] = value
622
+ config_path.parent.mkdir(parents=True, exist_ok=True)
623
+ with open(config_path, "w", encoding="utf-8") as f:
624
+ yaml.dump(data, f, allow_unicode=True, sort_keys=False)
625
+
626
+ # 同步更新内存中的 config 对象
627
+ setattr(config, key, value if key != "batch_size" else int(value))
628
+
629
+ # 重置 backend 单例,让新配置在下次使用时生效
630
+ from .embedding_backend import reset_backend
631
+
632
+ reset_backend()
633
+
634
+ console.print(f"[green]✓ {key} = {value}[/green]")
635
+
636
+ # 切换模型时检查维度
637
+ if key == "embedding_model":
638
+ _warn_dimension_change(value)
639
+
640
+
641
+ @app.command(name="config")
642
+ def config_cmd(
643
+ action: str = typer.Argument(..., help="操作: set"),
644
+ key: str = typer.Argument(None, help="配置项名称"),
645
+ value: str = typer.Argument(None, help="配置值"),
646
+ ):
647
+ """
648
+ 查看/修改知识库配置
649
+
650
+ 示例:
651
+
652
+ jfox config set device cuda
653
+ jfox config set device auto
654
+ jfox config set embedding_model BAAI/bge-m3
655
+ jfox config set embedding_model auto
656
+ """
657
+ try:
658
+ if action == "set":
659
+ if key is None:
660
+ console.print("[red]✗ 缺少配置项名称[/red]")
661
+ raise typer.Exit(1)
662
+ if value is None:
663
+ console.print("[red]✗ 缺少配置值[/red]")
664
+ raise typer.Exit(1)
665
+ _config_set_impl(key, value)
666
+ else:
667
+ console.print(f"[red]✗ 未知操作: {action}[/red]")
668
+ console.print(" 可用操作: set")
669
+ raise typer.Exit(1)
670
+ except ValueError as e:
671
+ console.print(f"[red]✗ {e}[/red]")
672
+ raise typer.Exit(1)
673
+
674
+
572
675
  def _status_impl(output_format: str, json_output: bool):
573
676
  """查看知识库状态的内部实现"""
574
677
  from .formatters import OutputFormatter
@@ -587,8 +690,9 @@ def _status_impl(output_format: str, json_output: bool):
587
690
  },
588
691
  "stats": stats,
589
692
  "backend": {
590
- "type": "CPU",
591
- "model": backend.model_name if backend.model else "not loaded",
693
+ "type": backend.resolved_device,
694
+ "model": backend.model_name or "auto (未加载)",
695
+ "dimension": backend.dimension,
592
696
  },
593
697
  }
594
698
 
@@ -612,8 +716,9 @@ def _status_impl(output_format: str, json_output: bool):
612
716
  table.add_row("Fleeting", str(stats["by_type"].get("fleeting", 0)))
613
717
  table.add_row("Literature", str(stats["by_type"].get("literature", 0)))
614
718
  table.add_row("Permanent", str(stats["by_type"].get("permanent", 0)))
615
- table.add_row("Backend", "CPU")
616
- table.add_row("Model", backend.model_name)
719
+ table.add_row("Backend", backend.resolved_device)
720
+ table.add_row("Model", backend.model_name or "auto (未加载)")
721
+ table.add_row("Dimension", str(backend.dimension))
617
722
 
618
723
  console.print(table)
619
724
  else:
@@ -2507,7 +2612,10 @@ def daemon(
2507
2612
 
2508
2613
  try:
2509
2614
  if action == "start":
2615
+ from .daemon.process import DAEMON_LOG_FILE
2616
+
2510
2617
  console.print("[yellow]正在启动 embedding daemon...[/yellow]")
2618
+ console.print(f"[dim]日志文件: {DAEMON_LOG_FILE}[/dim]")
2511
2619
  ok = start_daemon(port=port)
2512
2620
  if ok:
2513
2621
  info = get_daemon_status()
@@ -2520,11 +2628,13 @@ def daemon(
2520
2628
  table.add_row("端口", str(info["port"]))
2521
2629
  table.add_row("模型", info["model"])
2522
2630
  table.add_row("维度", str(info["dimension"]))
2631
+ table.add_row("设备", info.get("device", "unknown"))
2523
2632
  console.print(table)
2524
2633
  else:
2525
2634
  console.print("[green]✓ Daemon 已启动[/green]")
2526
2635
  else:
2527
2636
  console.print("[red]✗ Daemon 启动失败[/red]")
2637
+ console.print(f"[dim]查看日志: {DAEMON_LOG_FILE}[/dim]")
2528
2638
  raise typer.Exit(1)
2529
2639
 
2530
2640
  elif action == "stop":
@@ -2546,6 +2656,7 @@ def daemon(
2546
2656
  table.add_row("端口", str(info["port"]))
2547
2657
  table.add_row("模型", info.get("model", "unknown"))
2548
2658
  table.add_row("维度", str(info.get("dimension", "unknown")))
2659
+ table.add_row("设备", info.get("device", "unknown"))
2549
2660
  console.print(table)
2550
2661
  else:
2551
2662
  console.print("[dim]Daemon 未运行[/dim]")
@@ -28,10 +28,10 @@ class ZKConfig:
28
28
  zk_dir: Path = field(init=False)
29
29
  chroma_dir: Path = field(init=False)
30
30
 
31
- # NPU 配置
32
- embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
33
- embedding_dimension: int = 384
34
- device: str = "auto" # auto / npu / gpu / cpu
31
+ # Embedding 配置
32
+ embedding_model: str = "auto" # auto = 根据 device 自动选择模型
33
+ embedding_dimension: int = 0 # 0 = 动态,由模型决定
34
+ device: str = "auto" # auto / cuda / cpu
35
35
  batch_size: int = 32
36
36
 
37
37
  # 检索配置
@@ -122,13 +122,14 @@ config = get_config()
122
122
 
123
123
 
124
124
  def _reset_singletons():
125
- """重置所有缓存的单例(搜索引擎、向量存储、BM25 索引)"""
125
+ """重置所有缓存的单例(搜索引擎、向量存储、BM25 索引、embedding 后端)"""
126
126
  import importlib
127
127
 
128
128
  for module_name, fn_name in [
129
129
  (".bm25_index", "reset_bm25_index"),
130
130
  (".search_engine", "reset_search_engine"),
131
131
  (".vector_store", "reset_vector_store"),
132
+ (".embedding_backend", "reset_backend"),
132
133
  ]:
133
134
  try:
134
135
  module = importlib.import_module(module_name, package="jfox")
@@ -3,9 +3,16 @@
3
3
  DEFAULT_HOST = "127.0.0.1"
4
4
  DEFAULT_PORT = 18700
5
5
 
6
- from .process import get_daemon_status, is_daemon_running, start_daemon, stop_daemon
6
+ from .process import (
7
+ DAEMON_LOG_FILE,
8
+ get_daemon_status,
9
+ is_daemon_running,
10
+ start_daemon,
11
+ stop_daemon,
12
+ )
7
13
 
8
14
  __all__ = [
15
+ "DAEMON_LOG_FILE",
9
16
  "DEFAULT_HOST",
10
17
  "DEFAULT_PORT",
11
18
  "start_daemon",
@@ -16,9 +16,28 @@ from typing import Optional
16
16
 
17
17
  from . import DEFAULT_HOST, DEFAULT_PORT
18
18
 
19
+
20
+ def _get_pythonw_executable() -> str:
21
+ """获取 pythonw.exe 路径(Windows 无控制台入口)
22
+
23
+ Windows 上优先使用 pythonw.exe 避免 daemon 子进程弹出控制台窗口。
24
+ 如果 pythonw.exe 不存在则回退到 sys.executable。
25
+ 非 Windows 平台直接返回 sys.executable。
26
+ """
27
+ if sys.platform != "win32":
28
+ return sys.executable
29
+
30
+ pythonw = sys.executable.replace("python.exe", "pythonw.exe")
31
+ if pythonw != sys.executable and Path(pythonw).exists():
32
+ return pythonw
33
+ return sys.executable
34
+
35
+
19
36
  logger = logging.getLogger(__name__)
20
37
 
21
38
  STARTUP_TIMEOUT = 60 # 模型加载可能较慢
39
+ FIRST_RUN_TIMEOUT = 300 # 首次下载模型超时(秒)
40
+ DAEMON_LOG_FILE = Path.home() / ".jfox_daemon.log"
22
41
  PID_FILE = Path.home() / ".jfox_daemon.pid"
23
42
 
24
43
 
@@ -86,6 +105,56 @@ def is_daemon_running() -> bool:
86
105
  return False
87
106
 
88
107
 
108
+ def _check_model_cache() -> dict:
109
+ """
110
+ 检查当前模型是否已缓存
111
+
112
+ Returns:
113
+ dict: {"needs_download": bool, "model_name": str, "size_hint": str}
114
+ """
115
+ try:
116
+ from ..config import config as _cfg
117
+ from ..embedding_backend import _CPU_DEFAULT_MODEL, _GPU_DEFAULT_MODEL
118
+
119
+ # 确定目标模型名
120
+ model_name = _cfg.embedding_model
121
+ if model_name == "auto" or not model_name:
122
+ try:
123
+ import torch
124
+
125
+ if torch.cuda.is_available():
126
+ model_name = _GPU_DEFAULT_MODEL
127
+ else:
128
+ model_name = _CPU_DEFAULT_MODEL
129
+ except Exception:
130
+ model_name = _CPU_DEFAULT_MODEL
131
+
132
+ # 检查 HuggingFace 缓存
133
+ hf_home = os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface"))
134
+ hub_cache = os.environ.get("HUGGINGFACE_HUB_CACHE", str(Path(hf_home) / "hub"))
135
+ model_cache_dir = Path(hub_cache) / f"models--{model_name.replace('/', '--')}"
136
+
137
+ size_hint = "2GB" if "bge-m3" in model_name else "90MB"
138
+
139
+ if model_cache_dir.exists():
140
+ snapshots_dir = model_cache_dir / "snapshots"
141
+ has_files = snapshots_dir.exists() and any(snapshots_dir.iterdir())
142
+ return {
143
+ "needs_download": not has_files,
144
+ "model_name": model_name,
145
+ "size_hint": size_hint,
146
+ }
147
+
148
+ return {
149
+ "needs_download": True,
150
+ "model_name": model_name,
151
+ "size_hint": size_hint,
152
+ }
153
+ except Exception as e:
154
+ logger.debug(f"Model cache check failed, assuming download needed: {e}")
155
+ return {"needs_download": True, "model_name": "unknown", "size_hint": ""}
156
+
157
+
89
158
  def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
90
159
  """
91
160
  启动 daemon 后台进程
@@ -107,12 +176,28 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
107
176
  )
108
177
  return True
109
178
 
110
- # 构建启动命令
111
- cmd = [sys.executable, "-m", "jfox.daemon.server", "--host", host, "--port", str(port)]
179
+ # 首次启动预检:检查模型缓存是否存在
180
+ timeout = STARTUP_TIMEOUT
181
+ cache_info = _check_model_cache()
182
+ if cache_info["needs_download"]:
183
+ logger.info(
184
+ f"首次启动需要下载模型 {cache_info['model_name']}" f"(约 {cache_info['size_hint']})"
185
+ )
186
+ timeout = FIRST_RUN_TIMEOUT
187
+
188
+ # 构建启动命令(Windows 使用 pythonw.exe 避免控制台窗口)
189
+ cmd = [
190
+ _get_pythonw_executable(),
191
+ "-m",
192
+ "jfox.daemon.server",
193
+ "--host",
194
+ host,
195
+ "--port",
196
+ str(port),
197
+ ]
112
198
 
113
199
  kwargs = {}
114
200
  if sys.platform == "win32":
115
- # Windows: 后台分离进程,不弹窗
116
201
  CREATE_NEW_PROCESS_GROUP = 0x00000200
117
202
  DETACHED_PROCESS = 0x00000008
118
203
  CREATE_NO_WINDOW = 0x08000000
@@ -120,39 +205,46 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
120
205
  else:
121
206
  kwargs["start_new_session"] = True
122
207
 
208
+ # 子进程日志落盘(stdout/stderr → 日志文件)
209
+ log_file = open(DAEMON_LOG_FILE, "a", encoding="utf-8")
210
+
123
211
  try:
124
212
  proc = subprocess.Popen(
125
213
  cmd,
126
- stdout=subprocess.DEVNULL,
127
- stderr=subprocess.DEVNULL,
214
+ stdout=log_file,
215
+ stderr=log_file,
128
216
  stdin=subprocess.DEVNULL,
129
217
  **kwargs,
130
218
  )
131
219
  logger.info(f"Daemon 进程已启动 (PID: {proc.pid})")
220
+ logger.info(f"Daemon 日志文件: {DAEMON_LOG_FILE}")
132
221
  except Exception as e:
222
+ log_file.close()
133
223
  logger.error(f"启动 daemon 失败: {e}")
134
224
  return False
135
225
 
136
226
  # 等待 daemon 就绪(用 HTTP 健康检查判断,不用 PID)
137
- for i in range(STARTUP_TIMEOUT):
138
- time.sleep(1)
139
- health = _http_health_check(host, port)
140
- if health is not None:
141
- # daemon 自身获取真实 PID
142
- real_pid = health.get("pid", proc.pid)
143
- _write_pid_file(
144
- {
145
- "pid": real_pid,
146
- "host": host,
147
- "port": port,
148
- "started_at": time.time(),
149
- }
150
- )
151
- logger.info(f"Daemon 已就绪 (PID: {real_pid}, port: {port})")
152
- return True
227
+ try:
228
+ for i in range(timeout):
229
+ time.sleep(1)
230
+ health = _http_health_check(host, port)
231
+ if health is not None:
232
+ real_pid = health.get("pid", proc.pid)
233
+ _write_pid_file(
234
+ {
235
+ "pid": real_pid,
236
+ "host": host,
237
+ "port": port,
238
+ "started_at": time.time(),
239
+ }
240
+ )
241
+ logger.info(f"Daemon 已就绪 (PID: {real_pid}, port: {port})")
242
+ return True
153
243
 
154
- logger.warning("Daemon 启动超时")
155
- return False
244
+ logger.warning(f"Daemon 启动超时({timeout}秒),日志见 {DAEMON_LOG_FILE}")
245
+ return False
246
+ finally:
247
+ log_file.close()
156
248
 
157
249
 
158
250
  def stop_daemon() -> bool:
@@ -222,5 +314,6 @@ def get_daemon_status() -> Optional[dict]:
222
314
  "port": port,
223
315
  "model": health.get("model", "unknown"),
224
316
  "dimension": health.get("dimension", 384),
317
+ "device": health.get("device", "unknown"),
225
318
  "started_at": data.get("started_at") if data else None,
226
319
  }
@@ -26,12 +26,17 @@ def _load_model():
26
26
  """启动时加载模型(标记为 daemon 进程,防止自引用)"""
27
27
  global _backend
28
28
  os.environ["JFOX_DAEMON_PROCESS"] = "1"
29
+ from ..config import config
29
30
  from ..embedding_backend import EmbeddingBackend
30
31
 
31
- _backend = EmbeddingBackend()
32
+ model_name = config.embedding_model if config.embedding_model != "auto" else None
33
+ _backend = EmbeddingBackend(device=config.device, model_name=model_name)
32
34
  try:
33
35
  _backend.load()
34
- logger.info(f"Daemon: 模型已加载 ({_backend.model_name})")
36
+ logger.info(
37
+ f"Daemon: 模型已加载 {_backend.model_name} "
38
+ f"(device={_backend._resolved_device}, dimension={_backend._resolved_dim})"
39
+ )
35
40
  except Exception as e:
36
41
  logger.error(f"Daemon: 模型加载失败,进程退出: {e}")
37
42
  os._exit(1)
@@ -46,6 +51,7 @@ class HealthResponse(BaseModel):
46
51
  status: str
47
52
  model: str
48
53
  dimension: int
54
+ device: str # 实际使用的设备
49
55
  pid: int
50
56
 
51
57
 
@@ -80,6 +86,7 @@ def health():
80
86
  status="ok",
81
87
  model=_backend.model_name,
82
88
  dimension=_backend.dimension,
89
+ device=_backend.resolved_device,
83
90
  pid=os.getpid(),
84
91
  )
85
92