kb-memory 2.1.0__tar.gz → 2.1.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 (92) hide show
  1. {kb_memory-2.1.0 → kb_memory-2.1.1}/PKG-INFO +2 -2
  2. {kb_memory-2.1.0 → kb_memory-2.1.1}/README.md +1 -1
  3. kb_memory-2.1.1/kb/__init__.py +2 -0
  4. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/api.py +8 -1
  5. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/cli.py +21 -38
  6. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/discovery.py +13 -3
  7. kb_memory-2.1.1/kb/idle.py +36 -0
  8. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/service.py +4 -0
  9. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/PKG-INFO +2 -2
  10. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/SOURCES.txt +1 -0
  11. {kb_memory-2.1.0 → kb_memory-2.1.1}/pyproject.toml +1 -1
  12. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_bootstrap.py +34 -0
  13. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_runtime.py +66 -0
  14. kb_memory-2.1.0/kb/__init__.py +0 -2
  15. {kb_memory-2.1.0 → kb_memory-2.1.1}/LICENSE +0 -0
  16. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/__main__.py +0 -0
  17. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/audit.py +0 -0
  18. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/bm25.py +0 -0
  19. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/config.py +0 -0
  20. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/consolidation.py +0 -0
  21. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/embedder.py +0 -0
  22. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/eval.py +0 -0
  23. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/governance.py +0 -0
  24. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/i18n.py +0 -0
  25. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/ingest.py +0 -0
  26. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/llm.py +0 -0
  27. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/logging_setup.py +0 -0
  28. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/mcp.py +0 -0
  29. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/models.py +0 -0
  30. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/monitor.py +0 -0
  31. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/reranker.py +0 -0
  32. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/retriever.py +0 -0
  33. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/scopes.py +0 -0
  34. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/sparse.py +0 -0
  35. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/storage.py +0 -0
  36. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/watcher.py +0 -0
  37. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/dependency_links.txt +0 -0
  38. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/entry_points.txt +0 -0
  39. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/requires.txt +0 -0
  40. {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/top_level.txt +0 -0
  41. {kb_memory-2.1.0 → kb_memory-2.1.1}/setup.cfg +0 -0
  42. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_agent_isolation.py +0 -0
  43. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_api_auth.py +0 -0
  44. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_charset_json.py +0 -0
  45. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_consolidation.py +0 -0
  46. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_embed_model_resolution.py +0 -0
  47. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_hotfix_validation.py +0 -0
  48. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_bm25_batch.py +0 -0
  49. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_concurrency.py +0 -0
  50. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_isolation_recall.py +0 -0
  51. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n01_config.py +0 -0
  52. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n02_models.py +0 -0
  53. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n03_embedder.py +0 -0
  54. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n04_storage.py +0 -0
  55. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n05_bm25.py +0 -0
  56. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n06_retriever_service.py +0 -0
  57. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n07_api.py +0 -0
  58. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n08_api_ext.py +0 -0
  59. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n09_llm.py +0 -0
  60. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n10_ask.py +0 -0
  61. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n11_routing.py +0 -0
  62. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n12_mcp.py +0 -0
  63. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n13_ingest.py +0 -0
  64. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n13_ingest_extra.py +0 -0
  65. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n14_web.py +0 -0
  66. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n15_watcher.py +0 -0
  67. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n16_regression.py +0 -0
  68. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n17_logging.py +0 -0
  69. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_logs_api.py +0 -0
  70. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_logs_doc.py +0 -0
  71. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_monitor.py +0 -0
  72. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n19_nav.py +0 -0
  73. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n21_decay.py +0 -0
  74. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n21_metadata.py +0 -0
  75. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n22_dedup.py +0 -0
  76. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n22_governance.py +0 -0
  77. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n23_audit.py +0 -0
  78. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n23_cli.py +0 -0
  79. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n24_reranker.py +0 -0
  80. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n25_sparse.py +0 -0
  81. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n26_eval.py +0 -0
  82. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n27_perf.py +0 -0
  83. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n28_cli.py +0 -0
  84. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n29_security.py +0 -0
  85. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n30_mcp_stdio.py +0 -0
  86. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n31_serve_probe.py +0 -0
  87. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n32_hybrid_fusion_recall.py +0 -0
  88. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_skill_package.py +0 -0
  89. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_user_experience_fixes.py +0 -0
  90. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_i18n.py +0 -0
  91. {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_packaging.py +0 -0
  92. {kb_memory-2.1.0 → kb_memory-2.1.1}/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.1
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.1 · 自动化回归全绿,待人工验收)。
64
64
 
65
65
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
66
66
 
@@ -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.1 · 自动化回归全绿,待人工验收)。
14
14
 
15
15
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
16
16
 
@@ -0,0 +1,2 @@
1
+ """kb:本地 Agent 记忆与知识服务。"""
2
+ __version__ = "2.1.1" # 对应 git tag v2.1.1(生命周期日志 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:
@@ -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:
@@ -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),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kb-memory
3
- Version: 2.1.0
3
+ Version: 2.1.1
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.1 · 自动化回归全绿,待人工验收)。
64
64
 
65
65
  > 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
66
66
 
@@ -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.1"
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"
@@ -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.1\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
File without changes