kb-memory 2.1.0__tar.gz → 2.1.2__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 (92) hide show
  1. {kb_memory-2.1.0/kb_memory.egg-info → kb_memory-2.1.2}/PKG-INFO +6 -10
  2. {kb_memory-2.1.0 → kb_memory-2.1.2}/README.md +5 -9
  3. kb_memory-2.1.2/kb/__init__.py +2 -0
  4. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/api.py +11 -6
  5. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/cli.py +21 -38
  6. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/discovery.py +13 -3
  7. kb_memory-2.1.2/kb/idle.py +36 -0
  8. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/service.py +4 -0
  9. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/watcher.py +22 -2
  10. {kb_memory-2.1.0 → kb_memory-2.1.2/kb_memory.egg-info}/PKG-INFO +6 -10
  11. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/SOURCES.txt +1 -0
  12. {kb_memory-2.1.0 → kb_memory-2.1.2}/pyproject.toml +1 -1
  13. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n10_ask.py +3 -1
  14. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n15_watcher.py +35 -0
  15. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_bootstrap.py +34 -0
  16. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_runtime.py +66 -0
  17. kb_memory-2.1.0/kb/__init__.py +0 -2
  18. {kb_memory-2.1.0 → kb_memory-2.1.2}/LICENSE +0 -0
  19. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/__main__.py +0 -0
  20. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/audit.py +0 -0
  21. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/bm25.py +0 -0
  22. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/config.py +0 -0
  23. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/consolidation.py +0 -0
  24. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/embedder.py +0 -0
  25. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/eval.py +0 -0
  26. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/governance.py +0 -0
  27. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/i18n.py +0 -0
  28. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/ingest.py +0 -0
  29. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/llm.py +0 -0
  30. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/logging_setup.py +0 -0
  31. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/mcp.py +0 -0
  32. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/models.py +0 -0
  33. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/monitor.py +0 -0
  34. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/reranker.py +0 -0
  35. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/retriever.py +0 -0
  36. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/scopes.py +0 -0
  37. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/sparse.py +0 -0
  38. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/storage.py +0 -0
  39. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/dependency_links.txt +0 -0
  40. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/entry_points.txt +0 -0
  41. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/requires.txt +0 -0
  42. {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/top_level.txt +0 -0
  43. {kb_memory-2.1.0 → kb_memory-2.1.2}/setup.cfg +0 -0
  44. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_agent_isolation.py +0 -0
  45. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_api_auth.py +0 -0
  46. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_charset_json.py +0 -0
  47. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_consolidation.py +0 -0
  48. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_embed_model_resolution.py +0 -0
  49. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_hotfix_validation.py +0 -0
  50. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_bm25_batch.py +0 -0
  51. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_concurrency.py +0 -0
  52. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_isolation_recall.py +0 -0
  53. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n01_config.py +0 -0
  54. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n02_models.py +0 -0
  55. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n03_embedder.py +0 -0
  56. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n04_storage.py +0 -0
  57. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n05_bm25.py +0 -0
  58. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n06_retriever_service.py +0 -0
  59. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n07_api.py +0 -0
  60. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n08_api_ext.py +0 -0
  61. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n09_llm.py +0 -0
  62. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n11_routing.py +0 -0
  63. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n12_mcp.py +0 -0
  64. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n13_ingest.py +0 -0
  65. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n13_ingest_extra.py +0 -0
  66. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n14_web.py +0 -0
  67. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n16_regression.py +0 -0
  68. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n17_logging.py +0 -0
  69. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_logs_api.py +0 -0
  70. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_logs_doc.py +0 -0
  71. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_monitor.py +0 -0
  72. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n19_nav.py +0 -0
  73. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n21_decay.py +0 -0
  74. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n21_metadata.py +0 -0
  75. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n22_dedup.py +0 -0
  76. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n22_governance.py +0 -0
  77. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n23_audit.py +0 -0
  78. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n23_cli.py +0 -0
  79. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n24_reranker.py +0 -0
  80. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n25_sparse.py +0 -0
  81. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n26_eval.py +0 -0
  82. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n27_perf.py +0 -0
  83. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n28_cli.py +0 -0
  84. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n29_security.py +0 -0
  85. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n30_mcp_stdio.py +0 -0
  86. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n31_serve_probe.py +0 -0
  87. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n32_hybrid_fusion_recall.py +0 -0
  88. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_skill_package.py +0 -0
  89. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_user_experience_fixes.py +0 -0
  90. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_i18n.py +0 -0
  91. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_packaging.py +0 -0
  92. {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_watcher_missing_dir.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kb-memory
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM
5
5
  Author: fish827-08
6
6
  License-Expression: Apache-2.0
@@ -60,7 +60,7 @@ Dynamic: license-file
60
60
 
61
61
  <!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
62
62
 
63
- **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.0 · 自动化回归全绿,待人工验收)。
63
+ **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
64
64
 
65
65
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
66
66
 
@@ -150,7 +150,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
150
150
  >
151
151
  > **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
152
152
  > 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
153
- > 可另装 CUDA 版 torch,见 [AGENTS.md](AGENTS.md) 第 7 节;也可显式 `KB_DEVICE=cuda`。
153
+ > 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
154
154
  >
155
155
  > **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
156
156
  > 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
@@ -398,18 +398,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
398
398
  ```
399
399
  kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
400
400
  service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
401
- tests/ kb 验收测试(417 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
402
- docs/ 核心文档、当前设计(历史 spec/plan 已归档本地,不上传)
401
+ tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
402
+ docs/ 用户指南、开发者手册与设计文档
403
403
  kb_data/ kb 运行数据(gitignore)
404
- _archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
405
404
  ```
406
405
 
407
406
  ## 更多文档
408
407
 
409
- - **用户使用手册(人类用户入口)**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
410
- - **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
408
+ - **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
411
409
  - 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
412
410
  - **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
413
411
  - 开发者维护手册:`docs/DEV_HANDBOOK.md`
414
- - AI 协作规范:`AGENTS.md`
415
- - 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
@@ -10,7 +10,7 @@
10
10
 
11
11
  <!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
12
12
 
13
- **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.0 · 自动化回归全绿,待人工验收)。
13
+ **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
14
14
 
15
15
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
16
16
 
@@ -100,7 +100,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
100
100
  >
101
101
  > **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
102
102
  > 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
103
- > 可另装 CUDA 版 torch,见 [AGENTS.md](AGENTS.md) 第 7 节;也可显式 `KB_DEVICE=cuda`。
103
+ > 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
104
104
  >
105
105
  > **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
106
106
  > 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
@@ -348,18 +348,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
348
348
  ```
349
349
  kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
350
350
  service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
351
- tests/ kb 验收测试(417 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
352
- docs/ 核心文档、当前设计(历史 spec/plan 已归档本地,不上传)
351
+ tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
352
+ docs/ 用户指南、开发者手册与设计文档
353
353
  kb_data/ kb 运行数据(gitignore)
354
- _archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
355
354
  ```
356
355
 
357
356
  ## 更多文档
358
357
 
359
- - **用户使用手册(人类用户入口)**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
360
- - **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
358
+ - **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
361
359
  - 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
362
360
  - **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
363
361
  - 开发者维护手册:`docs/DEV_HANDBOOK.md`
364
- - AI 协作规范:`AGENTS.md`
365
- - 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
@@ -0,0 +1,2 @@
1
+ """kb:本地 Agent 记忆与知识服务。"""
2
+ __version__ = "2.1.2" # 对应 git tag v2.1.2(生命周期日志 version= 字段用)
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, field_validator
17
17
 
18
18
  from kb import __version__
19
19
  from kb.config import Settings, get_settings
20
+ from kb.idle import start_idle_watchdog
20
21
  from kb.logging_setup import setup_logging
21
22
  from kb.monitor import MonitorAgent, run_once_summary
22
23
  from kb.mcp import create_mcp_server
@@ -507,7 +508,8 @@ def _wrap_api_key(app, settings):
507
508
 
508
509
  def create_app(settings: Settings | None = None,
509
510
  enable_watcher: bool = False) -> FastAPI:
510
- """应用工厂;全局单例 KBService 挂 app.state.kb(REST 与 MCP 共享该实例)。
511
+ """应用工厂;全局单例 KBService 在工厂内创建,由 REST/MCP 共享。
512
+ 返回经鉴权/charset/访问日志中间件包装的 ASGI 应用(外部拿不到 .state)。
511
513
  统一错误 JSON:{"error": "<CODE>", "message": "<人话>"}。
512
514
 
513
515
  enable_watcher=True 时(仅 serve 模式)在 lifespan 中挂目录监听线程;
@@ -542,6 +544,10 @@ def create_app(settings: Settings | None = None,
542
544
  serve_log.info("鉴权已启用(KB_API_KEY 已配置,要求 Bearer/X-API-Key)")
543
545
  else:
544
546
  serve_log.info("鉴权未启用(本地模式,KB_API_KEY 为空)")
547
+ # v2.1 空闲卸载:守护线程必须在工厂内部启动——create_app 最终返回
548
+ # 中间件包装后的 ASGI 函数,外部拿不到 app.state.kb。
549
+ idle_stop = start_idle_watchdog(lambda: kb.embedder,
550
+ kb.settings.idle_unload_seconds)
545
551
  # N29 启动安全警告:非回环监听 + 未鉴权 → warning 日志(终端醒目横幅由 serve 命令负责)
546
552
  warn = exposure_warning(kb.settings.api_host, kb.settings.api_key)
547
553
  if warn:
@@ -556,7 +562,7 @@ def create_app(settings: Settings | None = None,
556
562
  "未安装 watchdog,目录监听已禁用"
557
563
  "(pip install \"kb-memory[ingest]\" 可启用)")
558
564
  else:
559
- watcher = KBWatcher(kb, wd)
565
+ watcher = KBWatcher(kb, wd, ignore_dir=kb.settings.data_dir)
560
566
  watcher.start()
561
567
  # 本地监控 Agent(TASK-0017):serve 模式默认启用,仅读配置决定间隔;
562
568
  # TestClient 测试默认不启动(monitor_enabled 默认 False,测试按需开)
@@ -570,6 +576,7 @@ def create_app(settings: Settings | None = None,
570
576
  serve_log.info("服务就绪 records=%s", kb.stats().get("records"))
571
577
  yield
572
578
  finally:
579
+ idle_stop.set()
573
580
  if monitor is not None:
574
581
  monitor.stop()
575
582
  if watcher is not None:
@@ -804,11 +811,9 @@ def create_app(settings: Settings | None = None,
804
811
  try:
805
812
  return kb.ask(body.question, agent_id=body.agent_id,
806
813
  client=body.client, project=body.project)
807
- except LLMDisabledError:
814
+ except LLMDisabledError as exc:
808
815
  raise HTTPException(status_code=503, detail={
809
- "error": "LLM_DISABLED",
810
- "message": "未检测到可用的 LLM:请在 .env 配置 KB_LLM_MODE=local "
811
- "(Ollama)或 KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)"})
816
+ "error": "LLM_DISABLED", "message": str(exc)})
812
817
 
813
818
  @app.get("/api/v1/audit")
814
819
  def audit(client: str | None = None, project: str | None = None,
@@ -14,12 +14,28 @@ from rich.table import Table
14
14
 
15
15
  from kb.config import Settings, get_settings
16
16
  from kb.governance import days_since
17
+ from kb.idle import start_idle_watchdog # 兼容既有导入:kb.cli.start_idle_watchdog
17
18
  from kb.service import KBService
18
19
 
19
20
  app = typer.Typer()
20
21
  console = Console()
21
22
 
22
23
 
24
+ @app.callback(invoke_without_command=True)
25
+ def _version_option(
26
+ ctx: typer.Context,
27
+ version: bool = typer.Option(
28
+ False, "--version", "-V", is_eager=True,
29
+ help="显示版本并退出 / Show version and exit")):
30
+ if version:
31
+ from kb import __version__
32
+ console.print(f"kb {__version__}")
33
+ raise typer.Exit()
34
+ if ctx.invoked_subcommand is None:
35
+ console.print(ctx.get_help())
36
+ raise typer.Exit()
37
+
38
+
23
39
  def _default_project() -> str:
24
40
  """当前目录名规整为合法项目名;无法规整时回退全局库(空串)。
25
41
 
@@ -387,8 +403,11 @@ def ask(
387
403
  except LLMDisabledError:
388
404
  # LLM 不可用:检索结果仍有价值,输出命中并给出配置指引
389
405
  hits = svc.search(question, top_k=top_k)
390
- console.print("[yellow]LLM 不可用(本地 Ollama 未响应且未配置云端 "
391
- "Key),以下为检索结果(仅检索未生成)[/yellow]")
406
+ if getattr(getattr(svc, "settings", None), "llm_mode", "") == "off":
407
+ reason = "LLM 已关闭(KB_LLM_MODE=off,默认不调用 LLM)"
408
+ else:
409
+ reason = "LLM 不可用(本地 Ollama 未响应且未配置云端 Key)"
410
+ console.print(f"[yellow]{reason},以下为检索结果(仅检索未生成)[/yellow]")
392
411
  console.print(f"配置指引:在 .env 设 KB_LLM_MODE=local 并配置 KB_LLM_MODEL=(先用 "
393
412
  "ollama pull 选一个模型,名称以 ollama list 为准),或设"
394
413
  " KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)")
@@ -409,39 +428,6 @@ def ask(
409
428
  raise typer.Exit(code=1)
410
429
 
411
430
 
412
- def start_idle_watchdog(embedder, idle_seconds: int,
413
- check_seconds: float | None = None):
414
- """启动空闲卸载守护线程(daemon),返回停止事件;idle_seconds<=0 不启动。
415
-
416
- 每轮调用 embedder.unload_if_idle(),空闲超时后卸载模型并释放显存;
417
- 兜底异常记 WARNING,守护线程永不因单次失败退出。
418
- English: Start the idle-unload daemon thread and return its stop event;
419
- idle_seconds<=0 disables it. Each tick calls embedder.unload_if_idle(),
420
- releasing memory/VRAM after the idle timeout; exceptions are logged as
421
- WARNING so the thread never dies on a single failure.
422
- """
423
- import logging
424
- import threading
425
- stop = threading.Event()
426
- if idle_seconds <= 0:
427
- return stop
428
- interval = (check_seconds if check_seconds is not None
429
- else max(1.0, min(30.0, idle_seconds / 4)))
430
-
431
- def _loop() -> None:
432
- log = logging.getLogger("kb.idle")
433
- while not stop.wait(interval):
434
- try:
435
- target = embedder() if callable(embedder) else embedder
436
- if target.unload_if_idle():
437
- log.info("嵌入模型空闲超过 %s 秒,已卸载", idle_seconds)
438
- except Exception as exc: # noqa: BLE001 - 守护线程不因单次失败退出
439
- log.warning("空闲卸载检查失败:%s", exc)
440
-
441
- threading.Thread(target=_loop, name="kb-idle-unload", daemon=True).start()
442
- return stop
443
-
444
-
445
431
  @app.command()
446
432
  def serve(non_interactive: bool = typer.Option(
447
433
  False, "--non-interactive",
@@ -468,15 +454,12 @@ def serve(non_interactive: bool = typer.Option(
468
454
  console.print(f"[bold red]{warn}[/bold red]")
469
455
  console.print(f"[bold red]{'!' * 68}[/bold red]\n")
470
456
  app = create_app(s, enable_watcher=True)
471
- idle_stop = start_idle_watchdog(lambda: app.state.kb.embedder,
472
- s.idle_unload_seconds)
473
457
  # kb.pid 由服务进程自身管理:启动前写入(真实存活到此处才写,不再由
474
458
  # start_kb.bat 猜写);无论正常退出还是异常,退出时清理,不留指向死进程的旧 pid。
475
459
  write_pid_file()
476
460
  try:
477
461
  uvicorn.run(app, host=s.api_host, port=s.api_port)
478
462
  finally:
479
- idle_stop.set()
480
463
  remove_pid_file()
481
464
 
482
465
 
@@ -76,6 +76,15 @@ def _running(host: str, port: int) -> bool:
76
76
  return False
77
77
 
78
78
 
79
+ def _absolute_data_dir(value) -> str:
80
+ """把数据目录解析为绝对路径:相对路径按服务启动时的 cwd 落盘,where 必须显示真实位置。"""
81
+ path = Path(value).expanduser()
82
+ try:
83
+ return str(path.resolve())
84
+ except OSError:
85
+ return str(path.absolute())
86
+
87
+
79
88
  def discover(settings=None) -> dict:
80
89
  """按 manifest → PATH → 当前解释器 的顺序发现服务安装。
81
90
 
@@ -93,7 +102,8 @@ def discover(settings=None) -> dict:
93
102
  "source": "manifest",
94
103
  "python": manifest["python"],
95
104
  "version": manifest.get("version") or _package_version(),
96
- "data_dir": manifest.get("data_dir") or str(settings.data_dir),
105
+ "data_dir": _absolute_data_dir(
106
+ manifest.get("data_dir") or settings.data_dir),
97
107
  "venv": manifest.get("venv") or "",
98
108
  "running": _running(host, port),
99
109
  "endpoint": f"http://{host}:{port}/mcp/",
@@ -111,7 +121,7 @@ def discover(settings=None) -> dict:
111
121
  "source": "path",
112
122
  "python": python,
113
123
  "version": _package_version(),
114
- "data_dir": str(settings.data_dir),
124
+ "data_dir": _absolute_data_dir(settings.data_dir),
115
125
  "venv": "",
116
126
  "running": _running(host, port),
117
127
  "endpoint": f"http://{host}:{port}/mcp/",
@@ -121,7 +131,7 @@ def discover(settings=None) -> dict:
121
131
  "source": "package",
122
132
  "python": sys.executable,
123
133
  "version": _package_version(),
124
- "data_dir": str(settings.data_dir),
134
+ "data_dir": _absolute_data_dir(settings.data_dir),
125
135
  "venv": "",
126
136
  "running": _running(host, port),
127
137
  "endpoint": f"http://{host}:{port}/mcp/",
@@ -0,0 +1,36 @@
1
+ """空闲卸载守护线程(v2.1 资源占用优化)。
2
+
3
+ 独立模块以便 create_app(服务生命周期)与 CLI 复用;create_app 返回的是
4
+ 经中间件包装的 ASGI 函数,无法再从外部访问 app.state,因此守护线程必须在
5
+ 工厂内部、拿得到 KBService 的地方启动。
6
+ """
7
+ import logging
8
+ import threading
9
+
10
+
11
+ def start_idle_watchdog(embedder, idle_seconds: int,
12
+ check_seconds: float | None = None):
13
+ """启动空闲卸载守护线程(daemon),返回停止事件;idle_seconds<=0 不启动。
14
+
15
+ embedder 可以是 Embedder 实例,也可以是返回实例的可调用对象(延迟解析,
16
+ 兼容测试替身与延迟初始化)。每轮调用 unload_if_idle();异常记 WARNING,
17
+ 守护线程不因单次失败退出。
18
+ """
19
+ stop = threading.Event()
20
+ if idle_seconds <= 0:
21
+ return stop
22
+ interval = (check_seconds if check_seconds is not None
23
+ else max(1.0, min(30.0, idle_seconds / 4)))
24
+
25
+ def _loop() -> None:
26
+ log = logging.getLogger("kb.idle")
27
+ while not stop.wait(interval):
28
+ try:
29
+ target = embedder() if callable(embedder) else embedder
30
+ if target.unload_if_idle():
31
+ log.info("嵌入模型空闲超过 %s 秒,已卸载", idle_seconds)
32
+ except Exception as exc: # noqa: BLE001 - 守护线程不因单次失败退出
33
+ log.warning("空闲卸载检查失败:%s", exc)
34
+
35
+ threading.Thread(target=_loop, name="kb-idle-unload", daemon=True).start()
36
+ return stop
@@ -767,6 +767,10 @@ class KBService:
767
767
  - v2.1: with project, retrieval covers that project's sub-store plus the global store;
768
768
  an ask access-audit is emitted."""
769
769
  if self.llm.status is LLMStatus.DISABLED:
770
+ if self.settings.llm_mode == "off":
771
+ raise LLMDisabledError(
772
+ "LLM 已关闭(KB_LLM_MODE=off,默认不调用 LLM);"
773
+ "如需 /ask 问答请配置 local/auto/cloud")
770
774
  raise LLMDisabledError("LLM 不可用:本地 Ollama 未响应且未配置云端 Key")
771
775
  results = sorted(self.search(question, top_k=5, agent_id=agent_id,
772
776
  client=client, project=project),
@@ -47,10 +47,16 @@ def _is_supported(path: Path) -> bool:
47
47
  class KBWatcher:
48
48
  """目录监听器:watchdog Observer 采集事件 + 后台 worker 去抖处理。"""
49
49
 
50
- def __init__(self, service, watch_dir: Path, debounce_seconds: float = 2.0):
51
- """service 为 KBService;watch_dir 为监听目录(仅顶层,不递归)。"""
50
+ def __init__(self, service, watch_dir: Path, debounce_seconds: float = 2.0,
51
+ ignore_dir: Path | None = None):
52
+ """service 为 KBService;watch_dir 为监听目录(仅顶层,不递归)。
53
+
54
+ ignore_dir 为服务数据目录:与 watch_dir 重合时 start() 跳过监听,
55
+ 避免把 bm25_corpus.json 等数据文件反复入库(自我入库死循环)。
56
+ """
52
57
  self.service = service
53
58
  self.watch_dir = Path(watch_dir)
59
+ self.ignore_dir = Path(ignore_dir) if ignore_dir is not None else None
54
60
  self.debounce_seconds = debounce_seconds
55
61
  self._pending: dict[Path, float] = {} # 待入库路径 → 最近一次事件时间
56
62
  self._deletes: queue.Queue[str] = queue.Queue() # 待删除 source(文件名)
@@ -60,6 +66,15 @@ class KBWatcher:
60
66
  self._worker: threading.Thread | None = None
61
67
 
62
68
  # ---- 生命周期 ----
69
+ def _overlaps_data_dir(self) -> bool:
70
+ """监听目录是否就是数据目录(相对路径/符号链接解析后比较)。"""
71
+ if self.ignore_dir is None:
72
+ return False
73
+ try:
74
+ return self.watch_dir.resolve() == self.ignore_dir.resolve()
75
+ except OSError:
76
+ return False
77
+
63
78
  def start(self) -> None:
64
79
  """启动 watchdog Observer 与后台处理线程。
65
80
 
@@ -75,6 +90,11 @@ class KBWatcher:
75
90
  logger.info("监听目录不存在或不可用,跳过目录监听:%s",
76
91
  self.watch_dir)
77
92
  return
93
+ if self._overlaps_data_dir():
94
+ logger.warning(
95
+ "监听目录与数据目录相同(%s),已跳过目录监听以避免自我入库;"
96
+ "请把 KB_WATCH_DIR 指向数据目录之外", self.watch_dir)
97
+ return
78
98
  self._observer = Observer()
79
99
  self._observer.schedule(_EventHandler(self), str(self.watch_dir),
80
100
  recursive=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kb-memory
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM
5
5
  Author: fish827-08
6
6
  License-Expression: Apache-2.0
@@ -60,7 +60,7 @@ Dynamic: license-file
60
60
 
61
61
  <!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
62
62
 
63
- **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.0 · 自动化回归全绿,待人工验收)。
63
+ **kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
64
64
 
65
65
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
66
66
 
@@ -150,7 +150,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
150
150
  >
151
151
  > **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
152
152
  > 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
153
- > 可另装 CUDA 版 torch,见 [AGENTS.md](AGENTS.md) 第 7 节;也可显式 `KB_DEVICE=cuda`。
153
+ > 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
154
154
  >
155
155
  > **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
156
156
  > 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
@@ -398,18 +398,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
398
398
  ```
399
399
  kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
400
400
  service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
401
- tests/ kb 验收测试(417 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
402
- docs/ 核心文档、当前设计(历史 spec/plan 已归档本地,不上传)
401
+ tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
402
+ docs/ 用户指南、开发者手册与设计文档
403
403
  kb_data/ kb 运行数据(gitignore)
404
- _archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
405
404
  ```
406
405
 
407
406
  ## 更多文档
408
407
 
409
- - **用户使用手册(人类用户入口)**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
410
- - **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
408
+ - **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
411
409
  - 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
412
410
  - **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
413
411
  - 开发者维护手册:`docs/DEV_HANDBOOK.md`
414
- - AI 协作规范:`AGENTS.md`
415
- - 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
@@ -14,6 +14,7 @@ kb/embedder.py
14
14
  kb/eval.py
15
15
  kb/governance.py
16
16
  kb/i18n.py
17
+ kb/idle.py
17
18
  kb/ingest.py
18
19
  kb/llm.py
19
20
  kb/logging_setup.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kb-memory"
7
- version = "2.1.0"
7
+ version = "2.1.2"
8
8
  description = "Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -51,4 +51,6 @@ def test_禁用转503(env_isolated):
51
51
  r = c.post("/api/v1/ask", json={"question": "测试"})
52
52
  assert r.status_code == 503
53
53
  assert r.json()["error"] == "LLM_DISABLED"
54
- assert "Ollama" in r.json()["message"]
54
+ # 默认 KB_LLM_MODE=off:必须说明是主动关闭,而不是误报 Ollama 不可用
55
+ assert "已关闭" in r.json()["message"]
56
+ assert "KB_LLM_MODE=off" in r.json()["message"]
@@ -39,3 +39,38 @@ def test_文件删除级联清理(env_isolated):
39
39
  assert s.list_records(q="将被删除")[1] == 0
40
40
  finally:
41
41
  w.stop()
42
+
43
+
44
+ def test_监听目录与数据目录相同时跳过(tmp_path, caplog):
45
+ """watch_dir == data_dir 时 listener 会把自己的 bm25 语料反复入库,必须跳过。"""
46
+ from kb.watcher import KBWatcher
47
+
48
+ class FakeService:
49
+ def add_document(self, path): # pragma: no cover - 不应被调用
50
+ raise AssertionError("不应入库")
51
+
52
+ data = tmp_path / "data"
53
+ data.mkdir()
54
+ w = KBWatcher(FakeService(), data, ignore_dir=data)
55
+ with caplog.at_level("WARNING", logger="kb.watcher"):
56
+ w.start()
57
+ try:
58
+ assert w._observer is None
59
+ assert w._worker is None
60
+ assert "自我入库" in caplog.text
61
+ finally:
62
+ w.stop()
63
+
64
+
65
+ def test_监听目录与数据目录不同仍正常监听(tmp_path):
66
+ """ignore_dir 只拦截与监听目录重合的数据目录,不影响正常监听。"""
67
+ from kb.watcher import KBWatcher
68
+
69
+ watch = tmp_path / "watch"; watch.mkdir()
70
+ data = tmp_path / "data"; data.mkdir()
71
+ w = KBWatcher(object(), watch, ignore_dir=data)
72
+ w.start()
73
+ try:
74
+ assert w._observer is not None and w._worker is not None
75
+ finally:
76
+ w.stop()
@@ -96,3 +96,37 @@ def test_清单发现与仅启动(boot, tmp_path, capsys):
96
96
  def test_无清单时状态提示(boot, capsys):
97
97
  assert boot.main(["--status", "--port", "1"]) == 1
98
98
  assert "未找到安装清单" in capsys.readouterr().out
99
+
100
+
101
+ class FailingFirstPipRunner(FakeRunner):
102
+ """首次 pip 安装 kb-memory 返回 1,模拟镜像未同步新包。"""
103
+
104
+ def __call__(self, cmd, check=False, capture_output=False, text=False):
105
+ self.calls.append(list(cmd))
106
+ joined = " ".join(cmd)
107
+ if "pip install kb-memory" in joined \
108
+ and "--extra-index-url" not in joined:
109
+ return SimpleNamespace(returncode=1, stdout="")
110
+ return SimpleNamespace(returncode=0, stdout="2.1.2\n")
111
+
112
+
113
+ def test_pip源缺包时自动官方兜底(boot, tmp_path, capsys):
114
+ inst = tmp_path / "inst"
115
+ venv_python = boot.venv_python(inst / "venv")
116
+ venv_python.parent.mkdir(parents=True)
117
+ venv_python.write_text("", encoding="utf-8")
118
+ runner = FailingFirstPipRunner()
119
+ rc = boot.main(["--yes", "--no-start", "--dir", str(inst),
120
+ "--port", "1"], runner=runner)
121
+ assert rc == 0
122
+ joined = [" ".join(c) for c in runner.calls]
123
+ assert any("--extra-index-url https://pypi.org/simple/" in j
124
+ for j in joined)
125
+ assert "官方 PyPI 兜底重试" in capsys.readouterr().out
126
+ assert boot.read_manifest() is not None
127
+
128
+
129
+ def test_兜底命令不重复追加(boot):
130
+ cmd = ["python", "-m", "pip", "install", "kb-memory[ingest]",
131
+ "--extra-index-url", "https://example.com/simple/"]
132
+ assert boot.fallback_command(cmd) is None
@@ -45,6 +45,36 @@ def test_空闲守护线程可启停(env_isolated):
45
45
  stop.set()
46
46
 
47
47
 
48
+ def test_服务生命周期启动并停止空闲守护(tmp_path, monkeypatch, env_isolated):
49
+ """create_app 返回包装后的 ASGI 函数,外部拿不到 app.state;守护线程必须在
50
+ lifespan 内用工厂里的 KBService 启动,否则空闲卸载永远不会生效。"""
51
+ from fastapi.testclient import TestClient
52
+ from kb import api as api_module
53
+ from kb.config import Settings
54
+ from kb.embedder import Embedder
55
+
56
+ started = {}
57
+ stopped = threading.Event()
58
+
59
+ class FakeStop:
60
+ def set(self):
61
+ stopped.set()
62
+
63
+ def fake_start(target, idle_seconds, check_seconds=None):
64
+ started["idle_seconds"] = idle_seconds
65
+ resolved = target() if callable(target) else target
66
+ started["target_is_embedder"] = isinstance(resolved, Embedder)
67
+ return FakeStop()
68
+
69
+ monkeypatch.setattr(api_module, "start_idle_watchdog", fake_start)
70
+ settings = Settings(data_dir=tmp_path / "data", log_dir=tmp_path / "logs",
71
+ idle_unload_seconds=42, llm_mode="off", watch_dir="")
72
+ with TestClient(api_module.create_app(settings)):
73
+ assert started["idle_seconds"] == 42
74
+ assert started["target_is_embedder"] is True
75
+ assert stopped.is_set(), "服务停止时应通知守护线程退出"
76
+
77
+
48
78
  def test_发现顺序_manifest优先(tmp_path, monkeypatch, env_isolated):
49
79
  from kb import discovery
50
80
  fake_home = tmp_path / "home"
@@ -96,6 +126,42 @@ def test_where命令输出(env_isolated):
96
126
  assert key in result.output
97
127
 
98
128
 
129
+ def test_version选项(env_isolated):
130
+ from typer.testing import CliRunner
131
+ from kb import __version__
132
+ from kb.cli import app
133
+ result = CliRunner().invoke(app, ["--version"])
134
+ assert result.exit_code == 0, result.output
135
+ assert __version__ in result.output
136
+
137
+
138
+ def test_发现数据目录为绝对路径(tmp_path, monkeypatch, env_isolated):
139
+ """相对 data_dir 实际按启动 cwd 落盘;where 必须显示绝对路径,避免误解。"""
140
+ from kb import discovery
141
+ from kb.config import Settings
142
+ fake_home = tmp_path / "home"
143
+ fake_home.mkdir()
144
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
145
+ monkeypatch.setattr(discovery.shutil, "which", lambda name: None)
146
+ monkeypatch.chdir(tmp_path)
147
+ settings = Settings(data_dir=Path("relative_data"),
148
+ log_dir=tmp_path / "logs", llm_mode="off",
149
+ watch_dir="")
150
+ info = discovery.discover(settings)
151
+ assert Path(info["data_dir"]) == (tmp_path / "relative_data").resolve()
152
+
153
+
154
+ def test_ask关闭时提示已关闭(monkeypatch, env_isolated):
155
+ from kb.service import KBService, LLMDisabledError
156
+ monkeypatch.setenv("KB_LLM_MODE", "off")
157
+ from kb import config
158
+ config.get_settings.cache_clear()
159
+ svc = KBService()
160
+ with pytest.raises(LLMDisabledError) as exc:
161
+ svc.ask("任意问题")
162
+ assert "已关闭" in str(exc.value)
163
+
164
+
99
165
  @pytest.mark.skipif(os.name == "nt", reason="Windows 启动项由 .cmd 实现")
100
166
  def test_linux_systemd_unit内容(tmp_path, monkeypatch, env_isolated):
101
167
  from kb import discovery
@@ -1,2 +0,0 @@
1
- """kb:本地 Agent 记忆与知识服务。"""
2
- __version__ = "2.1.0" # 对应 git tag v2.1.0(生命周期日志 version= 字段用)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes