@yottameta/yotta-logs 0.3.0 → 0.3.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## v0.3.1 (2026-09-13)
2
+
3
+ - 修复本机专属路径硬编码:移除本机自定义数据目录候选;opencode 只认 `XDG_DATA_HOME` / `OPENCODE_DATA` / 官方默认路径,Codex notes 只认 `$CODEX_HOME/memories` 或 `~/.codex/memories`。
4
+ - 配置路径改为平台无关:`$YOTTA_LOGS_CONFIG` > Windows `%APPDATA%` > Unix `$XDG_CONFIG_HOME` / `~/.config`。
5
+ - 新增便携覆盖回归:`YOTTA_MEMORY_HOME`、`CODEX_HOME`、`YOTTA_LOGS_CONFIG`;并加本机自定义目录反向断言。
6
+
1
7
  ## v0.3.0 (2026-09-08)
2
8
 
3
9
  **评测驱动完善**:新增 FAQ,安装器错误处理与测试补齐。
package/README.md CHANGED
@@ -162,6 +162,7 @@ bash install.sh --list # list agents -> default directories
162
162
 
163
163
  ## Changelog
164
164
 
165
+ - v0.3.1 (2026-09-13): Removed machine-specific hardcoded paths; discovery and config now follow environment variables and platform defaults, with regression coverage.
165
166
  - v0.3.0 (2026-09-08): Evaluation-driven refinement — added FAQ, hardened installer error handling and exit codes, and added installer tests.
166
167
 
167
168
  - v0.2.2 (2026-08-29): Install docs alignment — unified four install methods (npx -y @yottameta/yotta-logs --agent/--dir, git clone, GitHub Download ZIP, install.sh --agent/--dir/--list), removed the legacy GitHub-clone installer and global-install (-g) recommendations; bilingual README install section synced to 发布规范 §3.3.1. No functional change.
package/README.zh-CN.md CHANGED
@@ -162,6 +162,7 @@ bash install.sh --list # 列出智能体 -> 默认目录
162
162
 
163
163
  ## 更新日志
164
164
 
165
+ - v0.3.1(2026-09-13):移除本机专属硬编码路径;discovery 与配置统一走环境变量和平台默认位置,并补回归覆盖。
165
166
  - v0.3.0(2026-09-08):评测驱动完善——新增常见问题,安装器错误处理与退出码加固,并补齐安装器测试。
166
167
 
167
168
  - v0.2.2(2026-08-29):安装方式统一为四方式(对齐发布规范 §3.3.1)——方式一 npx -y @yottameta/yotta-logs --agent / --dir(推荐,走 npm 源);方式二 git clone;方式三 GitHub Download ZIP;方式四 bash install.sh --agent/--dir/--list。移除旧式 GitHub 克隆安装器与全局安装(-g)推荐;中英 README 安装节同步。无功能变更。
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yotta-logs
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  description: 元史 —— 跨智能体的历史会话 / 记忆日志检索技能:零依赖检索 / 分析 JSONL、JSON、SQLite、Markdown 多格式会话与记忆文件,回溯旧对话与父会话上下文,为跨会话追溯提供原始日志依据。触发:用户问起先前聊过的内容 / 父会话 / 历史上下文、要查以前说过的结论、跨会话回溯某次讨论、需要从会话日志或记忆文件定位某段决策时。边界:仅读取本机自己的会话日志 / 记忆文件;不修改、不删除;只查本地不联网上传。
5
5
  license: MIT
6
6
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-logs",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Yuanshi — a skill for retrieving historical session / memory logs across AI agents: zero-dependency search and analysis of JSONL, JSON, SQLite and Markdown session & memory files (unified Record + field-alias normalization + config fallback), with full source discovery to recall past conversations and parent-session context. Triggers when the user asks about previously discussed content / a parent session / historical context, wants to look up an earlier conclusion, traces a past discussion across sessions, or needs to locate a decision in session logs or memory files. Boundaries: reads only the local agent's own session logs / memory files; never modifies or deletes records; local-only, never uploaded.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -19,6 +19,7 @@
19
19
  "NOTICE",
20
20
  "CHANGELOG.md",
21
21
  "README.zh-CN.md",
22
+ "!scripts/test_*.py",
22
23
  "!scripts/__pycache__",
23
24
  "!**/__pycache__",
24
25
  "!**/*.pyc"
@@ -48,8 +48,8 @@
48
48
 
49
49
  ### 3.3 SQLite(sqlite)
50
50
 
51
- - 代表:opencode(~/.local/share/opencode/opencode.db;本机实测 D:\AI_WorkDir\.OpenCodeData\data\opencode\opencode.db,走 XDG_DATA_HOME / OPENCODE_DATA)、Cursor state.vscdb、Trae、Copilot CLI session-store、CodeBuddy。
52
- - opencode 实测 schema(2026-08-27,D:\AI_WorkDir\.OpenCodeData\data\opencode\opencode.db):
51
+ - 代表:opencode(~/.local/share/opencode/opencode.db;XDG_DATA_HOME / OPENCODE_DATA 可覆盖)、Cursor state.vscdb、Trae、Copilot CLI session-store、CodeBuddy。
52
+ - opencode 实测 schema(2026-08-27):
53
53
  - `session(id, project_id, title, cost, tokens_input, tokens_output, time_created[毫秒], ...)`
54
54
  - `message(id, session_id, time_created[毫秒], data[JSON: role, time, agent, model, ...])`
55
55
  - `part(id, message_id, session_id, time_created[毫秒], data[JSON: type=text/tool/reasoning/step-start...])`——text 部分取 `text` 字段;tool 部分取 `tool` 字段为工具名。
@@ -65,7 +65,7 @@
65
65
 
66
66
  - 代表:yotta-memory(记忆库 facts / private / archive 下的 *.md)、agent-code、opencode-agent-memory。
67
67
  - frontmatter:`type`(FACT/PREF/BOUND/COMMIT → role)、`subject` → title、`statement` → text、`created / updated / date` → time、`tags / confidence / scope / owner / immutable` → meta。
68
- - 本机实测样本(2026-08-27):D:\AI_WorkDir\.yottamemory\facts\2026-08-25-0002.md(记忆库位置由 ~/.yottamemory/config.json 的 `memory_home` 决定)。
68
+ - 实测样本路径:`<memory_home>/facts/*.md`。`memory_home` 由 `YOTTA_MEMORY_HOME` 或 `~/.yottamemory/config.json` 决定。
69
69
  - frontmatter 解析为零依赖 YAML 子集(key: value / key: [a, b] / 引号),非完整 YAML。
70
70
 
71
71
  ### 3.6 二进制 / 专有 / 加密(binary)
@@ -83,20 +83,20 @@
83
83
  | opencode-sessions | ~/.config/opencode/sessions | jsonl | session | 开 |
84
84
  | gemini-sessions | ~/.gemini/sessions | jsonl | session | 开 |
85
85
  | agents-sessions | ~/.agents/sessions | jsonl | session | 开 |
86
- | opencode-db | ~/.local/share/opencode/opencode.db;$XDG_DATA_HOME/opencode/opencode.db;$OPENCODE_DATA;~/.OpenCodeData/data/opencode/opencode.db | sqlite | session | 开 |
86
+ | opencode-db | ~/.local/share/opencode/opencode.db;$XDG_DATA_HOME/opencode/opencode.db;$OPENCODE_DATA | sqlite | session | 开 |
87
87
  | cursor-state / code-state | VS Code / Cursor globalStorage 下 state.vscdb(Windows / Linux / macOS) | sqlite | session | 开 |
88
88
  | continue-sessions | ~/.continue/sessions、~/.config/continue/sessions | json | session | 开 |
89
89
  | yottamemory-facts | 记忆库 facts(memory_home 配置) | markdown | memory | 开 |
90
90
  | yottamemory-private | 记忆库 private(memory_home 配置) | markdown | memory | 开 |
91
91
  | yottamemory-archive | 记忆库 archive(memory_home 配置) | markdown | memory | 开 |
92
- | codex-notes | $CODEX_HOME/memories、~/.CodexData/memories | markdown | note | 关(显式开) |
92
+ | codex-notes | $CODEX_HOME/memories、~/.codex/memories | markdown | note | 关(显式开) |
93
93
  | aider-history | 当前目录 *.aider.*.md | markdown | session | 开 |
94
94
  | windsurf-conv | ~/.codeium/windsurf、~/.windsurf 下 *.pbtxt | binary | log | 关 |
95
95
  | 自定义 sources | 配置 sources[](见下) | 任意 | 任意 | 配置 default_scope |
96
96
 
97
97
  ## 五、配置兜底(config.json)
98
98
 
99
- 路径:`$YOTTA_LOGS_CONFIG` 或 `~/.config/yotta-logs/config.json`。
99
+ 路径:`$YOTTA_LOGS_CONFIG`;未设置时使用平台默认位置:Windows = `%APPDATA%\yotta-logs\config.json`,Unix = `$XDG_CONFIG_HOME/yotta-logs/config.json` 或 `~/.config/yotta-logs/config.json`。
100
100
 
101
101
  ```json
102
102
  {
package/references/cli.md CHANGED
@@ -66,7 +66,7 @@
66
66
 
67
67
  ## 配置(配置兜底)
68
68
 
69
- - 配置文件:`$YOTTA_LOGS_CONFIG` 或 `~/.config/yotta-logs/config.json`;
69
+ - 配置文件:`$YOTTA_LOGS_CONFIG`;未设置时 Windows 用 `%APPDATA%\yotta-logs\config.json`,Unix 用 `$XDG_CONFIG_HOME/yotta-logs/config.json` 或 `~/.config/yotta-logs/config.json`;
70
70
  - `default_scope`:默认检索范围(默认 `["session", "memory"]`);
71
71
  - `sources[]`:自定义源(path / format / kind / name / table / col_time / col_role / col_text / col_session / col_title),引擎零改动接入怪格式。
72
72
  - 示例见 `agent-formats.md` 第五节。
@@ -64,7 +64,7 @@ try:
64
64
  except Exception:
65
65
  pass
66
66
 
67
- VERSION = "0.3.0"
67
+ VERSION = "0.3.1"
68
68
  TOOL_NAME = "yotta-logs"
69
69
  TOOL_CN = "元史"
70
70
  DEFAULT_LIMIT = 50
@@ -743,7 +743,6 @@ class SQLiteReader:
743
743
  cands += [
744
744
  ("opencode-db", base / ".local" / "share" / "opencode" / "opencode.db"),
745
745
  ("opencode-db", base / ".config" / "opencode" / "opencode.db"),
746
- ("opencode-db", base / ".OpenCodeData" / "data" / "opencode" / "opencode.db"),
747
746
  ]
748
747
  seen = set()
749
748
  for name, p in cands:
@@ -979,9 +978,8 @@ class MarkdownReader:
979
978
  if p.is_dir() and any(x.suffix.lower() in MD_SUFFIXES
980
979
  for x in p.iterdir()):
981
980
  out.append(_mk_source(name, "memory", "markdown", p))
982
- codex_home = os.environ.get("CODEX_HOME")
983
- codex_notes = Path(codex_home) / "memories" if codex_home \
984
- else base / ".CodexData" / "memories"
981
+ codex_home = os.environ.get("CODEX_HOME") or str(base / ".codex")
982
+ codex_notes = Path(codex_home) / "memories"
985
983
  if codex_notes.is_dir() and cls._has_md(codex_notes):
986
984
  out.append(_mk_source("codex-notes", "note", "markdown", codex_notes,
987
985
  default_on=False))
@@ -993,7 +991,10 @@ class MarkdownReader:
993
991
 
994
992
  @staticmethod
995
993
  def _memory_home(base):
996
- """yotta-memory 记忆库位置:优先读引擎 config.json 的 memory_home。"""
994
+ """yotta-memory 记忆库位置:优先环境变量,再读引擎 config.json。"""
995
+ env = os.environ.get("YOTTA_MEMORY_HOME")
996
+ if env:
997
+ return Path(env)
997
998
  try:
998
999
  cfg_p = base / ".yottamemory" / "config.json"
999
1000
  cfg = json.loads(cfg_p.read_text(encoding="utf-8", errors="replace"))
@@ -1284,11 +1285,23 @@ def sniff_source(path):
1284
1285
 
1285
1286
  # ── 配置兜底 + discover 全源登记 ─────────────────────────────────────────
1286
1287
 
1288
+ def default_config_path():
1289
+ """平台无关的默认配置路径;环境变量始终优先。"""
1290
+ env = os.environ.get("YOTTA_LOGS_CONFIG")
1291
+ if env:
1292
+ return Path(env)
1293
+ xdg = os.environ.get("XDG_CONFIG_HOME")
1294
+ if xdg:
1295
+ return Path(xdg) / "yotta-logs" / "config.json"
1296
+ if os.name == "nt":
1297
+ appdata = os.environ.get("APPDATA")
1298
+ if appdata:
1299
+ return Path(appdata) / "yotta-logs" / "config.json"
1300
+ return Path.home() / ".config" / "yotta-logs" / "config.json"
1301
+
1302
+
1287
1303
  def load_config():
1288
- p = os.environ.get("YOTTA_LOGS_CONFIG")
1289
- if not p:
1290
- p = str(Path.home() / ".config" / "yotta-logs" / "config.json")
1291
- cfg_path = Path(p)
1304
+ cfg_path = default_config_path()
1292
1305
  if not cfg_path.exists():
1293
1306
  return {}
1294
1307
  try:
@@ -1,757 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """test_yotta_logs.py — 元史(yotta-logs)测试。
4
-
5
- 覆盖:JSONL 解析容错 / 会话发现 / sessions.json 索引 / 角色与文本提取 /
6
- 默认脱敏 / scan / search(关键词·正则·日期·会话·角色·截断)/ session 提取 /
7
- stats(角色·成本·token·每日汇总)/ tools 排行 / CLI 退出码 / JSON 输出 /
8
- GBK 控制台 / 只读保证。纯标准库,无 pytest 依赖。
9
-
10
- 运行:python scripts/test_yotta_logs.py
11
- """
12
- import json
13
- import os
14
- import subprocess
15
- import sys
16
- import tempfile
17
- from pathlib import Path
18
-
19
- _HERE = Path(__file__).resolve().parent
20
- sys.path.insert(0, str(_HERE))
21
-
22
- import yotta_logs as YL # noqa: E402
23
-
24
- PASS = 0
25
- FAIL = 0
26
- FAILED = []
27
-
28
-
29
- def check(name, cond, detail=""):
30
- global PASS, FAIL
31
- if cond:
32
- PASS += 1
33
- else:
34
- FAIL += 1
35
- FAILED.append(name)
36
- print(" FAIL: %s %s" % (name, detail))
37
-
38
-
39
- def wjsonl(p, rows):
40
- lines = []
41
- for r in rows:
42
- if isinstance(r, str):
43
- lines.append(r)
44
- else:
45
- lines.append(json.dumps(r, ensure_ascii=False))
46
- p.write_text("\n".join(lines) + "\n", encoding="utf-8")
47
-
48
-
49
- def build_fixture(base):
50
- """构造一个真实形态的会话日志目录,返回目录 Path。"""
51
- d = base / "sessions"
52
- d.mkdir(parents=True, exist_ok=True)
53
- a1 = [
54
- {"type": "session", "timestamp": "2026-08-26T03:00:00+08:00",
55
- "session_id": "a1", "title": "部署讨论"},
56
- {"type": "message", "timestamp": "2026-08-26T03:00:01+08:00",
57
- "message": {"role": "user", "content": [
58
- {"type": "text", "text": "你好,部署方案定了吗?"}]}},
59
- {"type": "message", "timestamp": "2026-08-26T03:00:05+08:00",
60
- "message": {"role": "assistant", "content": [
61
- {"type": "text",
62
- "text": "定了,按灰度发布执行。密钥 sk-abcdef1234567890 已就位。"}],
63
- "usage": {"cost": {"total": 0.01}, "input_tokens": 100,
64
- "output_tokens": 50}}},
65
- {"type": "message", "timestamp": "2026-08-26T03:01:00+08:00",
66
- "message": {"role": "assistant", "content": [
67
- {"type": "toolCall", "name": "read_file"},
68
- {"type": "text", "text": "我读一下配置。"}]}},
69
- {"type": "message", "timestamp": "2026-08-26T03:02:00+08:00",
70
- "message": {"role": "toolResult", "content": [
71
- {"type": "toolResult", "name": "read_file",
72
- "content": "{\"ok\": true}"}]}},
73
- "this line is not valid json",
74
- ]
75
- b2 = [
76
- {"type": "message", "timestamp": "2026-08-27T10:00:00+08:00",
77
- "message": {"role": "user", "content": [
78
- {"type": "text", "text": "CI 又失败了,看下日志。"}]}},
79
- {"type": "message", "timestamp": "2026-08-27T10:01:00+08:00",
80
- "message": {"role": "assistant", "content": [
81
- {"type": "text",
82
- "text": "好的,我去查。Bearer abcDEF123ghiJKL789 拿来用。"}]}},
83
- {"type": "message", "timestamp": "2026-08-27T10:02:00+08:00",
84
- "message": {"role": "assistant", "content": [
85
- {"type": "toolCall", "name": "run_shell"},
86
- {"type": "text", "text": "执行命令。"}]}},
87
- {"type": "message", "timestamp": "2026-08-27T10:03:00+08:00",
88
- "message": {"role": "user", "content": [
89
- {"type": "text", "text": "hello, please retry with the new endpoint"}]}},
90
- ]
91
- wjsonl(d / "a1.jsonl", a1)
92
- wjsonl(d / "b2.jsonl", b2)
93
- (d / "sessions.json").write_text(
94
- json.dumps({"微信-部署": "a1", "ci-排查": "b2"}, ensure_ascii=False),
95
- encoding="utf-8")
96
- (d / "notes.txt").write_text("not a session file", encoding="utf-8")
97
- return d
98
-
99
-
100
- def test_parse_jsonl():
101
- with tempfile.TemporaryDirectory() as td:
102
- p = Path(td) / "x.jsonl"
103
- p.write_text('{"a":1}\nnot json\n[1,2,3]\n{"b":2}\n',
104
- encoding="utf-8")
105
- records, invalid = YL.parse_jsonl(p)
106
- check("parse_jsonl 记录数", len(records) == 2, "got %d" % len(records))
107
- check("parse_jsonl 无效行计数", invalid == 2, "got %d" % invalid)
108
-
109
-
110
- def test_list_sessions():
111
- with tempfile.TemporaryDirectory() as td:
112
- d = Path(td)
113
- (d / "a.jsonl").write_text("\n", encoding="utf-8")
114
- (d / "b.jsonl").write_text("\n", encoding="utf-8")
115
- (d / "c.txt").write_text("x", encoding="utf-8")
116
- (d / "sessions.json").write_text("{}", encoding="utf-8")
117
- sess = YL.list_sessions(d)
118
- check("list_sessions 只收 jsonl", len(sess) == 2, "got %s" % sess)
119
- check("list_sessions 会话 ID = 文件名主干",
120
- {s["session"] for s in sess} == {"a", "b"})
121
-
122
-
123
- def test_load_index():
124
- with tempfile.TemporaryDirectory() as td:
125
- d = Path(td)
126
- (d / "sessions.json").write_text(
127
- json.dumps({"k1": "s1", "k2": "s2"}), encoding="utf-8")
128
- idx = YL.load_index(d)
129
- check("load_index dict 形态", idx == {"k1": "s1", "k2": "s2"}, str(idx))
130
- (d / "sessions.json").write_text(
131
- json.dumps([{"key": "k1", "sessionId": "s1"}]), encoding="utf-8")
132
- idx = YL.load_index(d)
133
- check("load_index list 形态", idx == {"k1": "s1"}, str(idx))
134
- (d / "sessions.json").write_text("not json", encoding="utf-8")
135
- check("load_index 坏文件容错", YL.load_index(d) == {})
136
-
137
-
138
- def test_rec_parsing():
139
- rec = {"type": "message", "timestamp": "2026-08-26T03:00:00Z",
140
- "message": {"role": "user", "content": "直接字符串"}}
141
- check("_rec_ts 顶层", YL._rec_ts(rec) == "2026-08-26T03:00:00Z")
142
- check("_rec_role user", YL._rec_role(rec) == "user")
143
- check("_rec_text 字符串", YL._rec_text(rec) == "直接字符串")
144
-
145
- rec2 = {"type": "message", "message": {"role": "toolResult", "content": [
146
- {"type": "text", "text": "A"}, {"type": "thinking", "text": "隐藏"},
147
- {"type": "toolCall", "name": "run_shell"}]}}
148
- check("_rec_role toolResult 归一为 tool", YL._rec_role(rec2) == "tool",
149
- YL._rec_role(rec2))
150
- check("_rec_text 只取 text", YL._rec_text(rec2) == "A",
151
- repr(YL._rec_text(rec2)))
152
- check("_rec_tool_names", YL._rec_tool_names(rec2) == ["run_shell"])
153
-
154
- rec3 = {"type": "message", "message": {"role": "assistant", "content": [
155
- {"type": "text", "text": "x"}]},
156
- "usage": {"cost": {"total": 0.5}, "input_tokens": 10,
157
- "output_tokens": 20}}
158
- check("_rec_cost", YL._rec_cost(rec3) == 0.5)
159
- check("_rec_tokens", YL._rec_tokens(rec3) == (10, 20))
160
- check("_is_message 排除 session 元数据",
161
- YL._is_message({"type": "session", "role": "session"}) is False)
162
-
163
-
164
- def test_redact():
165
- check("redact sk-", "sk-" not in YL.redact("密钥 sk-abcdef1234567890 已就位"))
166
- check("redact ghp_", "ghp_" not in YL.redact("token ghp_ABCDEFGHIJKLMNOPQRST"))
167
- check("redact AKIA", "AKIA" not in YL.redact("AKIA1234567890ABCDEF"))
168
- check("redact JWT",
169
- "eyJ" not in YL.redact("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"))
170
- check("redact Bearer",
171
- YL.redact("Bearer abcDEF123ghiJKL789") == "Bearer ***")
172
- check("redact URL 口令",
173
- YL.redact("https://user:pass@example.com/path")
174
- == "https://user:***@example.com/path")
175
- check("redact 赋值",
176
- "token=***" in YL.redact("token=sk-abcdef1234567890x"))
177
- check("redact 长串",
178
- "***" in YL.redact("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"))
179
- check("redact URL 路径保留",
180
- "https://example.com/api/v1/items" in YL.redact("看 https://example.com/api/v1/items 这里"))
181
- check("redact 普通中文不动",
182
- YL.redact("你好,今天天气不错。") == "你好,今天天气不错。")
183
- check("redact PEM",
184
- "PRIVATE KEY REDACTED" in YL.redact(
185
- "-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----"))
186
-
187
-
188
- def test_scan(fx):
189
- res = YL.scan_sessions(str(fx))
190
- check("scan 会话数 2", res["total_sessions"] == 2,
191
- str(res["rows"]))
192
- check("scan 消息合计 8", res["total_messages"] == 8,
193
- str(res["total_messages"]))
194
- check("scan 无效行 1", res["total_invalid"] == 1)
195
- by = {r["session"]: r for r in res["rows"]}
196
- check("scan a1 消息 4", by["a1"]["messages"] == 4)
197
- check("scan b2 日期", by["b2"]["date"] == "2026-08-27")
198
- check("scan 别名映射", by["a1"]["alias"] == "微信-部署")
199
-
200
-
201
- def test_search(fx):
202
- r = YL.search_sessions(str(fx), "部署")
203
- check("search 关键词命中", len(r["matches"]) == 1
204
- and r["matches"][0]["session"] == "a1", str(r))
205
- r = YL.search_sessions(str(fx), "HELLO")
206
- check("search 不区分大小写", len(r["matches"]) == 1
207
- and r["matches"][0]["session"] == "b2", str(r))
208
- r = YL.search_sessions(str(fx), r"CI \w+", regex=True)
209
- check("search 正则命中", len(r["matches"]) == 1
210
- and r["matches"][0]["session"] == "b2", str(r))
211
- r = YL.search_sessions(str(fx), "看下", date="2026-08-27")
212
- check("search 日期过滤", len(r["matches"]) == 1
213
- and r["matches"][0]["session"] == "b2", str(r))
214
- r = YL.search_sessions(str(fx), "灰度", sessions=["微信-部署"])
215
- check("search 会话别名过滤", len(r["matches"]) == 1
216
- and r["matches"][0]["session"] == "a1", str(r))
217
- r = YL.search_sessions(str(fx), "灰度", sessions=["b2"])
218
- check("search 会话 ID 过滤无命中", r["matches"] == [], str(r))
219
- r = YL.search_sessions(str(fx), "了", role="assistant")
220
- check("search 角色过滤", r["matches"] and all(
221
- m["role"] == "assistant" for m in r["matches"]), str(r))
222
- r = YL.search_sessions(str(fx), "了", limit=1)
223
- check("search limit 截断", r["truncated"] is True and len(r["matches"]) == 1
224
- and r["sessions_hit"] == 1, str(r))
225
- r = YL.search_sessions(str(fx), "绝不存在的词xyz")
226
- check("search 无命中空列表", r["matches"] == [] and r["sessions_hit"] == 0)
227
- r = YL.search_sessions(str(fx), "sk-abcdef1234567890")
228
- check("search 命中脱敏打码", r["matches"] and
229
- "sk-" not in r["matches"][0]["text"] and "***" in r["matches"][0]["text"],
230
- str(r["matches"]))
231
-
232
-
233
- def test_extract(fx):
234
- r = YL.extract_session(str(fx), "a1")
235
- check("extract 消息 4", len(r["messages"]) == 4, str(len(r["messages"])))
236
- check("extract 首条", r["messages"][0]["role"] == "user"
237
- and "部署方案" in r["messages"][0]["text"])
238
- check("extract 脱敏", "sk-" not in r["messages"][1]["text"]
239
- and "***" in r["messages"][1]["text"], repr(r["messages"][1]["text"]))
240
- r2 = YL.extract_session(str(fx), "a1", role="assistant")
241
- check("extract 角色过滤", len(r2["messages"]) == 2
242
- and all(m["role"] == "assistant" for m in r2["messages"]))
243
- r3 = YL.extract_session(str(fx), "ci-排查")
244
- check("extract 别名解析", r3["session"] == "b2", str(r3["session"]))
245
- r4 = YL.extract_session(str(fx), "b2", with_tools=True)
246
- tools = [t for m in r4["messages"] for t in m["tools"]]
247
- check("extract 工具标注", "run_shell" in tools, str(tools))
248
- try:
249
- YL.extract_session(str(fx), "nope")
250
- check("extract 未知会话抛错", False)
251
- except SystemExit:
252
- check("extract 未知会话抛错", True)
253
-
254
-
255
- def test_stats(fx):
256
- r = YL.session_stats(str(fx))
257
- check("stats 会话 2", r["sessions"] == 2)
258
- check("stats 消息 8", r["messages"] == 8)
259
- check("stats 角色分布", r["roles"] == {"user": 3, "assistant": 4, "tool": 1},
260
- str(r["roles"]))
261
- check("stats 成本", abs(r["cost"] - 0.01) < 1e-9, str(r["cost"]))
262
- check("stats token", r["tokens_in"] == 100 and r["tokens_out"] == 50)
263
- check("stats 首末时间", r["first"].startswith("2026-08-26")
264
- and r["last"].startswith("2026-08-27"))
265
- r2 = YL.session_stats(str(fx), daily=True)
266
- check("stats 每日两天", set(r2["days"].keys()) == {"2026-08-26", "2026-08-27"},
267
- str(r2["days"].keys()))
268
- check("stats 每日成本", abs(r2["days"]["2026-08-26"]["cost"] - 0.01) < 1e-9)
269
- r3 = YL.session_stats(str(fx), session_id="微信-部署")
270
- check("stats 单会话", r3["sessions"] == 1 and r3["messages"] == 4,
271
- str((r3["sessions"], r3["messages"])))
272
-
273
-
274
- def test_tools(fx):
275
- items = YL.tool_breakdown(str(fx))
276
- by = dict(items)
277
- check("tools 排行 read_file 2", by.get("read_file") == 2, str(items))
278
- check("tools 排行 run_shell 1", by.get("run_shell") == 1, str(items))
279
- items2 = YL.tool_breakdown(str(fx), session_id="a1")
280
- by2 = dict(items2)
281
- check("tools 单会话", by2.get("read_file") == 2 and "run_shell" not in by2,
282
- str(items2))
283
-
284
-
285
- def _run(args, inp=None, env=None, cwd=None):
286
- e = dict(os.environ)
287
- if env:
288
- e.update(env)
289
- return subprocess.run(
290
- [sys.executable, str(_HERE / "yotta_logs.py")] + args,
291
- input=inp, capture_output=True, text=True, encoding="utf-8",
292
- errors="replace", env=e, cwd=cwd)
293
-
294
-
295
- def test_cli(fx):
296
- r = _run(["version"])
297
- check("CLI version", r.returncode == 0 and YL.VERSION in r.stdout,
298
- "rc=%d" % r.returncode)
299
-
300
- r = _run(["scan", "--dir", str(fx), "--json"])
301
- try:
302
- obj = json.loads(r.stdout)
303
- check("CLI scan --json", r.returncode == 0
304
- and obj["total_sessions"] == 2, r.stdout[:120])
305
- except Exception as e: # noqa: BLE001
306
- check("CLI scan --json", False, str(e))
307
-
308
- r = _run(["search", "部署", "--dir", str(fx), "--json"])
309
- try:
310
- obj = json.loads(r.stdout)
311
- check("CLI search --json", r.returncode == 0
312
- and obj["total_matches"] == 1 and len(obj["matches"]) == 1,
313
- r.stdout[:120])
314
- except Exception as e: # noqa: BLE001
315
- check("CLI search --json", False, str(e))
316
-
317
- r = _run(["search", "绝不存在的词xyz", "--dir", str(fx)])
318
- check("CLI search 无命中退出码 1", r.returncode == 1,
319
- "rc=%d" % r.returncode)
320
-
321
- r = _run(["session", "a1", "--dir", str(fx)])
322
- check("CLI session 文本输出", r.returncode == 0 and "部署方案" in r.stdout,
323
- "rc=%d" % r.returncode)
324
-
325
- r = _run(["stats", "--dir", str(fx), "--daily"])
326
- check("CLI stats 每日", r.returncode == 0 and "每日汇总" in r.stdout,
327
- "rc=%d" % r.returncode)
328
-
329
- r = _run(["tools", "--dir", str(fx), "--json"])
330
- try:
331
- obj = json.loads(r.stdout)
332
- by = {t["name"]: t["count"] for t in obj["tools"]}
333
- check("CLI tools --json", r.returncode == 0 and by.get("read_file") == 2,
334
- r.stdout[:120])
335
- except Exception as e: # noqa: BLE001
336
- check("CLI tools --json", False, str(e))
337
-
338
- r = _run(["scan", "--dir", str(Path(fx).parent / "no-such-dir")])
339
- check("CLI 目录不存在退出码 4", r.returncode == 4, "rc=%d" % r.returncode)
340
-
341
- r = _run(["badcmd"])
342
- check("CLI 未知子命令退出码 4", r.returncode == 4, "rc=%d" % r.returncode)
343
-
344
- r = _run(["search", "Bearer", "--dir", str(fx), "--no-redact"])
345
- check("CLI --no-redact 原文保留", r.returncode == 0
346
- and "abcDEF123ghiJKL789" in r.stdout, repr(r.stdout[:120]))
347
-
348
- r = _run(["scan"], env={"YOTTA_LOGS_DIR": str(fx)})
349
- check("CLI YOTTA_LOGS_DIR 环境变量", r.returncode == 0
350
- and "会话 2 个" in r.stdout, "rc=%d" % r.returncode)
351
-
352
-
353
- def test_gbk_console(fx):
354
- env = dict(os.environ)
355
- env["PYTHONIOENCODING"] = "gbk"
356
- r = subprocess.run(
357
- [sys.executable, str(_HERE / "yotta_logs.py"),
358
- "search", "部署", "--dir", str(fx)],
359
- capture_output=True, text=True, encoding="gbk", errors="replace",
360
- env=env)
361
- check("GBK 控制台中文输出不炸", r.returncode == 0,
362
- "rc=%d err=%r" % (r.returncode, r.stderr[:100]))
363
-
364
-
365
- def test_readonly(fx):
366
- before = sorted((p.name, p.stat().st_size) for p in fx.iterdir())
367
- YL.scan_sessions(str(fx))
368
- YL.search_sessions(str(fx), "部署")
369
- YL.extract_session(str(fx), "a1")
370
- YL.session_stats(str(fx), daily=True)
371
- YL.tool_breakdown(str(fx))
372
- after = sorted((p.name, p.stat().st_size) for p in fx.iterdir())
373
- check("只读保证:目录内容不变", before == after,
374
- "before=%s after=%s" % (before, after))
375
-
376
-
377
- # ── v0.2.0 通用化测试 ────────────────────────────────────────────────────
378
-
379
- def build_json_fixture(base):
380
- """单文件 JSON 会话:一个数组文件 + 一个 dict-of-lists 文件。"""
381
- d = base / "jsons"
382
- d.mkdir(parents=True, exist_ok=True)
383
- arr = [
384
- {"role": "user", "content": "你好,单文件 JSON 测试。", "ts": "2026-08-26T09:00:00+08:00"},
385
- {"role": "assistant", "content": "收到。", "ts": "2026-08-26T09:01:00+08:00"},
386
- ]
387
- (d / "convo.json").write_text(json.dumps(arr, ensure_ascii=False),
388
- encoding="utf-8")
389
- multi = {
390
- "s1": [{"role": "user", "content": "s1 的第一个问题"},
391
- {"role": "assistant", "content": "s1 的回复"}],
392
- "s2": [{"role": "user", "content": "s2 的问题"}],
393
- }
394
- (d / "multi.json").write_text(json.dumps(multi, ensure_ascii=False),
395
- encoding="utf-8")
396
- return d
397
-
398
-
399
- def build_sqlite_opencode(p):
400
- import sqlite3 as _sq
401
- con = _sq.connect(str(p))
402
- con.execute("CREATE TABLE session (id TEXT, title TEXT, time_created INTEGER,"
403
- " cost REAL, tokens_input INTEGER, tokens_output INTEGER)")
404
- con.execute("CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT)")
405
- con.execute("CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT,"
406
- " time_created INTEGER, data TEXT)")
407
- con.execute("INSERT INTO session VALUES ('ses_a','部署讨论',1785834902916,0.05,100,50)")
408
- con.execute("INSERT INTO session VALUES ('ses_b','CI 排查',1785835000000,0.0,0,0)")
409
- con.execute("INSERT INTO message VALUES ('msg_1','ses_a',1785834903000,'{\"role\":\"user\"}')")
410
- con.execute("INSERT INTO part VALUES ('prt_1','msg_1','ses_a',1785834903001,"
411
- "'{\"type\":\"text\",\"text\":\"你好,部署方案定了吗?\"}')")
412
- con.execute("INSERT INTO message VALUES ('msg_2','ses_a',1785834904000,'{\"role\":\"assistant\"}')")
413
- con.execute("INSERT INTO part VALUES ('prt_2','msg_2','ses_a',1785834904001,"
414
- "'{\"type\":\"text\",\"text\":\"定了,按灰度发布执行。\"}')")
415
- con.execute("INSERT INTO part VALUES ('prt_3','msg_2','ses_a',1785834904002,"
416
- "'{\"type\":\"tool\",\"tool\":\"read_file\"}')")
417
- con.execute("INSERT INTO part VALUES ('prt_4','msg_2','ses_a',1785834904003,"
418
- "'{\"type\":\"reasoning\",\"text\":\"隐藏推理不输出\"}')")
419
- con.execute("INSERT INTO message VALUES ('msg_3','ses_b',1785835001000,'{\"role\":\"user\"}')")
420
- con.execute("INSERT INTO part VALUES ('prt_5','msg_3','ses_b',1785835001001,"
421
- "'{\"type\":\"text\",\"text\":\"CI 又失败了,看下日志。\"}')")
422
- con.commit()
423
- con.close()
424
-
425
-
426
- def build_sqlite_generic(p):
427
- import sqlite3 as _sq
428
- con = _sq.connect(str(p))
429
- con.execute("CREATE TABLE messages (id INTEGER, session_id TEXT, role TEXT,"
430
- " content TEXT, created_at TEXT)")
431
- con.execute("INSERT INTO messages VALUES (1,'g1','user','泛型表问题甲','2026-08-26T03:00:00+08:00')")
432
- con.execute("INSERT INTO messages VALUES (2,'g1','assistant','泛型表回复甲','2026-08-26T03:01:00+08:00')")
433
- con.execute("INSERT INTO messages VALUES (3,'g2','user','乙的问题','2026-08-27T10:00:00+08:00')")
434
- con.commit()
435
- con.close()
436
-
437
-
438
- def build_md_fixture(base):
439
- facts = base / ".yottamemory" / "facts"
440
- facts.mkdir(parents=True, exist_ok=True)
441
- (facts / "2026-08-25-0002.md").write_text(
442
- "---\ntype: FACT\nsubject: 共享记忆引擎接入指南\n"
443
- "statement: 本机运行 yotta-memory 记忆引擎,接入方式见正文。\n"
444
- "confidence: 1\ncreated: 2026-08-25\ntags: [memory, guide]\n---\n"
445
- "正文补充。\n", encoding="utf-8")
446
- notes = base / ".CodexData" / "memories"
447
- notes.mkdir(parents=True, exist_ok=True)
448
- (notes / "note.md").write_text(
449
- "# 推送闸门红线\n\n规则:测试通过才能推。\n", encoding="utf-8")
450
- return facts, notes
451
-
452
-
453
- def test_norm_time():
454
- check("毫秒转 ISO", YL._norm_time(1785834903000)
455
- .startswith("20") and "T" in YL._norm_time(1785834903000),
456
- YL._norm_time(1785834903000))
457
- check("秒时间戳", YL._norm_time(1785834903).startswith("20"),
458
- YL._norm_time(1785834903))
459
- check("ISO 原样", YL._norm_time("2026-08-26T03:00:00+08:00")
460
- == "2026-08-26T03:00:00+08:00")
461
- check("日期原样", YL._norm_time("2026-08-25") == "2026-08-25")
462
- check("Z 归一", YL._norm_time("2026-08-26T03:00:00Z")
463
- == "2026-08-26T03:00:00+00:00")
464
-
465
-
466
- def test_json_reader():
467
- with tempfile.TemporaryDirectory() as td:
468
- d = build_json_fixture(Path(td))
469
- src = YL.sniff_source(str(d))
470
- check("JSON 目录嗅探", src["format"] == "json" and src["kind"] == "session",
471
- str(src))
472
- reader = YL.JSONReader()
473
- rows, tm, inv = reader.iter_sessions(src)
474
- check("JSON scan 3 会话", len(rows) == 3 and tm == 5, str(rows))
475
- r = YL.search_all([src], "单文件")
476
- check("JSON search 命中", len(r["matches"]) == 1
477
- and r["matches"][0]["session"] == "convo", str(r))
478
- r2 = YL.search_all([src], "s2")
479
- check("JSON dict 会话检索", len(r2["matches"]) == 1
480
- and r2["matches"][0]["session"] == "s2", str(r2))
481
- ex = YL.extract_all([src], "convo")
482
- check("JSON extract", len(ex["messages"]) == 2
483
- and ex["messages"][0]["role"] == "user", str(ex))
484
- st = YL.stats_all([src])
485
- check("JSON stats 消息 5", st["messages"] == 5, str(st["messages"]))
486
- check("JSON stats 角色", st["roles"].get("user") == 3, str(st["roles"]))
487
-
488
-
489
- def test_sqlite_opencode_reader():
490
- with tempfile.TemporaryDirectory() as td:
491
- p = Path(td) / "opencode.db"
492
- build_sqlite_opencode(p)
493
- src = YL.sniff_source(str(p))
494
- check("SQLite 文件嗅探", src["format"] == "sqlite", str(src))
495
- reader = YL.SQLiteReader()
496
- rows, tm, inv = reader.iter_sessions(src)
497
- check("opencode scan 2 会话", len(rows) == 2, str(rows))
498
- by = {r["session"]: r for r in rows}
499
- check("opencode ses_a 消息 2", by["ses_a"]["messages"] == 2,
500
- str(by["ses_a"]))
501
- check("opencode 毫秒日期", by["ses_a"]["date"].startswith("20"),
502
- by["ses_a"]["date"])
503
- r = YL.search_all([src], "灰度")
504
- check("opencode search 命中", len(r["matches"]) == 1
505
- and r["matches"][0]["session"] == "ses_a", str(r))
506
- r2 = YL.search_all([src], "隐藏推理")
507
- check("opencode reasoning 不进文本", r2["matches"] == [], str(r2))
508
- ex = YL.extract_all([src], "ses_a")
509
- check("opencode extract 消息 2", len(ex["messages"]) == 2, str(ex))
510
- msg2 = ex["messages"][1]
511
- check("opencode 工具标注", msg2["tools"] == ["read_file"], str(msg2))
512
- check("opencode role assistant", msg2["role"] == "assistant", str(msg2))
513
- st = YL.stats_all([src])
514
- check("opencode stats 消息 3", st["messages"] == 3, str(st["messages"]))
515
- check("opencode stats 角色", st["roles"] == {"user": 2, "assistant": 1},
516
- str(st["roles"]))
517
- items = YL.tools_all([src])
518
- check("opencode tools read_file 1", dict(items).get("read_file") == 1,
519
- str(items))
520
-
521
-
522
- def test_sqlite_generic_reader():
523
- with tempfile.TemporaryDirectory() as td:
524
- p = Path(td) / "app.db"
525
- build_sqlite_generic(p)
526
- src = YL._mk_source("generic-test", "session", "sqlite", p,
527
- extra={"table": "messages"})
528
- reader = YL.SQLiteReader()
529
- rows, tm, inv = reader.iter_sessions(src)
530
- by = {r["session"]: r for r in rows}
531
- check("generic scan 2 会话", len(rows) == 2 and tm == 3, str(rows))
532
- check("generic g1 消息 2", by["g1"]["messages"] == 2, str(by["g1"]))
533
- r = YL.search_all([src], "甲")
534
- check("generic search 命中 2", len(r["matches"]) == 2, str(r))
535
- ex = YL.extract_all([src], "g1")
536
- check("generic extract 2 条", len(ex["messages"]) == 2, str(ex))
537
- st = YL.stats_all([src])
538
- check("generic stats 角色", st["roles"] == {"user": 2, "assistant": 1},
539
- str(st["roles"]))
540
-
541
-
542
- def test_markdown_reader():
543
- with tempfile.TemporaryDirectory() as td:
544
- facts, notes = build_md_fixture(Path(td))
545
- fsrc = YL._mk_source("yottamemory-facts", "memory", "markdown", facts)
546
- nsrc = YL._mk_source("codex-notes", "note", "markdown", notes)
547
- fr = list(YL.MarkdownReader().iter_records(fsrc))
548
- check("md memory 1 条", len(fr) == 1, str(fr))
549
- rec = fr[0]
550
- check("md role FACT", rec["role"] == "FACT", str(rec["role"]))
551
- check("md title subject", rec["meta"].get("title") == "共享记忆引擎接入指南",
552
- str(rec["meta"]))
553
- check("md text statement", "yotta-memory" in rec["text"], rec["text"][:50])
554
- check("md created 时间", rec["time"] == "2026-08-25", rec["time"])
555
- nr = list(YL.MarkdownReader().iter_records(nsrc))
556
- check("md note 1 条", len(nr) == 1 and nr[0]["kind"] == "note", str(nr))
557
- check("md note 标题", nr[0]["meta"].get("title") == "推送闸门红线",
558
- str(nr[0]["meta"]))
559
- check("md note 正文", "测试通过" in nr[0]["text"], nr[0]["text"][:40])
560
- rows, tm, inv = YL.MarkdownReader().iter_sessions(fsrc)
561
- check("md memory scan", len(rows) == 1 and tm == 1, str(rows))
562
- # 结构化 md 文件单独 --dir → kind memory
563
- fp = facts / "2026-08-25-0002.md"
564
- s = YL.sniff_source(str(fp))
565
- check("md 文件嗅探 memory", s["format"] == "markdown" and s["kind"] == "memory",
566
- str(s))
567
- np = notes / "note.md"
568
- s2 = YL.sniff_source(str(np))
569
- check("md 文件嗅探 note", s2["kind"] == "note", str(s2))
570
-
571
-
572
- def test_binary_reader():
573
- with tempfile.TemporaryDirectory() as td:
574
- p = Path(td) / "conv.pbtxt"
575
- p.write_bytes(b"\x00\x01Conversation WindSurf Title\x00\x02more")
576
- src = YL._mk_source("windsurf-conv", "log", "binary", p, default_on=False)
577
- recs = list(YL.BinaryReader().iter_records(src))
578
- check("binary 1 条且不崩", len(recs) == 1, str(recs))
579
- check("binary title 提取", "WindSurf" in recs[0]["text"], recs[0]["text"])
580
- check("binary kind log", recs[0]["kind"] == "log", str(recs[0]["kind"]))
581
- rows, tm, inv = YL.BinaryReader().iter_sessions(src)
582
- check("binary scan", len(rows) == 1 and tm == 1, str(rows))
583
-
584
-
585
- def test_sniff_and_discover():
586
- with tempfile.TemporaryDirectory() as td:
587
- base = Path(td)
588
- # discover:JSONL + SQLite(opencode) + 记忆 md + 自由笔记
589
- jd = base / ".codex" / "sessions"
590
- jd.mkdir(parents=True, exist_ok=True)
591
- (jd / "a.jsonl").write_text('{"type":"message","message":{"role":"user","content":"hi"}}\n',
592
- encoding="utf-8")
593
- db = base / ".local" / "share" / "opencode" / "opencode.db"
594
- db.parent.mkdir(parents=True, exist_ok=True)
595
- build_sqlite_opencode(db)
596
- facts, notes = build_md_fixture(base)
597
- jsrcs = YL.JSONLReader.discover(base)
598
- check("discover JSONL 命中 codex", any(s["name"] == "codex-sessions"
599
- for s in jsrcs), str(jsrcs))
600
- ssrcs = YL.SQLiteReader.discover(base)
601
- check("discover SQLite 命中 opencode", any(s["name"] == "opencode-db"
602
- for s in ssrcs), str(ssrcs))
603
- msrcs = YL.MarkdownReader.discover(base)
604
- names = {s["name"] for s in msrcs}
605
- check("discover md 命中 yottamemory-facts", "yottamemory-facts" in names,
606
- str(msrcs))
607
- check("discover md 命中 codex-notes", "codex-notes" in names, str(msrcs))
608
- for s in msrcs:
609
- if s["name"] == "codex-notes":
610
- check("自由笔记默认关", s["default_on"] is False, str(s))
611
- if s["name"] == "yottamemory-facts":
612
- check("结构化记忆默认开", s["default_on"] is True, str(s))
613
- # 配置兜底
614
- cfg = {"sources": [{"name": "myapp", "path": str(base / "app.db"),
615
- "format": "sqlite", "kind": "session",
616
- "table": "messages", "col_text": "content"}]}
617
- srcs = YL.discover_sources(cfg)
618
- check("配置源登记", any(s["name"] == "myapp" for s in srcs), str(srcs))
619
-
620
-
621
- def test_filters_and_scope():
622
- srcs = [
623
- YL._mk_source("sess", "session", "jsonl", "/x", True),
624
- YL._mk_source("mem", "memory", "markdown", "/y", True),
625
- YL._mk_source("note", "note", "markdown", "/z", False),
626
- ]
627
-
628
- class A:
629
- source = None
630
- kind = None
631
- format = None
632
-
633
- a = A()
634
- out = YL.filter_sources(srcs, a)
635
- check("默认范围排除 note", [s["name"] for s in out] == ["sess", "mem"],
636
- str(out))
637
- a.kind = "note"
638
- out = YL.filter_sources(srcs, a)
639
- check("--kind note 显式开", [s["name"] for s in out] == ["note"], str(out))
640
- a.kind = None
641
- a.format = "markdown"
642
- out = YL.filter_sources(srcs, a)
643
- check("--format markdown 显式含 note", {s["name"] for s in out} == {"mem", "note"},
644
- str(out))
645
- a.format = None
646
- a.source = ["sess"]
647
- out = YL.filter_sources(srcs, a)
648
- check("--source 过滤", [s["name"] for s in out] == ["sess"], str(out))
649
-
650
-
651
- def test_cross_source_scope():
652
- with tempfile.TemporaryDirectory() as td:
653
- base = Path(td)
654
- # 用自己的 fixture:jsonl 会话 + 记忆 md + 自由笔记 都含关键词
655
- jd = base / "sessions"
656
- jd.mkdir(parents=True, exist_ok=True)
657
- (jd / "s1.jsonl").write_text(
658
- '{"type":"message","timestamp":"2026-08-26T03:00:00+08:00","message":{"role":"user","content":"部署方案定了吗"}}\n',
659
- encoding="utf-8")
660
- facts, notes = build_md_fixture(base)
661
- (facts / "m1.md").write_text(
662
- "---\ntype: FACT\nsubject: 部署\nstatement: 部署方案已拍板。\ncreated: 2026-08-25\n---\n",
663
- encoding="utf-8")
664
- (notes / "n1.md").write_text("# 部署笔记\n\n部署草稿。\n", encoding="utf-8")
665
- jsrc = YL.sniff_source(str(jd))
666
- fsrc = YL._mk_source("facts", "memory", "markdown", facts)
667
- nsrc = YL._mk_source("notes", "note", "markdown", notes, default_on=False)
668
- srcs = [jsrc, fsrc, nsrc]
669
- # 默认:会话 + 记忆,排除自由笔记
670
- default = YL.filter_sources(srcs, type("A", (), {"source": None,
671
- "kind": None,
672
- "format": None})())
673
- res = YL.search_all(default, "部署")
674
- names = {(m["source"], m["session"]) for m in res["matches"]}
675
- check("默认范围不含自由笔记", ("notes", "n1") not in names, str(names))
676
- check("默认范围含会话与记忆",
677
- ("sessions", "s1") in names and ("facts", "m1") in names, str(names))
678
- # 显式开 note
679
- res2 = YL.search_all([nsrc], "部署")
680
- check("显式开 note 可检索", ("notes", "n1") in
681
- {(m["source"], m["session"]) for m in res2["matches"]}, str(res2))
682
-
683
-
684
- def test_cli_v020(fx):
685
- # --kind / --format / --source 参数存在且 --dir 行为不变
686
- r = _run(["search", "部署", "--dir", str(fx), "--format", "jsonl"])
687
- check("CLI --format jsonl", r.returncode == 0 and "部署方案" in r.stdout,
688
- "rc=%d" % r.returncode)
689
- r = _run(["search", "部署", "--dir", str(fx), "--kind", "session"])
690
- check("CLI --kind session", r.returncode == 0, "rc=%d" % r.returncode)
691
- r = _run(["scan", "--dir", str(fx), "--source", "nope"])
692
- check("CLI --source 无命中退出码 1", r.returncode == 1,
693
- "rc=%d" % r.returncode)
694
- # locate --json 结构
695
- r = _run(["locate", "--json"])
696
- try:
697
- obj = json.loads(r.stdout)
698
- check("CLI locate --json", r.returncode == 0
699
- and "sources" in obj and "default_scope" in obj, r.stdout[:120])
700
- except Exception as e: # noqa: BLE001
701
- check("CLI locate --json", False, str(e))
702
- # 单文件 md --dir(自由笔记显式可查)
703
- with tempfile.TemporaryDirectory() as td:
704
- np = Path(td) / "note.md"
705
- np.write_text("# 标题甲\n\n正文含关键词乙。\n", encoding="utf-8")
706
- r = _run(["search", "关键词乙", "--dir", str(np)])
707
- check("CLI 单 md 文件检索", r.returncode == 0 and "关键词乙" in r.stdout,
708
- "rc=%d out=%s" % (r.returncode, r.stdout[:80]))
709
- r = _run(["scan", "--dir", str(np), "--json"])
710
- try:
711
- obj = json.loads(r.stdout)
712
- check("CLI 单 md scan", r.returncode == 0
713
- and obj["total_sessions"] == 1, r.stdout[:120])
714
- except Exception as e: # noqa: BLE001
715
- check("CLI 单 md scan", False, str(e))
716
-
717
-
718
-
719
- def main():
720
- print("元史(yotta-logs)测试开始…")
721
- with tempfile.TemporaryDirectory() as td:
722
- fx = build_fixture(Path(td))
723
- test_parse_jsonl()
724
- test_list_sessions()
725
- test_load_index()
726
- test_rec_parsing()
727
- test_redact()
728
- test_scan(fx)
729
- test_search(fx)
730
- test_extract(fx)
731
- test_stats(fx)
732
- test_tools(fx)
733
- test_cli(fx)
734
- test_gbk_console(fx)
735
- test_readonly(fx)
736
- test_norm_time()
737
- test_json_reader()
738
- test_sqlite_opencode_reader()
739
- test_sqlite_generic_reader()
740
- test_markdown_reader()
741
- test_binary_reader()
742
- test_sniff_and_discover()
743
- test_filters_and_scope()
744
- test_cross_source_scope()
745
- test_cli_v020(fx)
746
- print("")
747
- print("通过 %d 项,失败 %d 项" % (PASS, FAIL))
748
- if FAILED:
749
- print("失败清单:")
750
- for name in FAILED:
751
- print(" - " + name)
752
- sys.exit(1 if FAIL else 0)
753
-
754
-
755
-
756
- if __name__ == "__main__":
757
- main()