@yottameta/yotta-logs 0.3.0 → 0.3.2
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 +15 -0
- package/README.md +4 -3
- package/README.zh-CN.md +4 -3
- package/SKILL.md +1 -1
- package/install.sh +24 -5
- package/package.json +2 -1
- package/references/agent-formats.md +6 -6
- package/references/cli.md +1 -1
- package/scripts/yotta_logs.py +32 -11
- package/scripts/test_yotta_logs.py +0 -757
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## v0.3.2 (2026-09-25)
|
|
2
|
+
|
|
3
|
+
安全修复:URL 内嵌凭据脱敏覆盖所有协议 + 查询串凭据。
|
|
4
|
+
|
|
5
|
+
- 背景:默认脱敏只处理 `http(s)://user:pass@`,`postgres://user:secret@…` 这类连接串与 `?token=…` 查询串会原样保留(ClawHub T09 Medium)。
|
|
6
|
+
- 修复:用户信息脱敏扩展到任意 `scheme://user:pass@`;新增查询串凭据遮蔽(`token / api_key / access_token / password / secret / sig` 等参数值替换为 `***`),非凭据参数保持不变。
|
|
7
|
+
- 安装器加固 + README 安装命令补锁定版本写法。
|
|
8
|
+
- 回归:新增 3 项脱敏用例,测试 146/146 通过。
|
|
9
|
+
|
|
10
|
+
- 修复本机专属路径硬编码:移除本机自定义数据目录候选;opencode 只认 `XDG_DATA_HOME` / `OPENCODE_DATA` / 官方默认路径,Codex notes 只认 `$CODEX_HOME/memories` 或 `~/.codex/memories`。
|
|
11
|
+
- 配置路径改为平台无关:`$YOTTA_LOGS_CONFIG` > Windows `%APPDATA%` > Unix `$XDG_CONFIG_HOME` / `~/.config`。
|
|
12
|
+
- 新增便携覆盖回归:`YOTTA_MEMORY_HOME`、`CODEX_HOME`、`YOTTA_LOGS_CONFIG`;并加本机自定义目录反向断言。
|
|
13
|
+
|
|
14
|
+
## v0.3.1 (2026-09-13)
|
|
15
|
+
|
|
1
16
|
## v0.3.0 (2026-09-08)
|
|
2
17
|
|
|
3
18
|
**评测驱动完善**:新增 FAQ,安装器错误处理与测试补齐。
|
package/README.md
CHANGED
|
@@ -111,8 +111,8 @@ Pick any of the four methods below; the order is the recommended priority. Skill
|
|
|
111
111
|
|
|
112
112
|
```text
|
|
113
113
|
# Optional China mirror: npm config set registry https://registry.npmmirror.com
|
|
114
|
-
npx -y @yottameta/yotta-logs --agent <agent-name> # install to the agent's default user-level skills dir
|
|
115
|
-
npx -y @yottameta/yotta-logs --dir <your-skills-dir> # point to the skills dir itself (e.g. ~/.codex/skills)
|
|
114
|
+
npx -y @yottameta/yotta-logs@0.3.2 --agent <agent-name> # install to the agent's default user-level skills dir
|
|
115
|
+
npx -y @yottameta/yotta-logs@0.3.2 --dir <your-skills-dir> # point to the skills dir itself (e.g. ~/.codex/skills)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
- `--agent <name>` installs to that agent's default user-level directory; `--list` shows each agent's default directory.
|
|
@@ -162,9 +162,10 @@ 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
|
-
- 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.
|
|
168
|
+
- v0.2.2 (2026-08-29): Install docs alignment — unified four install methods (npx -y @yottameta/yotta-logs@0.3.2 --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.
|
|
168
169
|
|
|
169
170
|
- v0.2.1 (2026-08-27): Bilingual documentation — English README as the GitHub / npm / ClawHub homepage, full Chinese doc moved to README.zh-CN.md, English npm description.
|
|
170
171
|
- v0.2.0 (2026-08-27): Multi-format generalization — JSONL / single-file JSON / SQLite (opencode etc.) / Markdown (memory + free notes) / binary; unified Record + field-alias normalization + config fallback; discover; new --source / --kind / --format filters and default search scope (session + structured memory on, free notes / binary logs off). See CHANGELOG.md.
|
package/README.zh-CN.md
CHANGED
|
@@ -111,8 +111,8 @@ python3 scripts/yotta_logs.py search "部署方案" --dir /path/to/sessions --js
|
|
|
111
111
|
|
|
112
112
|
```text
|
|
113
113
|
# 可选国内加速:npm config set registry https://registry.npmmirror.com
|
|
114
|
-
npx -y @yottameta/yotta-logs --agent <智能体名称> # 装到指定智能体默认用户级技能目录
|
|
115
|
-
npx -y @yottameta/yotta-logs --dir <智能体的技能目录> # 指到技能目录本身(如 ~/.codex/skills)
|
|
114
|
+
npx -y @yottameta/yotta-logs@0.3.2 --agent <智能体名称> # 装到指定智能体默认用户级技能目录
|
|
115
|
+
npx -y @yottameta/yotta-logs@0.3.2 --dir <智能体的技能目录> # 指到技能目录本身(如 ~/.codex/skills)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
- `--agent <name>` 自动装到该智能体默认用户级目录;`--list` 可查看各智能体默认目录。
|
|
@@ -162,9 +162,10 @@ 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
|
-
- 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 安装节同步。无功能变更。
|
|
168
|
+
- v0.2.2(2026-08-29):安装方式统一为四方式(对齐发布规范 §3.3.1)——方式一 npx -y @yottameta/yotta-logs@0.3.2 --agent / --dir(推荐,走 npm 源);方式二 git clone;方式三 GitHub Download ZIP;方式四 bash install.sh --agent/--dir/--list。移除旧式 GitHub 克隆安装器与全局安装(-g)推荐;中英 README 安装节同步。无功能变更。
|
|
168
169
|
|
|
169
170
|
- v0.2.0(2026-08-27):多格式通用化——JSONL / 单文件 JSON / SQLite(opencode 等)/ Markdown(记忆 + 自由笔记)/ 二进制五大格式族,统一 Record + 字段别名归一 + 配置兜底,discover 全源登记,新增 --source / --kind / --format 过滤与默认检索范围(会话 + 结构化记忆开、自由笔记 / 二进制日志关)。详见 CHANGELOG.md。
|
|
170
171
|
- v0.1.0(2026-08-27):首版——零依赖 JSONL 会话日志检索引擎(locate / scan / search / session / stats / tools / version + 默认脱敏 + sessions.json 别名 + 只读)。
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-logs
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
description: 元史 —— 跨智能体的历史会话 / 记忆日志检索技能:零依赖检索 / 分析 JSONL、JSON、SQLite、Markdown 多格式会话与记忆文件,回溯旧对话与父会话上下文,为跨会话追溯提供原始日志依据。触发:用户问起先前聊过的内容 / 父会话 / 历史上下文、要查以前说过的结论、跨会话回溯某次讨论、需要从会话日志或记忆文件定位某段决策时。边界:仅读取本机自己的会话日志 / 记忆文件;不修改、不删除;只查本地不联网上传。
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
package/install.sh
CHANGED
|
@@ -61,10 +61,29 @@ resolve_user() {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
install_to() {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
local base="$1"
|
|
65
|
+
local dest
|
|
66
|
+
case "$base" in
|
|
67
|
+
""|"/") echo "安装失败:拒绝不安全的目标目录:'$base'" >&2; return 1 ;;
|
|
68
|
+
esac
|
|
69
|
+
if [ -L "$base" ]; then
|
|
70
|
+
echo "安装失败:目标目录是符号链接,拒绝跟随:$base" >&2; return 1
|
|
71
|
+
fi
|
|
72
|
+
mkdir -p "$base"
|
|
73
|
+
dest="$base/$SKILL_NAME"
|
|
74
|
+
if [ -L "$dest" ]; then
|
|
75
|
+
echo "安装失败:技能目录是符号链接,拒绝跟随:$dest" >&2; return 1
|
|
76
|
+
fi
|
|
77
|
+
if [ -e "$dest" ] && [ ! -d "$dest" ]; then
|
|
78
|
+
echo "安装失败:技能路径已存在且不是目录:$dest" >&2; return 1
|
|
79
|
+
fi
|
|
80
|
+
mkdir -p "$dest"
|
|
81
|
+
cp -RP "$SOURCE_DIR/." "$dest/"
|
|
82
|
+
# 只清理副本内部的开发残留(固定子路径),不做整目录删除、不跟随符号链接
|
|
83
|
+
if [ -d "$dest/.git" ] && [ ! -L "$dest/.git" ]; then rm -rf "$dest/.git"; fi
|
|
84
|
+
find "$dest" -type d -name '__pycache__' -prune -exec rm -rf {} + 2>/dev/null || true
|
|
85
|
+
find "$dest" -type f -name '*.pyc' -delete 2>/dev/null || true
|
|
86
|
+
echo "installed -> $dest"
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
list() {
|
|
@@ -129,4 +148,4 @@ main() {
|
|
|
129
148
|
fi
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
main "$@"
|
|
151
|
+
main "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-logs",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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
|
|
52
|
-
- opencode 实测 schema(2026-08-27
|
|
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
|
-
-
|
|
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
|
|
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、~/.
|
|
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` 第五节。
|
package/scripts/yotta_logs.py
CHANGED
|
@@ -64,7 +64,7 @@ try:
|
|
|
64
64
|
except Exception:
|
|
65
65
|
pass
|
|
66
66
|
|
|
67
|
-
VERSION = "0.3.
|
|
67
|
+
VERSION = "0.3.2"
|
|
68
68
|
TOOL_NAME = "yotta-logs"
|
|
69
69
|
TOOL_CN = "元史"
|
|
70
70
|
DEFAULT_LIMIT = 50
|
|
@@ -92,7 +92,14 @@ TITLE_ALIASES = ("title", "subject", "name", "heading")
|
|
|
92
92
|
# ── 脱敏(默认开启)──────────────────────────────────────────────────────
|
|
93
93
|
|
|
94
94
|
_URL_RE = re.compile(r"(https?://[^\s\"'<>]+)", re.I)
|
|
95
|
-
|
|
95
|
+
# v0.3.2:URL 内嵌凭据脱敏覆盖所有协议(不只是 http/https),并遮蔽查询串里的
|
|
96
|
+
# 凭据参数 —— 旧行为只处理 http(s)://user:pass@,`postgres://` / `?token=...` 会漏。
|
|
97
|
+
_URL_USERPASS_RE = re.compile(
|
|
98
|
+
r"([a-z][a-z0-9+.\-]*://)([^/\s:@]*):([^/\s@]+)@", re.I)
|
|
99
|
+
_URL_SECRET_QUERY_RE = re.compile(
|
|
100
|
+
r"(?i)([?&](?:token|api[_-]?key|apikey|access[_-]?token|auth|authorization|"
|
|
101
|
+
r"password|passwd|pwd|secret|client[_-]?secret|session|sig|signature)=)"
|
|
102
|
+
r"[^&\s\"'<>]+")
|
|
96
103
|
_KNOWN_KEY_RE = re.compile(
|
|
97
104
|
r"(?i)\b("
|
|
98
105
|
r"sk-[a-z0-9_-]{8,}" # OpenAI 类 API key
|
|
@@ -120,6 +127,7 @@ def redact(text):
|
|
|
120
127
|
return text
|
|
121
128
|
text = _PEM_RE.sub("[PRIVATE KEY REDACTED]", text)
|
|
122
129
|
text = _URL_USERPASS_RE.sub(r"\1\2:***@", text)
|
|
130
|
+
text = _URL_SECRET_QUERY_RE.sub(r"\1***", text)
|
|
123
131
|
chunks = _URL_RE.split(text) # 奇数下标为 URL,原文保留(路径不算密钥)
|
|
124
132
|
out = []
|
|
125
133
|
for i, chunk in enumerate(chunks):
|
|
@@ -743,7 +751,6 @@ class SQLiteReader:
|
|
|
743
751
|
cands += [
|
|
744
752
|
("opencode-db", base / ".local" / "share" / "opencode" / "opencode.db"),
|
|
745
753
|
("opencode-db", base / ".config" / "opencode" / "opencode.db"),
|
|
746
|
-
("opencode-db", base / ".OpenCodeData" / "data" / "opencode" / "opencode.db"),
|
|
747
754
|
]
|
|
748
755
|
seen = set()
|
|
749
756
|
for name, p in cands:
|
|
@@ -979,9 +986,8 @@ class MarkdownReader:
|
|
|
979
986
|
if p.is_dir() and any(x.suffix.lower() in MD_SUFFIXES
|
|
980
987
|
for x in p.iterdir()):
|
|
981
988
|
out.append(_mk_source(name, "memory", "markdown", p))
|
|
982
|
-
codex_home = os.environ.get("CODEX_HOME")
|
|
983
|
-
codex_notes = Path(codex_home) / "memories"
|
|
984
|
-
else base / ".CodexData" / "memories"
|
|
989
|
+
codex_home = os.environ.get("CODEX_HOME") or str(base / ".codex")
|
|
990
|
+
codex_notes = Path(codex_home) / "memories"
|
|
985
991
|
if codex_notes.is_dir() and cls._has_md(codex_notes):
|
|
986
992
|
out.append(_mk_source("codex-notes", "note", "markdown", codex_notes,
|
|
987
993
|
default_on=False))
|
|
@@ -993,7 +999,10 @@ class MarkdownReader:
|
|
|
993
999
|
|
|
994
1000
|
@staticmethod
|
|
995
1001
|
def _memory_home(base):
|
|
996
|
-
"""yotta-memory
|
|
1002
|
+
"""yotta-memory 记忆库位置:优先环境变量,再读引擎 config.json。"""
|
|
1003
|
+
env = os.environ.get("YOTTA_MEMORY_HOME")
|
|
1004
|
+
if env:
|
|
1005
|
+
return Path(env)
|
|
997
1006
|
try:
|
|
998
1007
|
cfg_p = base / ".yottamemory" / "config.json"
|
|
999
1008
|
cfg = json.loads(cfg_p.read_text(encoding="utf-8", errors="replace"))
|
|
@@ -1284,11 +1293,23 @@ def sniff_source(path):
|
|
|
1284
1293
|
|
|
1285
1294
|
# ── 配置兜底 + discover 全源登记 ─────────────────────────────────────────
|
|
1286
1295
|
|
|
1296
|
+
def default_config_path():
|
|
1297
|
+
"""平台无关的默认配置路径;环境变量始终优先。"""
|
|
1298
|
+
env = os.environ.get("YOTTA_LOGS_CONFIG")
|
|
1299
|
+
if env:
|
|
1300
|
+
return Path(env)
|
|
1301
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
1302
|
+
if xdg:
|
|
1303
|
+
return Path(xdg) / "yotta-logs" / "config.json"
|
|
1304
|
+
if os.name == "nt":
|
|
1305
|
+
appdata = os.environ.get("APPDATA")
|
|
1306
|
+
if appdata:
|
|
1307
|
+
return Path(appdata) / "yotta-logs" / "config.json"
|
|
1308
|
+
return Path.home() / ".config" / "yotta-logs" / "config.json"
|
|
1309
|
+
|
|
1310
|
+
|
|
1287
1311
|
def load_config():
|
|
1288
|
-
|
|
1289
|
-
if not p:
|
|
1290
|
-
p = str(Path.home() / ".config" / "yotta-logs" / "config.json")
|
|
1291
|
-
cfg_path = Path(p)
|
|
1312
|
+
cfg_path = default_config_path()
|
|
1292
1313
|
if not cfg_path.exists():
|
|
1293
1314
|
return {}
|
|
1294
1315
|
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()
|