@gobi-starweaver/ws-cli 0.3.1 → 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.
Files changed (36) hide show
  1. package/{chunks/chunk-S7EGXUII.js → dist/chunks/chunk-B2GIIUZ7.js} +2 -2
  2. package/{chunks/chunk-O2SA6W2I.js → dist/chunks/chunk-EMOHUSKW.js} +820 -456
  3. package/{chunks/chunk-BUBNMVFA.js → dist/chunks/chunk-PJQH2TDX.js} +2 -2
  4. package/{chunks/devtools-V5WI5QLO.js → dist/chunks/devtools-6ASGFZ3B.js} +33 -33
  5. package/{cli.js → dist/cli.js} +3988 -1199
  6. package/{mcp → dist/mcp}/servers/doc-toolchain-server.js +8 -8
  7. package/{mcp → dist/mcp}/servers/ws-vision-ocr-server.js +2 -2
  8. package/dist/vendor/hj_corpus/hj_corpus_mcp_server.py +436 -0
  9. package/dist/vendor/hj_corpus/hj_index_search.py +359 -0
  10. package/package.json +31 -11
  11. package/README-en.md +0 -52
  12. /package/{assets → dist/assets}/assembly-orders/README.md +0 -0
  13. /package/{assets → dist/assets}/asset-catalog/index.json +0 -0
  14. /package/{assets → dist/assets}/asset-catalog/snapshots/catalog-2026-08-15.json +0 -0
  15. /package/{assets → dist/assets}/card-templates/README.md +0 -0
  16. /package/{assets → dist/assets}/card-templates/collator.card.json +0 -0
  17. /package/{assets → dist/assets}/card-templates/deep-reader.card.json +0 -0
  18. /package/{assets → dist/assets}/card-templates/grant-editor.card.json +0 -0
  19. /package/{assets → dist/assets}/card-templates/grant-writer.card.json +0 -0
  20. /package/{assets → dist/assets}/card-templates/paper-review-rechecker.card.json +0 -0
  21. /package/{assets → dist/assets}/card-templates/paper-reviewer.card.json +0 -0
  22. /package/{assets → dist/assets}/card-templates/translation-reviewer.card.json +0 -0
  23. /package/{assets → dist/assets}/card-templates/translator.card.json +0 -0
  24. /package/{assets → dist/assets}/card-templates/visual-general.card.json +0 -0
  25. /package/{assets → dist/assets}/card-templates/writing-author.card.json +0 -0
  26. /package/{assets → dist/assets}/card-templates/writing-editor.card.json +0 -0
  27. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/345/206/231/344/275/234/347/273/204.md" +0 -0
  28. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/345/256/241/347/250/277/347/273/204.md" +0 -0
  29. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/347/224/263/346/212/245/347/273/204.md" +0 -0
  30. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/347/277/273/350/257/221/347/273/204.md" +0 -0
  31. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/350/275/254/345/221/210/345/275/242/346/200/201/350/247/204/350/214/203.md" +0 -0
  32. /package/{assets → dist/assets}/expressions/ascii-art.json +0 -0
  33. /package/{assets → dist/assets}/expressions/emoji-full.json +0 -0
  34. /package/{assets → dist/assets}/expressions/emoji.json +0 -0
  35. /package/{bundled → dist/bundled}/plan/SKILL.md +0 -0
  36. /package/{templates → dist/templates}/prompts/init_command.md.ejs +0 -0
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import sqlite3
7
+ import time
8
+ import unicodedata
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ STATION_ROOT = Path(__file__).resolve().parents[2]
15
+ LOG_DIR = STATION_ROOT / "08_运行日志与资产清单"
16
+ # T5:hj 走 HJ_CORPUS_DB env(T4 透传桥·main.cjs resolveHjCorpusDb() 三级解析回写 process.env)。
17
+ # 本 DEFAULT_DB 仅 fallback·封装态不触(hj server 读 env 入口·封装态指向 resources/data/hj_4_jt_core_fts.sqlite)。
18
+ # 开发态 marker 在 → env 未设时 fallback 到站根/05_...(逐字保值)。
19
+ DEFAULT_DB = STATION_ROOT / "05_本地资料索引" / "translate" / "corpus" / "indexes" / "hj_4_jt_fts.sqlite"
20
+ DEFAULT_JSON = LOG_DIR / "HJ古籍索引只读接口验证_20260609.json"
21
+ DEFAULT_TXT = LOG_DIR / "HJ古籍索引只读接口验证_20260609.txt"
22
+
23
+ SPACE_RE = re.compile(r"\s+")
24
+
25
+ EVIDENCE_POLICY = {
26
+ "evidence_role": "candidate_source_and_context_only",
27
+ "evidence_note": "本地古籍 FTS 命中只作出处候选和上下文依据,不作页码依据。",
28
+ "page_basis": False,
29
+ "page_warning": "页码、版本、卷次细目仍须回到拟引用版本或权威数据库逐项核对。",
30
+ }
31
+
32
+
33
+ def now_iso() -> str:
34
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
35
+
36
+
37
+ def compact_query(query: str) -> str:
38
+ return SPACE_RE.sub(" ", query.strip())
39
+
40
+
41
+ def normalize_with_map(text: str) -> tuple[str, list[int]]:
42
+ chars: list[str] = []
43
+ offsets: list[int] = []
44
+ for idx, ch in enumerate(text):
45
+ normalized = unicodedata.normalize("NFKC", ch)
46
+ for item in normalized:
47
+ category = unicodedata.category(item)
48
+ if category[0] in {"C", "P", "S", "Z"}:
49
+ continue
50
+ chars.append(item.lower())
51
+ offsets.append(idx)
52
+ return "".join(chars), offsets
53
+
54
+
55
+ def normalize_query(query: str) -> str:
56
+ normalized, _ = normalize_with_map(query)
57
+ return normalized
58
+
59
+
60
+ def fts_phrase(query: str) -> str:
61
+ return '"' + query.replace('"', '""') + '"'
62
+
63
+
64
+ def open_hj_index(db_path: Path = DEFAULT_DB) -> sqlite3.Connection:
65
+ if not db_path.exists():
66
+ raise FileNotFoundError(f"HJ index not found: {db_path}")
67
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
68
+ conn.row_factory = sqlite3.Row
69
+ conn.execute("PRAGMA query_only=ON")
70
+ conn.execute("PRAGMA temp_store=MEMORY")
71
+ conn.execute("PRAGMA cache_size=-262144")
72
+ return conn
73
+
74
+
75
+ def inspect_hj_index(db_path: Path = DEFAULT_DB) -> dict[str, Any]:
76
+ conn = open_hj_index(db_path)
77
+ try:
78
+ metadata = {
79
+ row["key"]: row["value"]
80
+ for row in conn.execute("SELECT key, value FROM metadata ORDER BY key")
81
+ }
82
+ return {
83
+ "db_path": str(db_path),
84
+ "opened_read_only": True,
85
+ "query_only": int(conn.execute("PRAGMA query_only").fetchone()[0]),
86
+ "doc_count": int(conn.execute("SELECT COUNT(*) FROM docs").fetchone()[0]),
87
+ "metadata": metadata,
88
+ "evidence_policy": EVIDENCE_POLICY,
89
+ }
90
+ finally:
91
+ conn.close()
92
+
93
+
94
+ def search_fts(
95
+ conn: sqlite3.Connection,
96
+ normalized_query: str,
97
+ limit: int,
98
+ book_prefix: str | None = None,
99
+ ) -> list[sqlite3.Row]:
100
+ params: list[object] = [fts_phrase(normalized_query)]
101
+ where = ["docs_fts MATCH ?"]
102
+ if book_prefix:
103
+ where.append("d.book_base LIKE ?")
104
+ params.append(book_prefix.rstrip("/\\") + "%")
105
+ params.append(limit)
106
+ sql = f"""
107
+ SELECT
108
+ d.docid, d.rel_path, d.book_base, d.article_no,
109
+ d.title, d.source, d.body, d.char_count,
110
+ bm25(docs_fts) AS rank
111
+ FROM docs_fts
112
+ JOIN docs AS d ON d.docid = docs_fts.rowid
113
+ WHERE {' AND '.join(where)}
114
+ ORDER BY rank
115
+ LIMIT ?
116
+ """
117
+ return list(conn.execute(sql, params))
118
+
119
+
120
+ def search_like(
121
+ conn: sqlite3.Connection,
122
+ query: str,
123
+ limit: int,
124
+ book_prefix: str | None = None,
125
+ ) -> list[sqlite3.Row]:
126
+ pattern = f"%{query}%"
127
+ params: list[object] = [pattern, pattern, pattern]
128
+ where = ["(d.title LIKE ? OR d.source LIKE ? OR d.body LIKE ?)"]
129
+ if book_prefix:
130
+ where.append("d.book_base LIKE ?")
131
+ params.append(book_prefix.rstrip("/\\") + "%")
132
+ params.append(limit)
133
+ sql = f"""
134
+ SELECT
135
+ d.docid, d.rel_path, d.book_base, d.article_no,
136
+ d.title, d.source, d.body, d.char_count,
137
+ 0.0 AS rank
138
+ FROM docs AS d
139
+ WHERE {' AND '.join(where)}
140
+ LIMIT ?
141
+ """
142
+ return list(conn.execute(sql, params))
143
+
144
+
145
+ def make_context(
146
+ text: str,
147
+ raw_query: str,
148
+ normalized_query: str,
149
+ width: int,
150
+ ) -> tuple[str, int, str]:
151
+ pos = text.find(raw_query)
152
+ end_pos = pos + len(raw_query) if pos >= 0 else -1
153
+ if pos < 0:
154
+ normalized_text, offsets = normalize_with_map(text)
155
+ folded_pos = normalized_text.find(normalized_query)
156
+ if folded_pos >= 0 and folded_pos < len(offsets):
157
+ pos = offsets[folded_pos]
158
+ last = min(folded_pos + len(normalized_query) - 1, len(offsets) - 1)
159
+ end_pos = offsets[last] + 1
160
+ if pos < 0 or end_pos < 0:
161
+ return text[: width * 2].replace("\n", " "), -1, ""
162
+ start = max(0, pos - width)
163
+ end = min(len(text), end_pos + width)
164
+ prefix = "..." if start > 0 else ""
165
+ suffix = "..." if end < len(text) else ""
166
+ return (prefix + text[start:end] + suffix).replace("\n", " "), pos, text[pos:end_pos]
167
+
168
+
169
+ def row_to_candidate(
170
+ row: sqlite3.Row,
171
+ raw_query: str,
172
+ normalized_query: str,
173
+ context_chars: int,
174
+ ) -> dict[str, Any]:
175
+ best_context = ""
176
+ position = -1
177
+ matched_text = ""
178
+ hit_column = "body"
179
+ for name in ("title", "source", "body"):
180
+ context, pos, match = make_context(row[name] or "", raw_query, normalized_query, context_chars)
181
+ if pos >= 0:
182
+ best_context = context
183
+ position = pos
184
+ matched_text = match
185
+ hit_column = name
186
+ break
187
+ if not best_context:
188
+ best_context = context
189
+
190
+ return {
191
+ "docid": row["docid"],
192
+ "book_base": row["book_base"],
193
+ "rel_path": row["rel_path"],
194
+ "article_no": row["article_no"],
195
+ "title": row["title"],
196
+ "source": row["source"],
197
+ "char_count": row["char_count"],
198
+ "rank": row["rank"],
199
+ "hit_column": hit_column,
200
+ "position": position,
201
+ "matched_text": matched_text,
202
+ "context": best_context,
203
+ "candidate_source": {
204
+ "book_base": row["book_base"],
205
+ "title": row["title"],
206
+ "article_no": row["article_no"],
207
+ "rel_path": row["rel_path"],
208
+ },
209
+ "evidence_role": EVIDENCE_POLICY["evidence_role"],
210
+ "page_basis": False,
211
+ "page_warning": EVIDENCE_POLICY["page_warning"],
212
+ }
213
+
214
+
215
+ def search_hj_index(
216
+ query: str,
217
+ *,
218
+ db_path: Path = DEFAULT_DB,
219
+ limit: int = 10,
220
+ context_chars: int = 80,
221
+ book_prefix: str | None = None,
222
+ allow_short_scan: bool = False,
223
+ ) -> dict[str, Any]:
224
+ raw_query = compact_query(query)
225
+ normalized_query = normalize_query(raw_query)
226
+ if not normalized_query:
227
+ raise ValueError("query is empty after normalization")
228
+ if limit < 1:
229
+ raise ValueError("limit must be positive")
230
+ if context_chars < 0:
231
+ raise ValueError("context_chars must be non-negative")
232
+
233
+ warnings: list[str] = []
234
+ conn = open_hj_index(db_path)
235
+ try:
236
+ started = time.perf_counter()
237
+ if len(normalized_query) >= 3:
238
+ rows = search_fts(conn, normalized_query, limit, book_prefix)
239
+ mode = "fts5-trigram"
240
+ elif allow_short_scan:
241
+ rows = search_like(conn, raw_query, limit, book_prefix)
242
+ mode = "like-scan-short-query"
243
+ warnings.append("短词查询未走 trigram 倒排索引,结果仅用于人工初筛。")
244
+ else:
245
+ rows = []
246
+ mode = "short-query-blocked"
247
+ warnings.append("少于 3 个规范化字符的查询不会命中 trigram 索引;必要时启用 allow_short_scan。")
248
+ elapsed_ms = (time.perf_counter() - started) * 1000
249
+ candidates = [
250
+ row_to_candidate(row, raw_query, normalized_query, context_chars)
251
+ for row in rows
252
+ ]
253
+ finally:
254
+ conn.close()
255
+
256
+ return {
257
+ "query": raw_query,
258
+ "normalized_query": normalized_query,
259
+ "searched_at": now_iso(),
260
+ "db_path": str(db_path),
261
+ "mode": mode,
262
+ "limit": limit,
263
+ "context_chars": context_chars,
264
+ "book_prefix": book_prefix,
265
+ "hit_count": len(candidates),
266
+ "elapsed_ms": elapsed_ms,
267
+ "warnings": warnings,
268
+ "evidence_policy": EVIDENCE_POLICY,
269
+ "results": candidates,
270
+ }
271
+
272
+
273
+ def write_json_report(report: dict[str, Any], out_path: Path) -> None:
274
+ out_path.parent.mkdir(parents=True, exist_ok=True)
275
+ out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
276
+
277
+
278
+ def write_text_report(report: dict[str, Any], out_path: Path) -> None:
279
+ lines = [
280
+ "HJ古籍索引只读检索接口验证",
281
+ f"查询:{report['query']}",
282
+ f"规范化查询:{report['normalized_query']}",
283
+ f"数据库:{report['db_path']}",
284
+ f"模式:{report['mode']}",
285
+ f"命中数:{report['hit_count']}",
286
+ f"耗时毫秒:{report['elapsed_ms']:.3f}",
287
+ "",
288
+ "证据规则:",
289
+ f"- {EVIDENCE_POLICY['evidence_note']}",
290
+ f"- {EVIDENCE_POLICY['page_warning']}",
291
+ ]
292
+ if report["warnings"]:
293
+ lines.extend(["", "警告:"])
294
+ lines.extend(f"- {warning}" for warning in report["warnings"])
295
+ lines.extend(["", "命中候选:"])
296
+ for idx, item in enumerate(report["results"], start=1):
297
+ lines.extend(
298
+ [
299
+ "",
300
+ f"{idx}. {item['title']}",
301
+ f" book_base/article:{item['book_base']} / {item['article_no']}",
302
+ f" source:{item['source']}",
303
+ f" hit:{item['hit_column']}@{item['position']} matched={item['matched_text'] or '(未定位)'}",
304
+ f" context:{item['context']}",
305
+ " page_basis:False;页码须另核版本。",
306
+ ]
307
+ )
308
+ out_path.parent.mkdir(parents=True, exist_ok=True)
309
+ out_path.write_text("\n".join(lines), encoding="utf-8")
310
+
311
+
312
+ def command_stats(args: argparse.Namespace) -> None:
313
+ print(json.dumps(inspect_hj_index(Path(args.db)), ensure_ascii=False, indent=2))
314
+
315
+
316
+ def command_search(args: argparse.Namespace) -> None:
317
+ report = search_hj_index(
318
+ args.query,
319
+ db_path=Path(args.db),
320
+ limit=args.limit,
321
+ context_chars=args.context,
322
+ book_prefix=args.book,
323
+ allow_short_scan=args.allow_short_scan,
324
+ )
325
+ if args.out_json:
326
+ write_json_report(report, Path(args.out_json))
327
+ if args.out_txt:
328
+ write_text_report(report, Path(args.out_txt))
329
+ print(json.dumps(report, ensure_ascii=False, indent=2))
330
+
331
+
332
+ def build_parser() -> argparse.ArgumentParser:
333
+ parser = argparse.ArgumentParser(description="中古史学术IDE HJ古籍索引只读检索接口")
334
+ parser.add_argument("--db", default=str(DEFAULT_DB), help="hj_4_jt_fts.sqlite path")
335
+ subparsers = parser.add_subparsers(dest="command", required=True)
336
+
337
+ stats = subparsers.add_parser("stats", help="inspect index metadata using read-only access")
338
+ stats.set_defaults(func=command_stats)
339
+
340
+ search = subparsers.add_parser("search", help="search HJ index as source candidates")
341
+ search.add_argument("query")
342
+ search.add_argument("--limit", type=int, default=10)
343
+ search.add_argument("--context", type=int, default=80)
344
+ search.add_argument("--book", help="filter by book_base prefix, e.g. A01/A01_100")
345
+ search.add_argument("--allow-short-scan", action="store_true")
346
+ search.add_argument("--out-json", default="")
347
+ search.add_argument("--out-txt", default="")
348
+ search.set_defaults(func=command_search)
349
+ return parser
350
+
351
+
352
+ def main() -> None:
353
+ parser = build_parser()
354
+ args = parser.parse_args()
355
+ args.func(args)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
package/package.json CHANGED
@@ -1,26 +1,46 @@
1
1
  {
2
2
  "name": "@gobi-starweaver/ws-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "北辰计划·望舒 Polaris·WS — 面向史学与人文研究的终端智能体(WS CLI)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
7
- "main": "cli.js",
7
+ "homepage": "https://gobi-starweaver.cn",
8
8
  "bin": {
9
- "ws": "cli.js"
9
+ "ws": "./dist/cli.js"
10
10
  },
11
+ "main": "./dist/cli.js",
11
12
  "files": [
12
- "cli.js",
13
- "chunks/**",
14
- "templates/**",
15
- "bundled/**",
16
- "assets/**",
17
- "mcp/**",
13
+ "dist/cli.js",
14
+ "dist/chunks/**",
15
+ "dist/templates/**",
16
+ "dist/bundled/**",
17
+ "dist/mcp/**",
18
+ "dist/vendor/**",
19
+ "dist/assets/**",
18
20
  "README.md",
19
- "README-en.md",
20
21
  "LICENSE"
21
22
  ],
22
23
  "engines": {
23
24
  "node": ">=22"
24
25
  },
25
- "dependencies": {}
26
+ "scripts": {
27
+ "typecheck": "tsc -p ./ --noEmit",
28
+ "bundle": "node ../../scripts/esbuild.config.js",
29
+ "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"import('node:fs').then(f => f.chmodSync('dist/cli.js', 0o755))\"",
30
+ "prepublishOnly": "npm run build",
31
+ "format": "prettier --write .",
32
+ "test": "node ../../scripts/scan-theme-drift.mjs && node src/tests/run-tests.mjs"
33
+ },
34
+ "dependencies": {
35
+ "@gobi-starweaver/ws-core": "file:../core",
36
+ "chalk": "^5.6.2",
37
+ "ignore": "^7.0.5",
38
+ "ink": "^7.0.4",
39
+ "react": "^19.2.5",
40
+ "read-package-up": "^12.0.0",
41
+ "yargs": "^18.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/yargs": "^17.0.35"
45
+ }
26
46
  }
package/README-en.md DELETED
@@ -1,52 +0,0 @@
1
- <div align="center">
2
-
3
- <h1>北辰计划·望舒 Polaris·WS</h1>
4
-
5
- **A soft lamp for all who sail the sea of learning by night.**
6
-
7
- [中文](README.md) · English
8
-
9
- </div>
10
-
11
- 望舒 (WS) is a terminal agent from the Beichen Project (北辰计划), built for historical and humanistic research: in your command line, it inquires, reads, writes and revises with you through conversation — like a colleague sitting across your desk.
12
-
13
- The Beichen Project is the humanities-agent series of Gobi StarWeaver (GSH), and holds that an agent for the humanities should carry both human warmth and scholarly rigor.
14
-
15
- ## Installation
16
-
17
- One command installs everything (auto-detects your OS and architecture, installs all dependencies, and talks to exactly one domain — our official site):
18
-
19
- **Windows** (paste in PowerShell, press Enter):
20
-
21
- ```powershell
22
- irm https://gobi-starweaver.cn/dist/install.ps1 | iex
23
- ```
24
-
25
- **macOS / Linux** (paste in a terminal, press Enter):
26
-
27
- ```bash
28
- curl -fsSL https://gobi-starweaver.cn/dist/install.sh | bash
29
- ```
30
-
31
- Then **close and reopen your terminal**, and run `ws` in any project directory. On first launch, sign in with your Beichen Project account to get started (subscription verification happens automatically with login).
32
-
33
- ## What it does
34
-
35
- - **Conversation is the work**: state your topic and materials; 望舒 searches, reads, writes and revises with you, step by step.
36
- - **Historical sources at hand**: built-in Hanji corpus retrieval — dynastic histories, veritable records and collected works, with citations traceable to juan and page.
37
- - **Thinking with a breath**: while waiting, you can see footprints like "consulting the Zizhi Tongjian…"; the moment the answer arrives, all is calm again.
38
- - **Skills that grow**: the Skills and MCP plugin system lets 望舒 grow with your research habits.
39
- - **Sign in and rest easy**: once logged in and your subscription verified, every request is relayed securely through Beichen servers — no keys ever live on your machine.
40
-
41
- ## Versioning
42
-
43
- 望舒 carries two version numbers, each governing its own domain:
44
-
45
- - **Mind version v0.9**: the settled version of 望舒's persona, mind and writing style — how it thinks and how it writes.
46
- - **Release version 0.3.0**: the semantic version of the CLI software itself — installation, upgrades and issue reports refer to this one.
47
-
48
- ## License and origin
49
-
50
- This software is **proprietary** — copyright Gobi StarWeaver Digital History Lab (GSH), all rights reserved; the portions originating from [DeepCode](https://github.com/lessweb/deepcode-cli) remain under the MIT License as required — see [LICENSE](LICENSE). 望舒 CLI is continuously developed by GSH.
51
-
52
- 《史学智能生态系统》魏柯帆团队原创,知识产权受保护。
File without changes
File without changes