kb-memory 2.1.0__py3-none-any.whl

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/config.py ADDED
@@ -0,0 +1,136 @@
1
+ """全局配置:环境变量前缀 KB_,支持 .env 文件。"""
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """全部配置;环境变量前缀 KB_,支持 .env 文件。"""
10
+
11
+ model_config = SettingsConfigDict(
12
+ env_prefix="KB_", env_file=".env", extra="ignore"
13
+ )
14
+
15
+ data_dir: Path = Path("kb_data") # 运行数据根目录
16
+ device: str = "" # 空=自动检测;显式设 cpu/cuda 覆盖
17
+ # ---- v2.1 项目记忆与资源占用 ----
18
+ project_cache_size: int = 4 # 同时打开的项目子库上限(LRU);全局库不计入
19
+ project_boost: float = 0.05 # 项目子库命中加分(检索合并时优先项目记忆)
20
+ idle_unload_seconds: int = 600 # 嵌入模型空闲卸载秒数;0=不卸载
21
+ memory_lang: str = "auto" # 记忆书写语言:auto=系统 locale,或 zh/en
22
+ rules_max_chars: int = 4000 # 自定义规则注入上限,防止提示词膨胀
23
+ embed_model: str = "BAAI/bge-small-zh-v1.5" # 轻量优先(设计文档 §3.1,2026-10-19):~92MB/512维;可显式换 BAAI/bge-m3
24
+ llm_mode: str = "off" # off(默认,完全不加载/调用LLM) | local | auto | cloud
25
+ ollama_base_url: str = "http://localhost:11434"
26
+ llm_model: str = "" # 本地 Ollama 模型名(空=未配置,用户按自己电脑显存/需求自配,以 ollama list 为准)
27
+ deepseek_api_key: str = "" # 兼容旧配置(已弃用,见 llm_api_key/llm_base_url/llm_model)
28
+ deepseek_base_url: str = "https://api.deepseek.com"
29
+ deepseek_model: str = "deepseek-v4-flash"
30
+ # 通用云端 LLM(OpenAI 兼容接口):任意服务商(OpenAI/DeepSeek/通义/硅基流动等)
31
+ llm_api_key: str = "" # KB_LLM_API_KEY:云服务商 API Key
32
+ llm_base_url: str = "https://api.deepseek.com" # KB_LLM_BASE_URL:OpenAI 兼容端点 base_url
33
+ llm_cloud_model: str = "deepseek-v4-flash" # KB_LLM_CLOUD_MODEL:云端模型名
34
+ chunk_size: int = 500
35
+ chunk_overlap: int = 100
36
+ watch_dir: Path = Path("data") # 目录监听(serve 挂载);空串/"."=不启动
37
+ api_host: str = "127.0.0.1"
38
+ api_port: int = 8000
39
+ api_key: str = "" # KB_API_KEY:空=不鉴权(本地回环零摩擦);非空=启用 Bearer/X-API-Key 鉴权(N19/TASK-0062)
40
+ context_token_limit: int = 2000 # /ask 检索上下文 token 硬上限
41
+ llm_max_tokens: int = 800
42
+ llm_temperature: float = 0.2
43
+ cache_size: int = 100 # /ask 答案缓存条数(LRU)
44
+ cache_sim_threshold: float = 0.95 # 缓存命中相似度阈值
45
+ sensitive_namespaces: str = "" # 逗号分隔,敏感 namespace 强制本地
46
+ # ---- 日志(日志设计文档第 3 节,N17)----
47
+ log_level: str = "INFO" # 全局级别(DEBUG 开发期排查用)
48
+ log_dir: Path = Path("logs") # 日志目录(相对仓库根;gitignore 排除)
49
+ log_max_bytes: int = 1048576 # 单文件 1MB,超限轮转
50
+ log_backup_count: int = 5 # 轮转保留备份数(合计 ≤6MB)
51
+ # ---- 本地监控 Agent(0015 设计书,N18;TASK-0021 去常驻改按需)----
52
+ monitor_enabled: bool = False # 默认不启常驻线程(去常驻:按需端点触发)
53
+ monitor_interval: int = 10 # 轮询间隔(分钟),≥1,非法回退默认(常驻模式用)
54
+ monitor_startup_run: bool = True # 启动时立即跑一轮(便于验证;常驻模式用)
55
+ monitor_max_tokens: int = 300 # 摘要输出上限(护栏:≤300,硬约束)
56
+ monitor_autotimer: int = 0 # KB_MONITOR_AUTOTIMER:外部轮询间隔(分钟),0=关
57
+ dispatch_enabled: bool = True # KB_DISPATCH_ENABLED:监控单轮后异常调度(comm:dispatch),默认开(TASK-0049)
58
+ monitor_llm: str = "off" # KB_MONITOR_LLM:监控摘要模式 off=全程纯文本不调LLM(默认零成本/不抢GPU) / auto=LLM可用摘要化不可用降级(TASK-0065)
59
+ # ---- 记忆治理 A3(N21b 衰减评分公式模块,TASK-0068;N22a 语义去重服务层,TASK-0069)----
60
+ # ---- Agent 身份隔离与存取审计(A 节点 spec:2026-08-29;v2 2026-08-30)----
61
+ access_audit_enabled: bool = True # KB_ACCESS_AUDIT_ENABLED:Agent 存取审计开关(access-audit.log)
62
+ lang: str = "auto" # KB_LANG:消息语言 zh/en/auto(auto=检测系统 locale;B4 节点)
63
+ decay_enabled: bool = False # KB_DECAY_ENABLED:访问频率衰减开关,默认关(零行为变化)
64
+ decay_lambda: float = 0.02 # KB_DECAY_LAMBDA:衰减速率 λ(/天),半衰期≈35天
65
+ decay_gamma: float = 0.3 # KB_DECAY_GAMMA:高频访问加权系数 γ,access_count=10→约2.0倍
66
+ # ---- 记忆治理 A3(N22b 新鲜度权重,TASK-0070)----
67
+ freshness_enabled: bool = False # KB_FRESHNESS_ENABLED:新鲜度权重开关,默认关(零行为变化)
68
+ freshness_beta: float = 0.05 # KB_FRESHNESS_BETA:新鲜度衰减速率 β(/天),半衰期≈14天
69
+ freshness_alpha: float = 0.3 # KB_FRESHNESS_ALPHA:新鲜度加权上限系数 α,boost范围[1,1.3]
70
+ # ---- 记忆治理 A3(N22a 语义去重,TASK-0069)----
71
+ dedup_enabled: bool = False # KB_DEDUP_ENABLED:语义去重开关,默认关(零行为变化)
72
+ dedup_threshold: float = 0.92 # KB_DEDUP_THRESHOLD:去重余弦相似度阈值,≥此值视为重复(BGE-M3,0.92以上高度重复)
73
+ # ---- 记忆治理审计(N23b/TASK-0073):治理操作结构化审计日志 ----
74
+ audit_dedup_enabled: bool = True # KB_AUDIT_DEDUP_ENABLED:去重拦截审计,默认开
75
+ audit_decay_enabled: bool = False # KB_AUDIT_DECAY_ENABLED:衰减降权审计,默认关
76
+ audit_freshness_enabled: bool = False # KB_AUDIT_FRESHNESS_ENABLED:新鲜度加权审计,默认关
77
+ # ---- A3 智能层 consolidation(N23c 基础框架,TASK-0076)----
78
+ consolidation_enabled: bool = False # KB_CONSOLIDATION_ENABLED:智能归并总开关,默认关(零行为变化)
79
+ consolidation_confidence_threshold: float = 0.6 # KB_CONSOLIDATION_CONFIDENCE_THRESHOLD:置信度门槛,低于强制 human
80
+ # ---- A3.5 检索质量(N24 reranker,2026-08-28 spec)----
81
+ rerank_enabled: bool = False # KB_RERANK_ENABLED:交叉重排开关,默认关(零行为变化)
82
+ rerank_model: str = "BAAI/bge-reranker-v2-m3" # KB_RERANK_MODEL:重排模型(~600MB fp16 显存)
83
+ rerank_top_n: int = 20 # KB_RERANK_TOP_N:参与精排的融合候选数上限
84
+ # ---- A3.5 检索质量(N25 稀疏向量,2026-08-28 spec)----
85
+ sparse_enabled: bool = False # KB_SPARSE_ENABLED:稀疏第三路开关,默认关(零行为变化)
86
+
87
+ @property
88
+ def chroma_dir(self) -> Path:
89
+ """全局库 ChromaDB 持久化目录。"""
90
+ return self.data_dir / "chroma"
91
+
92
+ @property
93
+ def projects_dir(self) -> Path:
94
+ """项目子库根目录:每个项目一个独立 Chroma/BM25。"""
95
+ return self.data_dir / "projects"
96
+
97
+ @property
98
+ def rules_file(self) -> Path:
99
+ """本安装的自定义记忆规则文件。"""
100
+ return self.data_dir / "rules.md"
101
+
102
+ @property
103
+ def runtime_file(self) -> Path:
104
+ """运行时选择持久化文件(如 GPU 加速选择)。"""
105
+ return self.data_dir / "runtime.json"
106
+
107
+ @property
108
+ def sensitive_ns_list(self) -> list[str]:
109
+ """把敏感 namespace 拆分为列表,空串→[]。"""
110
+ return [s.strip() for s in self.sensitive_namespaces.split(",") if s.strip()]
111
+
112
+ @property
113
+ def log_file(self) -> Path:
114
+ """日志文件路径(log_dir/kb.log)。"""
115
+ return self.log_dir / "kb.log"
116
+
117
+ @property
118
+ def cloud_api_key(self) -> str:
119
+ """云端 API Key:优先通用 KB_LLM_API_KEY,回退旧 KB_DEEPSEEK_API_KEY(兼容)。"""
120
+ return self.llm_api_key or self.deepseek_api_key
121
+
122
+ @property
123
+ def cloud_base_url(self) -> str:
124
+ """云端 base_url:优先通用 KB_LLM_BASE_URL,回退旧 KB_DEEPSEEK_BASE_URL(兼容)。"""
125
+ return self.llm_base_url or self.deepseek_base_url
126
+
127
+ @property
128
+ def cloud_model(self) -> str:
129
+ """云端模型名:优先 KB_LLM_CLOUD_MODEL,回退旧 KB_DEEPSEEK_MODEL(兼容)。"""
130
+ return self.llm_cloud_model or self.deepseek_model
131
+
132
+
133
+ @lru_cache
134
+ def get_settings() -> Settings:
135
+ """全局配置单例;测试用环境变量隔离时调用 get_settings.cache_clear()。"""
136
+ return Settings()
kb/consolidation.py ADDED
@@ -0,0 +1,228 @@
1
+ """智能层 consolidation 基础模块(A3 智能层 spec,N23c/TASK-0076)。
2
+
3
+ 职责(基础框架,提示词按 TASK-0074 spec §4 落地):
4
+ - MergeResult:单对记录的归并决策结果模型
5
+ - detect_conflict:启发式冲突检测纯函数(spec §3.1,无 LLM 可用)
6
+ - consolidate_pair:LLM 归并决策封装(spec §4,复用 kb/llm.py,仅本地禁云端)
7
+ - consolidate_dry_run:dry-run 骨架(spec §7 preview 语义,只返回建议不写库)
8
+
9
+ 安全护栏(spec §9):默认关(consolidation_enabled=False 零行为变化);
10
+ LLM 输出校验失败 / 置信度低于门槛 → 降级 human;LLM 不可用 → 降级 human。
11
+ 零新增依赖;不触碰 service/api/retriever/cli/audit。
12
+ """
13
+ import json
14
+ import re
15
+ from datetime import datetime
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from kb.config import Settings
20
+ from kb.llm import LLMClient, LLMError
21
+ from kb.models import Record
22
+
23
+
24
+ class ConsolidationError(Exception):
25
+ """consolidation 不可用(未启用 / 配置非法等)。"""
26
+
27
+
28
+ class MergeResult(BaseModel):
29
+ """单对记录的归并决策结果(spec §4.3 输出结构)。
30
+
31
+ - action:merge(合并)/ independent(保持独立)/ human(人工确认)
32
+ - merged_content:merge 时的合并后内容;其余决策为 None
33
+ - confidence:LLM 置信度 0.0-1.0;低于门槛会被强制升级 human
34
+ - conflict_type:预筛冲突标记(attribute_conflict / temporal / none)
35
+ """
36
+
37
+ action: str
38
+ merged_content: str | None = None
39
+ confidence: float = 0.0
40
+ reason: str = ""
41
+ conflict_type: str = "none"
42
+
43
+
44
+ # 决策枚举(spec §4.1)
45
+ _ACTIONS = ("merge", "independent", "human")
46
+
47
+ # 属性模式:`关键词[::] 值`(值取到首个中文标点/换行为止,spec §3.1)
48
+ _KEY_VALUE_RE = re.compile(r"([\u4e00-\u9fa5A-Za-z0-9_\-]{1,16})\s*[::]\s*([^,。;\n]+)")
49
+ # 断定词(spec §3.1 陈述语气)
50
+ _ASSERTION_WORDS = ("是", "为", "用", "使用", "采用")
51
+ # 时间矛盾的判定阈值(spec §3.1:updated_at 差距 >30 天)
52
+ _TEMPORAL_GAP_DAYS = 30.0
53
+ # 同主题判定:共享字符 bigram 数下限(关键词重叠的轻量近似)
54
+ _TOPIC_OVERLAP_BIGRAMS = 3
55
+
56
+
57
+ def _bigrams(text: str) -> set[str]:
58
+ """文本的字符 bigram 集合(去空白后滑动窗口,用于同主题近似判定)。"""
59
+ cleaned = re.sub(r"\s+", "", text)
60
+ return {cleaned[i:i + 2] for i in range(len(cleaned) - 1)}
61
+
62
+
63
+ def _extract_key_values(content: str) -> dict[str, str]:
64
+ """从内容提取 key:value 属性对(spec §3.1 属性模式匹配)。
65
+
66
+ 同一 key 多次出现取首个(以最早表述为准,后续由 LLM 综合判断)。
67
+ """
68
+ kv: dict[str, str] = {}
69
+ for m in _KEY_VALUE_RE.finditer(content):
70
+ key = m.group(1).lower()
71
+ if key not in kv:
72
+ kv[key] = m.group(2).strip()
73
+ return kv
74
+
75
+
76
+ def detect_conflict(record_a: Record, record_b: Record) -> str:
77
+ """启发式冲突检测纯函数(spec §3.1,无 LLM 可用)。
78
+
79
+ 返回 conflict_type:
80
+ - attribute_conflict:两记录提取到同 key 不同值(值无包含关系)
81
+ - temporal:同主题(bigram 重叠≥3)+ updated_at 差>30 天 + 含断定词
82
+ - none:未检测到明显冲突(仍可送 LLM 判断是否互补合并)
83
+
84
+ 预筛只做标记不做决策,最终决策由 LLM 做出(spec §3.1)。
85
+ """
86
+ kv_a = _extract_key_values(record_a.content)
87
+ kv_b = _extract_key_values(record_b.content)
88
+ # ① 同 key 不同值(值包含关系视为兼容,如 Python vs Python 3.10)
89
+ for key, va in kv_a.items():
90
+ if key in kv_b:
91
+ vb = kv_b[key]
92
+ if va == vb or va in vb or vb in va:
93
+ continue # 同值或兼容细化
94
+ return "attribute_conflict"
95
+ # ② 时间矛盾:同主题 + 时间差>30天 + 陈述语气
96
+ overlap = _bigrams(record_a.content) & _bigrams(record_b.content)
97
+ if len(overlap) >= _TOPIC_OVERLAP_BIGRAMS and _has_assertion(
98
+ record_a.content, record_b.content):
99
+ try:
100
+ ta = datetime.fromisoformat(record_a.updated_at)
101
+ tb = datetime.fromisoformat(record_b.updated_at)
102
+ except (ValueError, TypeError):
103
+ return "none" # 日期无效不做时间判断
104
+ if abs((ta - tb).total_seconds()) > _TEMPORAL_GAP_DAYS * 86400:
105
+ return "temporal"
106
+ return "none"
107
+
108
+
109
+ def _has_assertion(*contents: str) -> bool:
110
+ """任一内容含断定词(是/为/用等,spec §3.1 陈述语气)。"""
111
+ return any(w in c for c in contents for w in _ASSERTION_WORDS)
112
+
113
+
114
+ # ---- LLM 提示词(TASK-0074 spec §4.1/§4.2 原文落地) ----
115
+
116
+ _SYSTEM_PROMPT = """你是记忆归并代理(Memory Consolidation Agent)。
117
+ 任务:给定 2-5 条记忆记录,判断它们是否应合并为一条更完整的记忆,或保持独立,或需要人工确认。
118
+
119
+ 决策规则:
120
+ - merge:记录表达同一事实/同一主题互补信息/同一属性新值明确替代旧值 → 合并为一条
121
+ - independent:记录主题不同/角度不同且各自有独立检索价值 → 保持独立
122
+ - human:信息矛盾且无法判断哪个正确/合并可能丢失重要信息/置信度低 → 人工确认
123
+
124
+ 合并要求:
125
+ - merged_content 保留所有有价值信息,去重,语言简洁
126
+ - 若同一属性有新旧值,保留较新值(updated_at 较晚者),除非旧值明显更准确
127
+ - 不添加记录中不存在的信息
128
+ - 不删除可能有独立检索价值的细节
129
+
130
+ 输出严格 JSON,不输出任何其他文字:
131
+ {"decision":"merge|independent|human","reason":"简短原因","merged_content":"合并后内容或null","conflict_type":"attribute_conflict|temporal|none","confidence":0.0-1.0}"""
132
+
133
+
134
+ def _build_user_prompt(record_a: Record, record_b: Record,
135
+ conflict_type: str) -> str:
136
+ """用户提示词模板(spec §4.2):记录列表 + 预筛冲突标记。"""
137
+
138
+ def _line(r: Record) -> str:
139
+ return (f"[id={r.id}] created_at={r.created_at} "
140
+ f"updated_at={r.updated_at} tags={r.tags} source={r.source}\n"
141
+ f"内容:{r.content}")
142
+
143
+ return (f"记忆记录列表:\n{_line(record_a)}\n{_line(record_b)}\n\n"
144
+ f"预筛冲突标记:{conflict_type}"
145
+ f"(若为 none 表示未检测到明显冲突)\n\n"
146
+ f"请按系统提示规则决策,输出严格 JSON。")
147
+
148
+
149
+ def _parse_llm_output(raw: str, threshold: float) -> MergeResult:
150
+ """解析并校验 LLM 输出(spec §4.3)。
151
+
152
+ - JSON 解析失败 / decision 不在枚举 / merge 时 merged_content 空 → 降级 human
153
+ - confidence < threshold → 强制升级 human
154
+ """
155
+ fallback = MergeResult(
156
+ action="human", confidence=0.0,
157
+ reason="LLM 输出校验失败,降级人工确认")
158
+ try:
159
+ data = json.loads(raw)
160
+ except (ValueError, TypeError):
161
+ return fallback
162
+ if not isinstance(data, dict):
163
+ return fallback
164
+ action = data.get("decision")
165
+ if action not in _ACTIONS:
166
+ return fallback
167
+ try:
168
+ confidence = float(data.get("confidence", 0.0))
169
+ except (ValueError, TypeError):
170
+ return fallback
171
+ merged = data.get("merged_content") or None
172
+ # merge 决策必须带非空合并内容
173
+ if action == "merge" and not (merged and merged.strip()):
174
+ return fallback
175
+ # 置信度门槛:低于强制 human(spec §4.3)
176
+ if confidence < threshold:
177
+ return MergeResult(
178
+ action="human", confidence=confidence,
179
+ reason=f"置信度 {confidence:.2f} 低于门槛 {threshold},强制人工确认",
180
+ conflict_type=data.get("conflict_type", "none"))
181
+ return MergeResult(
182
+ action=action,
183
+ merged_content=merged if action == "merge" else None,
184
+ confidence=confidence,
185
+ reason=str(data.get("reason", "")),
186
+ conflict_type=data.get("conflict_type", "none"))
187
+
188
+
189
+ def consolidate_pair(record_a: Record, record_b: Record,
190
+ client: LLMClient,
191
+ settings: Settings | None = None) -> MergeResult:
192
+ """LLM 归并决策封装(spec §4/§6):对一对记录调本地 LLM 返回 MergeResult。
193
+
194
+ - 复用 kb/llm.py LLMClient.chat,prefer="local"(智能层禁止云端外传)
195
+ - 预筛冲突标记(detect_conflict)作为用户提示上下文
196
+ - LLM 不可用/调用失败 → 该对降级 human(不抛异常,spec §6)
197
+ """
198
+ threshold = (settings or Settings()).consolidation_confidence_threshold
199
+ conflict_type = detect_conflict(record_a, record_b)
200
+ messages = [
201
+ {"role": "system", "content": _SYSTEM_PROMPT},
202
+ {"role": "user", "content": _build_user_prompt(
203
+ record_a, record_b, conflict_type)},
204
+ ]
205
+ try:
206
+ raw = client.chat(messages, prefer="local")
207
+ except LLMError as exc:
208
+ return MergeResult(
209
+ action="human", confidence=0.0,
210
+ reason=f"LLM 不可用({exc}),降级人工确认",
211
+ conflict_type=conflict_type)
212
+ return _parse_llm_output(raw, threshold)
213
+
214
+
215
+ def consolidate_dry_run(pairs: list[tuple[Record, Record]],
216
+ client: LLMClient,
217
+ settings: Settings) -> list[MergeResult]:
218
+ """dry-run 归并建议(spec §7 preview 语义):逐对返回建议,不写库。
219
+
220
+ - consolidation_enabled=False 时抛 ConsolidationError(默认关,零行为变化)
221
+ - 单对失败(LLM 不可用/输出非法)降级 human,不阻塞整批(spec §6)
222
+ - 纯计算无副作用:不触碰存储层,apply/rollback 由后续节点实现
223
+ """
224
+ if not settings.consolidation_enabled:
225
+ raise ConsolidationError(
226
+ "consolidation 未启用(需 KB_CONSOLIDATION_ENABLED=true)")
227
+ return [consolidate_pair(a, b, client, settings)
228
+ for a, b in pairs]
kb/discovery.py ADDED
@@ -0,0 +1,264 @@
1
+ """服务发现与登录自启(v2.1 spec §3.3)。
2
+
3
+ 安装清单 `~/.kb/install.json` 由 skill 引导脚本写入;`kb where` 按
4
+ manifest → PATH → importlib.metadata 的顺序定位服务安装,`kb autostart`
5
+ 在 Windows 启动目录 / systemd user unit / macOS LaunchAgent 之间切换。
6
+
7
+ English: Service discovery and login autostart (v2.1 spec §3.3). The install
8
+ manifest ~/.kb/install.json is written by the skill bootstrap; `kb where`
9
+ locates the service via manifest → PATH → importlib.metadata, and
10
+ `kb autostart` manages the Windows Startup entry / systemd user unit /
11
+ macOS LaunchAgent.
12
+ """
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ APP_NAME = "kb"
20
+ UNIT_NAME = "kb.service"
21
+ LAUNCH_LABEL = "io.github.fish827.kb"
22
+
23
+
24
+ def manifest_path() -> Path:
25
+ """安装清单路径:~/.kb/install.json。"""
26
+ return Path.home() / ".kb" / "install.json"
27
+
28
+
29
+ def read_manifest() -> dict | None:
30
+ """读取安装清单;缺失或损坏返回 None。"""
31
+ path = manifest_path()
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except (OSError, json.JSONDecodeError):
35
+ return None
36
+ return data if isinstance(data, dict) else None
37
+
38
+
39
+ def write_manifest(*, python: str, version: str, data_dir: str,
40
+ extras: str = "", venv: str = "") -> Path:
41
+ """写入安装清单(skill 引导安装成功后调用)。
42
+ English: Write the install manifest after a successful bootstrap install."""
43
+ from datetime import datetime
44
+ path = manifest_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload = {
47
+ "python": str(python),
48
+ "version": version,
49
+ "data_dir": str(data_dir),
50
+ "extras": extras,
51
+ "venv": str(venv),
52
+ "installed_at": datetime.now().isoformat(timespec="seconds"),
53
+ }
54
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
55
+ encoding="utf-8")
56
+ return path
57
+
58
+
59
+ def _package_version() -> str:
60
+ try:
61
+ from importlib.metadata import version
62
+ return version("kb-memory")
63
+ except Exception:
64
+ from kb import __version__
65
+ return __version__
66
+
67
+
68
+ def _running(host: str, port: int) -> bool:
69
+ """探测本机服务健康端点(0.5s 超时,不依赖第三方库)。"""
70
+ import urllib.request
71
+ try:
72
+ with urllib.request.urlopen(
73
+ f"http://{host}:{port}/api/v1/healthz", timeout=0.5) as resp:
74
+ return resp.status == 200
75
+ except Exception:
76
+ return False
77
+
78
+
79
+ def discover(settings=None) -> dict:
80
+ """按 manifest → PATH → 当前解释器 的顺序发现服务安装。
81
+
82
+ 返回 {source, python, version, data_dir, running, endpoint, command}。
83
+ English: Discover the service installation in manifest → PATH → current
84
+ interpreter order. Returns a dict describing the installation.
85
+ """
86
+ if settings is None:
87
+ from kb.config import get_settings
88
+ settings = get_settings()
89
+ host, port = settings.api_host, settings.api_port
90
+ manifest = read_manifest()
91
+ if manifest and manifest.get("python") and Path(manifest["python"]).exists():
92
+ return {
93
+ "source": "manifest",
94
+ "python": manifest["python"],
95
+ "version": manifest.get("version") or _package_version(),
96
+ "data_dir": manifest.get("data_dir") or str(settings.data_dir),
97
+ "venv": manifest.get("venv") or "",
98
+ "running": _running(host, port),
99
+ "endpoint": f"http://{host}:{port}/mcp/",
100
+ "command": [manifest["python"], "-m", "kb", "serve",
101
+ "--non-interactive"],
102
+ }
103
+ kb_bin = shutil.which("kb")
104
+ if kb_bin:
105
+ python = sys.executable
106
+ if os.name == "nt":
107
+ candidate = Path(kb_bin).with_name("python.exe")
108
+ if candidate.exists():
109
+ python = str(candidate)
110
+ return {
111
+ "source": "path",
112
+ "python": python,
113
+ "version": _package_version(),
114
+ "data_dir": str(settings.data_dir),
115
+ "venv": "",
116
+ "running": _running(host, port),
117
+ "endpoint": f"http://{host}:{port}/mcp/",
118
+ "command": [python, "-m", "kb", "serve", "--non-interactive"],
119
+ }
120
+ return {
121
+ "source": "package",
122
+ "python": sys.executable,
123
+ "version": _package_version(),
124
+ "data_dir": str(settings.data_dir),
125
+ "venv": "",
126
+ "running": _running(host, port),
127
+ "endpoint": f"http://{host}:{port}/mcp/",
128
+ "command": [sys.executable, "-m", "kb", "serve",
129
+ "--non-interactive"],
130
+ }
131
+
132
+
133
+ # ---- 登录自启 ----
134
+
135
+ def autostart_file() -> Path:
136
+ """当前平台的登录自启文件路径。"""
137
+ if os.name == "nt":
138
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData/Roaming")
139
+ return (Path(base) / "Microsoft" / "Windows" / "Start Menu"
140
+ / "Programs" / "Startup" / "kb.cmd")
141
+ if sys.platform == "darwin":
142
+ return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_LABEL}.plist"
143
+ return Path.home() / ".config" / "systemd" / "user" / UNIT_NAME
144
+
145
+
146
+ def _service_target(info: dict) -> tuple[str, str]:
147
+ """确定自启解释器与数据目录:manifest 优先,其次当前解释器/配置。"""
148
+ python = info.get("python") or sys.executable
149
+ data_dir = info.get("data_dir") or ""
150
+ return python, data_dir
151
+
152
+
153
+ def _windows_content(info: dict) -> str:
154
+ python, data_dir = _service_target(info)
155
+ pythonw = str(Path(python).with_name("pythonw.exe"))
156
+ exe = pythonw if Path(pythonw).exists() else python
157
+ lines = ["@echo off"]
158
+ if data_dir:
159
+ lines.append(f'set "KB_DATA_DIR={data_dir}"')
160
+ lines.append(f'start "" "{exe}" -m kb serve --non-interactive')
161
+ return "\r\n".join(lines) + "\r\n"
162
+
163
+
164
+ def _systemd_content(info: dict) -> str:
165
+ python, data_dir = _service_target(info)
166
+ env = f"Environment=KB_DATA_DIR={data_dir}\n" if data_dir else ""
167
+ return (
168
+ "[Unit]\n"
169
+ "Description=kb local memory & knowledge service\n"
170
+ "After=network.target\n\n"
171
+ "[Service]\n"
172
+ "Type=simple\n"
173
+ f"ExecStart={python} -m kb serve --non-interactive\n"
174
+ f"{env}"
175
+ "Restart=on-failure\n"
176
+ "RestartSec=5\n\n"
177
+ "[Install]\n"
178
+ "WantedBy=default.target\n"
179
+ )
180
+
181
+
182
+ def _launchd_content(info: dict) -> str:
183
+ python, data_dir = _service_target(info)
184
+ env = ""
185
+ if data_dir:
186
+ env = (" <key>EnvironmentVariables</key>\n <dict>\n"
187
+ " <key>KB_DATA_DIR</key>\n"
188
+ f" <string>{data_dir}</string>\n </dict>\n")
189
+ return (
190
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
191
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
192
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
193
+ f'<plist version="1.0">\n<dict>\n'
194
+ f' <key>Label</key>\n <string>{LAUNCH_LABEL}</string>\n'
195
+ ' <key>ProgramArguments</key>\n <array>\n'
196
+ f' <string>{python}</string>\n'
197
+ ' <string>-m</string>\n <string>kb</string>\n'
198
+ ' <string>serve</string>\n'
199
+ ' <string>--non-interactive</string>\n </array>\n'
200
+ ' <key>RunAtLoad</key>\n <true/>\n'
201
+ f'{env}'
202
+ '</dict>\n</plist>\n'
203
+ )
204
+
205
+
206
+ def autostart_enable(info: dict | None = None, run: bool = True) -> dict:
207
+ """写入并启用登录自启;返回 {file, platform, activated}。
208
+ English: Write and enable login autostart; returns {file, platform, activated}."""
209
+ import subprocess
210
+ info = info or discover()
211
+ path = autostart_file()
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ activated = False
214
+ if os.name == "nt":
215
+ path.write_text(_windows_content(info), encoding="utf-8",
216
+ newline="")
217
+ elif sys.platform == "darwin":
218
+ path.write_text(_launchd_content(info), encoding="utf-8")
219
+ if run:
220
+ activated = _run(["launchctl", "load", str(path)]) is not None
221
+ else:
222
+ path.write_text(_systemd_content(info), encoding="utf-8")
223
+ if run:
224
+ _run(["systemctl", "--user", "daemon-reload"])
225
+ activated = _run(["systemctl", "--user", "enable", "--now",
226
+ UNIT_NAME]) is not None
227
+ return {"file": str(path), "platform": sys.platform,
228
+ "activated": activated}
229
+
230
+
231
+ def autostart_disable(run: bool = True) -> dict:
232
+ """删除自启文件并停用;返回 {file, removed, deactivated}。"""
233
+ path = autostart_file()
234
+ deactivated = False
235
+ if run and sys.platform == "darwin" and path.exists():
236
+ deactivated = _run(["launchctl", "unload", str(path)]) is not None
237
+ elif run and os.name != "nt" and path.exists():
238
+ _run(["systemctl", "--user", "disable", "--now", UNIT_NAME])
239
+ deactivated = _run(["systemctl", "--user", "daemon-reload"]) is not None
240
+ removed = path.exists()
241
+ try:
242
+ path.unlink(missing_ok=True)
243
+ except OSError:
244
+ removed = False
245
+ return {"file": str(path), "removed": removed,
246
+ "deactivated": deactivated}
247
+
248
+
249
+ def autostart_status() -> dict:
250
+ """自启状态:是否已配置 + 文件路径 + 平台。"""
251
+ path = autostart_file()
252
+ return {"enabled": path.exists(), "file": str(path),
253
+ "platform": sys.platform}
254
+
255
+
256
+ def _run(cmd: list[str]) -> int | None:
257
+ """执行系统命令并吞掉输出;失败返回 None(不抛异常,自启失败可降级提示)。"""
258
+ import subprocess
259
+ try:
260
+ proc = subprocess.run(cmd, capture_output=True, text=True,
261
+ timeout=15)
262
+ return proc.returncode if proc.returncode == 0 else None
263
+ except (OSError, subprocess.SubprocessError):
264
+ return None