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.
- {kb_memory-2.1.0 → kb_memory-2.1.1}/PKG-INFO +2 -2
- {kb_memory-2.1.0 → kb_memory-2.1.1}/README.md +1 -1
- kb_memory-2.1.1/kb/__init__.py +2 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/api.py +8 -1
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/cli.py +21 -38
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/discovery.py +13 -3
- kb_memory-2.1.1/kb/idle.py +36 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/service.py +4 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/PKG-INFO +2 -2
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/SOURCES.txt +1 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/pyproject.toml +1 -1
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_bootstrap.py +34 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_runtime.py +66 -0
- kb_memory-2.1.0/kb/__init__.py +0 -2
- {kb_memory-2.1.0 → kb_memory-2.1.1}/LICENSE +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/__main__.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/audit.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/bm25.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/config.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/consolidation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/embedder.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/eval.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/governance.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/i18n.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/ingest.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/llm.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/logging_setup.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/mcp.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/models.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/monitor.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/reranker.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/retriever.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/scopes.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/sparse.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/storage.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb/watcher.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/dependency_links.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/entry_points.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/requires.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/kb_memory.egg-info/top_level.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/setup.cfg +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_agent_isolation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_api_auth.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_charset_json.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_consolidation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_embed_model_resolution.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_hotfix_validation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_bm25_batch.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_concurrency.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_improvements_isolation_recall.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n01_config.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n02_models.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n03_embedder.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n04_storage.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n05_bm25.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n06_retriever_service.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n07_api.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n08_api_ext.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n09_llm.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n10_ask.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n11_routing.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n12_mcp.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n13_ingest.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n13_ingest_extra.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n14_web.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n15_watcher.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n16_regression.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n17_logging.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_logs_api.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_logs_doc.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n18_monitor.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n19_nav.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n21_decay.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n21_metadata.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n22_dedup.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n22_governance.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n23_audit.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n23_cli.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n24_reranker.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n25_sparse.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n26_eval.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n27_perf.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n28_cli.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n29_security.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n30_mcp_stdio.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n31_serve_probe.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_n32_hybrid_fusion_recall.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_skill_package.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_user_experience_fixes.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_i18n.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.1}/tests/test_v21_packaging.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
13
|
+
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.1 · 自动化回归全绿,待人工验收)。
|
|
14
14
|
|
|
15
15
|
> 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
|
|
16
16
|
|
|
@@ -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
|
|
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
|
-
|
|
391
|
-
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
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.
|
|
63
|
+
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.1 · 自动化回归全绿,待人工验收)。
|
|
64
64
|
|
|
65
65
|
> 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
|
|
66
66
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kb-memory"
|
|
7
|
-
version = "2.1.
|
|
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
|
kb_memory-2.1.0/kb/__init__.py
DELETED
|
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
|
|
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
|
|
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
|
|
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
|