dsh-context-toolkit 0.2.4 → 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/README.md +22 -2
- package/README.zh.md +23 -2
- package/client.js +4 -4
- package/index.js +194 -2
- package/lib/compress.js +251 -0
- package/lib/store.js +55 -1
- package/locale/en.json +2 -0
- package/locale/zh.json +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ It separates *raw bytes* from *conclusions*: commands run in a child process, fi
|
|
|
28
28
|
| `ctk_batch_execute` | Several commands in one call, per-command status, conclusions only |
|
|
29
29
|
| `ctk_index` | File text or a file into the local FTS5 knowledge base |
|
|
30
30
|
| `ctk_search` | Multi-query retrieval; excerpts are centred on the match |
|
|
31
|
+
| `ctk_retrieve` | Read back a slimmed output's stored original, byte-for-byte, from its `[ctk_retrieve id=…]` marker |
|
|
31
32
|
| `ctk_fetch_and_index` | Fetch pages → strip markup → file them |
|
|
32
33
|
| `ctk_purge` | Delete filed material (requires `confirm: true`) |
|
|
33
34
|
| `ctk_stats` | Savings ledger: refusals, sandbox bytes, filed bytes, call counts |
|
|
@@ -114,12 +115,31 @@ Tool switches: `tools.execute`, `tools.executeFile`, `tools.batchExecute`, `tool
|
|
|
114
115
|
The offline suites need no DSH runtime:
|
|
115
116
|
|
|
116
117
|
```bash
|
|
117
|
-
node smoke.mjs #
|
|
118
|
+
node smoke.mjs # 82 checks: contract, runtime discovery, tools, gate (segment matching, host-aligned path base, script_path and host-fetcher coverage), output slimming and reversible retrieval, resource disposal, knowledge base, observability, version reporting
|
|
118
119
|
node test-client.mjs # 27 checks: browser-half contract and bilingual dictionary parity
|
|
119
120
|
node test-package.mjs # 20 checks: publish manifest and the zero-dependency constraint
|
|
120
|
-
node release-check.mjs #
|
|
121
|
+
node release-check.mjs # 30 checks: release readiness (manifest, deps, tool count, gate matching, version, SHA coverage, no hardcoded paths)
|
|
121
122
|
```
|
|
122
123
|
|
|
124
|
+
## Output slimming (after a tool runs)
|
|
125
|
+
|
|
126
|
+
The gate works at the *door*; slimming handles what is **already inside**. They catch different traffic: the gate can only read a call's *arguments*, while slimming sees the *bytes that actually came back* — from a tool the gate does not know, an entry point it does not cover, or output that was never predictable from the call.
|
|
127
|
+
|
|
128
|
+
At `tools/post-execute`, the strategy follows the shape:
|
|
129
|
+
|
|
130
|
+
| Shape | Treatment |
|
|
131
|
+
|---|---|
|
|
132
|
+
| Uniform JSON array | **Lossless** tabular re-encoding: keys written once, no information dropped |
|
|
133
|
+
| Table | Header plus both ends kept; the middle stride-sampled **with original line numbers** (the model gets a map with coordinates) |
|
|
134
|
+
| grep / search | Folded by file, first hits per file kept |
|
|
135
|
+
| Log | Ordinary lines folded away, **diagnostics kept verbatim** |
|
|
136
|
+
| Long prose | Head/tail window with an explicit elision marker |
|
|
137
|
+
| Code / key-value | **Not compressed** (the model may be about to edit exact bytes) |
|
|
138
|
+
|
|
139
|
+
**Two gates**: a candidate must be at most 72% of the original bytes *and* have a strictly lower token estimate, or it is dropped — no saving means no claim, and the ledger never overstates.
|
|
140
|
+
|
|
141
|
+
**Reversibility is the red line**: every lossy path stores the exact original first and injects a `[ctk_retrieve id=…]` marker, read back with `ctk_retrieve`. The originals live in the **same database as the FTS5 index** — one file, two jobs: FTS5 finds sections, blobs restore them.
|
|
142
|
+
|
|
123
143
|
## Known limits
|
|
124
144
|
|
|
125
145
|
- **Outbound fetching depends on this machine's network.** If host names resolve to a loopback address, `ctk_fetch_and_index` fails with that reason spelled out — not a bare "fetch failed" — and `ctk_index` is the way to file local material instead.
|
package/README.zh.md
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
| `ctk_batch_execute` | 一次跑多条命令,逐条状态,输出只有结论 |
|
|
29
29
|
| `ctk_index` | 把文本或文件存进本地 FTS5 知识库 |
|
|
30
30
|
| `ctk_search` | 多查询检索知识库,返回围绕命中词的片段 |
|
|
31
|
+
| `ctk_retrieve` | 取回被瘦身压缩掉的原文(逐字节,按 `[ctk_retrieve id=…]` 标记的 id) |
|
|
32
|
+
| `ctk_retrieve` | 取回被瘦身压缩掉的原文(逐字节,按 `[ctk_retrieve id=…]` 标记的 id) |
|
|
31
33
|
| `ctk_fetch_and_index` | 抓网页 → 去标签转文本 → 直接入库 |
|
|
32
34
|
| `ctk_purge` | 删除已归档内容(必须 `confirm: true`) |
|
|
33
35
|
| `ctk_stats` | 节约台账:拦截次数、沙箱保留字节、归档字节、调用计数 |
|
|
@@ -114,12 +116,31 @@ await ctk_search({ queries: ['rate limit', 'auth'] }); // 只取相关片段
|
|
|
114
116
|
离线套件不依赖 DSH 运行时:
|
|
115
117
|
|
|
116
118
|
```bash
|
|
117
|
-
node smoke.mjs #
|
|
119
|
+
node smoke.mjs # 82 项:契约、运行时探测、工具、门禁(分段匹配 + 路径基准对齐 + script_path 与取网页工具覆盖)、输出瘦身与可逆取回、资源释放、知识库、观测、版本自述
|
|
118
120
|
node test-client.mjs # 27 项:浏览器半体契约与双语字典一致性
|
|
119
121
|
node test-package.mjs # 20 项:发布清单与零依赖约束
|
|
120
|
-
node release-check.mjs #
|
|
122
|
+
node release-check.mjs # 30 项:发布就绪(清单/依赖/工具数/门禁匹配/版本/SHA 覆盖/无硬编码路径)
|
|
121
123
|
```
|
|
122
124
|
|
|
125
|
+
## 输出瘦身(工具执行后)
|
|
126
|
+
|
|
127
|
+
门禁在**入口**拦下次就会灌满对话的调用;瘦身则处理**已经进来的**输出——两者抓的是不同流量:门禁只能看调用的**参数**,瘦身看的是**真实回来的字节**(来自门禁不认识的工具、它没覆盖的入口,或本来就无法从调用预判的输出)。
|
|
128
|
+
|
|
129
|
+
在 `tools/post-execute` 处按形态选择策略:
|
|
130
|
+
|
|
131
|
+
| 形态 | 做法 |
|
|
132
|
+
|---|---|
|
|
133
|
+
| 同构 JSON 数组 | **无损**表化重编码:键只写一次,信息零损失 |
|
|
134
|
+
| 表格 | 保留表头 + 首尾,中段**按原始行号**步进采样(模型拿到的是带坐标的地图) |
|
|
135
|
+
| grep/search | 按文件折叠,每文件保留前若干命中 |
|
|
136
|
+
| 日志 | 折叠普通行,**诊断行逐字保留** |
|
|
137
|
+
| 长散文 | 头尾窗口 + 显式省略标记 |
|
|
138
|
+
| 代码 / 键值对 | **不压**(模型可能正要按精确字节编辑) |
|
|
139
|
+
|
|
140
|
+
**双重门**:候选必须同时满足「字节 ≤ 72%」且「估算 token 严格下降」,否则放弃——压不动就不压,台账绝不虚报节省。
|
|
141
|
+
|
|
142
|
+
**可逆红线**:任何有损路径先把原文**逐字节**存好并注入 `[ctk_retrieve id=…]` 标记,用 `ctk_retrieve` 取回。原文与 FTS5 索引同在**同一个数据库**(一个文件两个职责:FTS5 找段落,blob 逐字节还原)。
|
|
143
|
+
|
|
123
144
|
## 已知边界
|
|
124
145
|
|
|
125
146
|
- **出站抓取受本机网络影响**:若本机把域名解析到回环地址,`ctk_fetch_and_index` 会失败并明确说明原因(而不是只报 "fetch failed");此时改用 `ctk_index` 归档本地材料。
|
package/client.js
CHANGED
|
@@ -17,8 +17,8 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
let jsxs = jsxRuntime.jsxs;
|
|
18
18
|
|
|
19
19
|
//#region locales
|
|
20
|
-
const zh = {"title":"上下文工具箱","description":"在沙箱里跑代码、把大块材料归档成可检索的本地知识库、并挡住会灌满对话的抓取与整读。附一张实时上下文占用面板。","hint":"下列开关即时生效,无需重启:门禁在每次工具调用前读取。","routing":"路由门禁","routingHint":"总开关。开启后,每次工具调用执行前都会经过门禁检查。","denyCurlWget":"硬拦 curl / wget","denyCurlWgetHint":"这两个工具的设计就是把整页内容塞进对话;拦掉,改用 ctk_fetch_and_index。","bashNudgeMinCommandBytes":"长命令引导阈值(字节)","bashNudgeMinCommandBytesHint":"命令超过这个长度时提示改用 ctk_batch_execute。填 0 表示不提示。","maxReadBytesBeforeAsk":"大文件整读引导阈值(字节)","maxReadBytesBeforeAskHint":"整读超过这个大小的文件时提示改用 ctk_execute_file。填 0 表示不提示。","nudgeFetchTools":"取网页工具也纳入整页引导","nudgeFetchToolsHint":"把宿主自带的 web_fetch / read_page 也纳入引导:它们与 shell 里的 curl 取回同样一整页,却不在 shell 门禁的视野里。关闭则只拦 shell 一侧。","trustedReadBasenames":"不受整读门禁限制的文件名","trustedReadBasenamesHint":"逗号分隔;这些文件即使很大也允许整读。","compressNudgeTokens":"占用提示阈值(tokens)","compressNudgeTokensHint":"表面占用达到此值时,占用报告给出压缩建议。填 0 表示不提示。","searchLimit":"检索返回条数上限","searchLimitHint":"ctk_search 每次查询返回的片段数上限。","snippetChars":"片段最大字符数","snippetCharsHint":"ctk_search 每个片段返回的字符上限,超出部分省略。","fetchMaxBytes":"单页抓取上限(字节)","fetchMaxBytesHint":"ctk_fetch_and_index 抓取单页的最大字节数,超出部分丢弃。","storageDir":"知识库根目录","storageDirHint":"留空 = <DSH_HOME>/context-toolkit。","toolsTitle":"工具开关","toolsHint":"逐个启用或停用 ctk_* 工具。","overridden":"已覆盖","reset":"恢复默认","readOnly":"本部署的设置为只读。","unavailable":"该插件当前未加载,暂时无法配置。","save":"保存","saving":"保存中…","saveFailed":"本部署没有接受这些值,已保留供你修改。","invalidNumber":"请填数字;留空表示使用默认值。","note":"门禁开关即时生效;工具开关改动后建议重开页面确认。","ctxTitle":"上下文情况","ctxLoading":"正在读取…","ctxReload":"刷新","ctxFailed":"读取失败:","ctxNoSession":"本会话暂无可测量的投影。","ctxStale":"以下为最近一次实测快照({time}):网页请求没有会话边界,实时值请在对话中调用 ctk_context_report。","ctxTotal":"总压力 {n} tokens","ctxSurface":"表面占用 {n} tokens","ctxBudget":"提示阈值 {n} tokens","ctxOver":"已超过提示阈值,建议把大块输出改走 ctk_execute / ctk_index。","ctxUnder":"低于提示阈值,暂不需要干预。","ctxSystem":"系统提示","ctxTools":"工具 schema","ctxMessages":"会话消息","ctxEstimated":"(估算)","ctxNodes":"占用大头(前 {n} 项)","ctxLedger":"本插件节约台账","ctxLedgerLine":"门禁已拦截 {n} 次,约 {bytes} 字节未进入上下文;沙箱保留 {sandbox} 字节;已注册 {tools} 个 ctk_* 工具。","ctxKnowledge":"知识库:{docs} 文档 / {chunks} 片段({bytes} 字节)","ctxStorage":"知识库位置:{path}","tools.execute":"ctk_execute(在沙箱里跑代码,只把结论带回来)","tools.executeHint":"用于「只想要结论、不想让原始输出占满上下文」的分析型任务。","tools.executeFile":"ctk_execute_file(对某个文件跑代码,只回结论)","tools.executeFileHint":"适合读大文件后做统计或抽取,文件原文留在沙箱里。","tools.batchExecute":"ctk_batch_execute(一次跑多条命令,逐条状态)","tools.batchExecuteHint":"多条命令一次调用,只有结论回来。","tools.index":"ctk_index(把大块材料存进本地知识库)","tools.indexHint":"把长文档、日志、规范归档,之后用 ctk_search 按需取用。","tools.search":"ctk_search(按需检索知识库,取片段而非整份)","tools.searchHint":"知识库里已有的内容优先用它检索,不要整份读进来。","tools.fetchAndIndex":"ctk_fetch_and_index(抓网页并直接并入索引)","tools.fetchAndIndexHint":"抓整页网页又不让它淹没对话时用;curl/wget 会被门禁拦掉。","tools.purge":"ctk_purge(删除已索引内容,不可恢复)","tools.purgeHint":"清理知识库用;会永久删除,调用必须带 confirm:true。","tools.stats":"ctk_stats(节约台账与知识库用量)","tools.statsHint":"想知道这个插件省了多少、归档了多少时看它。","tools.doctor":"ctk_doctor(自检:运行时、临时目录、知识库往返)","tools.doctorHint":"出现异常(子进程跑不起来、索引失败)时先跑它定位。","tools.contextReport":"ctk_context_report(会话上下文占用报告)","tools.contextReportHint":"让模型可以主动查询本会话的上下文占用与构成。","restart":"需重启"};
|
|
21
|
-
const en = {"title":"Context Toolkit","description":"Run code in a sandbox, file large material into a local searchable knowledge base, and block the fetches and whole-file reads that flood a conversation. Ships a live context-occupancy panel.","hint":"These switches apply immediately, no restart: the gate is read before every tool call.","routing":"Routing gate","routingHint":"Master switch. When on, every tool call passes through the gate before running.","denyCurlWget":"Block curl / wget","denyCurlWgetHint":"Both exist to paste whole responses into the conversation; blocked here, use ctk_fetch_and_index instead.","bashNudgeMinCommandBytes":"Long-command nudge (bytes)","bashNudgeMinCommandBytesHint":"Commands longer than this get a nudge toward ctk_batch_execute. 0 disables it.","maxReadBytesBeforeAsk":"Whole-file read nudge (bytes)","maxReadBytesBeforeAskHint":"Reading a file larger than this gets a nudge toward ctk_execute_file. 0 disables it.","nudgeFetchTools":"Nudge the host fetchers too","nudgeFetchToolsHint":"Also nudge the host own web_fetch / read_page: they pull back the same whole page as a shell curl, and the shell gate never sees them. Off leaves that door unwatched.","trustedReadBasenames":"Filenames exempt from the read nudge","trustedReadBasenamesHint":"Comma separated; these may be read whole however large they are.","compressNudgeTokens":"Occupancy warning (tokens)","compressNudgeTokensHint":"At this surface occupancy the report suggests compaction. 0 disables it.","searchLimit":"Search result limit","searchLimitHint":"How many excerpts ctk_search returns per query.","snippetChars":"Excerpt size (chars)","snippetCharsHint":"Character ceiling on each excerpt from ctk_search.","fetchMaxBytes":"Per-page fetch ceiling (bytes)","fetchMaxBytesHint":"ctk_fetch_and_index keeps at most this many bytes of each page.","storageDir":"Knowledge-base root","storageDirHint":"Empty = <DSH_HOME>/context-toolkit.","toolsTitle":"Tool switches","toolsHint":"Enable or disable individual ctk_* tools.","overridden":"overridden","reset":"reset","readOnly":"This deployment is read-only.","unavailable":"The plugin is not loaded right now; it cannot be configured.","save":"Save","saving":"Saving…","saveFailed":"This deployment did not accept those values; they are kept for you to edit.","invalidNumber":"Numbers only; blank means the default.","note":"Gate switches apply immediately; reopen the page to confirm tool-switch changes.","ctxTitle":"Context occupancy","ctxLoading":"Reading…","ctxReload":"Refresh","ctxFailed":"Read failed: ","ctxNoSession":"No measurable projection in this session yet.","ctxStale":"Snapshot from the last real measurement ({time}): a page request has no session boundary; ask ctk_context_report in the conversation for a live reading.","ctxTotal":"{n} tokens total","ctxSurface":"{n} tokens on the surface","ctxBudget":"warning at {n} tokens","ctxOver":"Above the warning threshold — route bulk output through ctk_execute / ctk_index.","ctxUnder":"Below the warning threshold; nothing to do.","ctxSystem":"System prompt","ctxTools":"Tool schemas","ctxMessages":"Conversation","ctxEstimated":" (estimated)","ctxNodes":"Biggest contributors (top {n})","ctxLedger":"This plugin's ledger","ctxLedgerLine":"The gate blocked {n} calls, about {bytes} bytes kept out of context; the sandbox kept out {sandbox} bytes; {tools} ctk_* tools registered.","ctxKnowledge":"Knowledge base: {docs} documents / {chunks} chunks ({bytes} bytes)","ctxStorage":"Knowledge base at {path}","tools.execute":"ctk_execute (run code in the sandbox, return only the answer)","tools.executeHint":"For analysis where you want the conclusion, not the raw output.","tools.executeFile":"ctk_execute_file (run code over a file, return only the answer)","tools.executeFileHint":"Read a big file, compute over it, keep its bytes in the sandbox.","tools.batchExecute":"ctk_batch_execute (many commands in one call, per-command status)","tools.batchExecuteHint":"One call for many commands; only conclusions come back.","tools.index":"ctk_index (file large material into the local knowledge base)","tools.indexHint":"Archive long docs, logs or specs, then pull sections with ctk_search.","tools.search":"ctk_search (retrieve excerpts on demand, not the whole thing)","tools.searchHint":"Prefer this for material already in the knowledge base.","tools.fetchAndIndex":"ctk_fetch_and_index (fetch a page straight into the index)","tools.fetchAndIndexHint":"When a whole page would flood the conversation; curl/wget are gated.","tools.purge":"ctk_purge (delete indexed content — permanent)","tools.purgeHint":"Clears the knowledge base; permanent, so the call must confirm:true.","tools.stats":"ctk_stats (this plugin's savings ledger and knowledge usage)","tools.statsHint":"How much this plugin saved, and how much it filed.","tools.doctor":"ctk_doctor (self-check: runtimes, scratch dir, knowledge round-trip)","tools.doctorHint":"Run it first when a child process or indexing misbehaves.","tools.contextReport":"ctk_context_report (session context occupancy report)","tools.contextReportHint":"Lets the model ask for this session context occupancy and composition.","restart":"needs a restart"};
|
|
20
|
+
const zh = {"title":"上下文工具箱","description":"在沙箱里跑代码、把大块材料归档成可检索的本地知识库、并挡住会灌满对话的抓取与整读。附一张实时上下文占用面板。","hint":"下列开关即时生效,无需重启:门禁在每次工具调用前读取。","routing":"路由门禁","routingHint":"总开关。开启后,每次工具调用执行前都会经过门禁检查。","denyCurlWget":"硬拦 curl / wget","denyCurlWgetHint":"这两个工具的设计就是把整页内容塞进对话;拦掉,改用 ctk_fetch_and_index。","bashNudgeMinCommandBytes":"长命令引导阈值(字节)","bashNudgeMinCommandBytesHint":"命令超过这个长度时提示改用 ctk_batch_execute。填 0 表示不提示。","maxReadBytesBeforeAsk":"大文件整读引导阈值(字节)","maxReadBytesBeforeAskHint":"整读超过这个大小的文件时提示改用 ctk_execute_file。填 0 表示不提示。","nudgeFetchTools":"取网页工具也纳入整页引导","nudgeFetchToolsHint":"把宿主自带的 web_fetch / read_page 也纳入引导:它们与 shell 里的 curl 取回同样一整页,却不在 shell 门禁的视野里。关闭则只拦 shell 一侧。","slimOutputs":"工具输出瘦身","slimOutputsHint":"工具执行后、进入模型历史前压缩:表格保留表头+首尾+带原始行号的中段采样,同构 JSON 走无损重编码,日志折叠掉普通行保住诊断行。有损路径一定把原文逐字节存好,可用 ctk_retrieve 取回。","slimMinBytes":"普通输出瘦身门槛(字节)","slimMinBytesHint":"普通输出小于此值不动。0 表示关闭普通输出瘦身。","slimErrorMinBytes":"错误输出瘦身门槛(字节)","slimErrorMinBytesHint":"留得比普通输出高:调试时要的是保真,不是省字节。","slimOriginalTtlMinutes":"可逆原文保留时长(分钟)","slimOriginalTtlMinutesHint":"瘦身前存的原文保留多久;0 表示一直保留。","trustedReadBasenames":"不受整读门禁限制的文件名","trustedReadBasenamesHint":"逗号分隔;这些文件即使很大也允许整读。","compressNudgeTokens":"占用提示阈值(tokens)","compressNudgeTokensHint":"表面占用达到此值时,占用报告给出压缩建议。填 0 表示不提示。","searchLimit":"检索返回条数上限","searchLimitHint":"ctk_search 每次查询返回的片段数上限。","snippetChars":"片段最大字符数","snippetCharsHint":"ctk_search 每个片段返回的字符上限,超出部分省略。","fetchMaxBytes":"单页抓取上限(字节)","fetchMaxBytesHint":"ctk_fetch_and_index 抓取单页的最大字节数,超出部分丢弃。","storageDir":"知识库根目录","storageDirHint":"留空 = <DSH_HOME>/context-toolkit。","toolsTitle":"工具开关","toolsHint":"逐个启用或停用 ctk_* 工具。","overridden":"已覆盖","reset":"恢复默认","readOnly":"本部署的设置为只读。","unavailable":"该插件当前未加载,暂时无法配置。","save":"保存","saving":"保存中…","saveFailed":"本部署没有接受这些值,已保留供你修改。","invalidNumber":"请填数字;留空表示使用默认值。","note":"门禁开关即时生效;工具开关改动后建议重开页面确认。","ctxTitle":"上下文情况","ctxLoading":"正在读取…","ctxReload":"刷新","ctxFailed":"读取失败:","ctxNoSession":"本会话暂无可测量的投影。","ctxStale":"以下为最近一次实测快照({time}):网页请求没有会话边界,实时值请在对话中调用 ctk_context_report。","ctxTotal":"总压力 {n} tokens","ctxSurface":"表面占用 {n} tokens","ctxBudget":"提示阈值 {n} tokens","ctxOver":"已超过提示阈值,建议把大块输出改走 ctk_execute / ctk_index。","ctxUnder":"低于提示阈值,暂不需要干预。","ctxSystem":"系统提示","ctxTools":"工具 schema","ctxMessages":"会话消息","ctxEstimated":"(估算)","ctxNodes":"占用大头(前 {n} 项)","ctxLedger":"本插件节约台账","ctxLedgerLine":"门禁已拦截 {n} 次,约 {bytes} 字节未进入上下文;沙箱保留 {sandbox} 字节;已注册 {tools} 个 ctk_* 工具。","ctxKnowledge":"知识库:{docs} 文档 / {chunks} 片段({bytes} 字节)","ctxStorage":"知识库位置:{path}","tools.execute":"ctk_execute(在沙箱里跑代码,只把结论带回来)","tools.executeHint":"用于「只想要结论、不想让原始输出占满上下文」的分析型任务。","tools.executeFile":"ctk_execute_file(对某个文件跑代码,只回结论)","tools.executeFileHint":"适合读大文件后做统计或抽取,文件原文留在沙箱里。","tools.batchExecute":"ctk_batch_execute(一次跑多条命令,逐条状态)","tools.batchExecuteHint":"多条命令一次调用,只有结论回来。","tools.index":"ctk_index(把大块材料存进本地知识库)","tools.indexHint":"把长文档、日志、规范归档,之后用 ctk_search 按需取用。","tools.search":"ctk_search(按需检索知识库,取片段而非整份)","tools.searchHint":"知识库里已有的内容优先用它检索,不要整份读进来。","tools.fetchAndIndex":"ctk_fetch_and_index(抓网页并直接并入索引)","tools.fetchAndIndexHint":"抓整页网页又不让它淹没对话时用;curl/wget 会被门禁拦掉。","tools.purge":"ctk_purge(删除已索引内容,不可恢复)","tools.purgeHint":"清理知识库用;会永久删除,调用必须带 confirm:true。","tools.stats":"ctk_stats(节约台账与知识库用量)","tools.statsHint":"想知道这个插件省了多少、归档了多少时看它。","tools.doctor":"ctk_doctor(自检:运行时、临时目录、知识库往返)","tools.doctorHint":"出现异常(子进程跑不起来、索引失败)时先跑它定位。","tools.contextReport":"ctk_context_report(会话上下文占用报告)","tools.contextReportHint":"让模型可以主动查询本会话的上下文占用与构成。","restart":"需重启"};
|
|
21
|
+
const en = {"title":"Context Toolkit","description":"Run code in a sandbox, file large material into a local searchable knowledge base, and block the fetches and whole-file reads that flood a conversation. Ships a live context-occupancy panel.","hint":"These switches apply immediately, no restart: the gate is read before every tool call.","routing":"Routing gate","routingHint":"Master switch. When on, every tool call passes through the gate before running.","denyCurlWget":"Block curl / wget","denyCurlWgetHint":"Both exist to paste whole responses into the conversation; blocked here, use ctk_fetch_and_index instead.","bashNudgeMinCommandBytes":"Long-command nudge (bytes)","bashNudgeMinCommandBytesHint":"Commands longer than this get a nudge toward ctk_batch_execute. 0 disables it.","maxReadBytesBeforeAsk":"Whole-file read nudge (bytes)","maxReadBytesBeforeAskHint":"Reading a file larger than this gets a nudge toward ctk_execute_file. 0 disables it.","nudgeFetchTools":"Nudge the host fetchers too","nudgeFetchToolsHint":"Also nudge the host own web_fetch / read_page: they pull back the same whole page as a shell curl, and the shell gate never sees them. Off leaves that door unwatched.","slimOutputs":"Slim tool output","slimOutputsHint":"Compress after a tool runs and before its result reaches the model: tables keep a header, both ends and a middle sampled with original line numbers; uniform JSON is re-encoded losslessly; logs drop ordinary lines and keep diagnostics. Any lossy path stores the exact original first, retrievable with ctk_retrieve.","slimMinBytes":"Slimming floor, ordinary output (bytes)","slimMinBytesHint":"Ordinary output below this is left untouched. 0 disables slimming for it.","slimErrorMinBytes":"Slimming floor, error output (bytes)","slimErrorMinBytesHint":"Kept higher than ordinary output: debugging wants fidelity, not economy.","slimOriginalTtlMinutes":"Retention for stored originals (minutes)","slimOriginalTtlMinutesHint":"How long a stored original is kept; 0 keeps it forever.","trustedReadBasenames":"Filenames exempt from the read nudge","trustedReadBasenamesHint":"Comma separated; these may be read whole however large they are.","compressNudgeTokens":"Occupancy warning (tokens)","compressNudgeTokensHint":"At this surface occupancy the report suggests compaction. 0 disables it.","searchLimit":"Search result limit","searchLimitHint":"How many excerpts ctk_search returns per query.","snippetChars":"Excerpt size (chars)","snippetCharsHint":"Character ceiling on each excerpt from ctk_search.","fetchMaxBytes":"Per-page fetch ceiling (bytes)","fetchMaxBytesHint":"ctk_fetch_and_index keeps at most this many bytes of each page.","storageDir":"Knowledge-base root","storageDirHint":"Empty = <DSH_HOME>/context-toolkit.","toolsTitle":"Tool switches","toolsHint":"Enable or disable individual ctk_* tools.","overridden":"overridden","reset":"reset","readOnly":"This deployment is read-only.","unavailable":"The plugin is not loaded right now; it cannot be configured.","save":"Save","saving":"Saving…","saveFailed":"This deployment did not accept those values; they are kept for you to edit.","invalidNumber":"Numbers only; blank means the default.","note":"Gate switches apply immediately; reopen the page to confirm tool-switch changes.","ctxTitle":"Context occupancy","ctxLoading":"Reading…","ctxReload":"Refresh","ctxFailed":"Read failed: ","ctxNoSession":"No measurable projection in this session yet.","ctxStale":"Snapshot from the last real measurement ({time}): a page request has no session boundary; ask ctk_context_report in the conversation for a live reading.","ctxTotal":"{n} tokens total","ctxSurface":"{n} tokens on the surface","ctxBudget":"warning at {n} tokens","ctxOver":"Above the warning threshold — route bulk output through ctk_execute / ctk_index.","ctxUnder":"Below the warning threshold; nothing to do.","ctxSystem":"System prompt","ctxTools":"Tool schemas","ctxMessages":"Conversation","ctxEstimated":" (estimated)","ctxNodes":"Biggest contributors (top {n})","ctxLedger":"This plugin's ledger","ctxLedgerLine":"The gate blocked {n} calls, about {bytes} bytes kept out of context; the sandbox kept out {sandbox} bytes; {tools} ctk_* tools registered.","ctxKnowledge":"Knowledge base: {docs} documents / {chunks} chunks ({bytes} bytes)","ctxStorage":"Knowledge base at {path}","tools.execute":"ctk_execute (run code in the sandbox, return only the answer)","tools.executeHint":"For analysis where you want the conclusion, not the raw output.","tools.executeFile":"ctk_execute_file (run code over a file, return only the answer)","tools.executeFileHint":"Read a big file, compute over it, keep its bytes in the sandbox.","tools.batchExecute":"ctk_batch_execute (many commands in one call, per-command status)","tools.batchExecuteHint":"One call for many commands; only conclusions come back.","tools.index":"ctk_index (file large material into the local knowledge base)","tools.indexHint":"Archive long docs, logs or specs, then pull sections with ctk_search.","tools.search":"ctk_search (retrieve excerpts on demand, not the whole thing)","tools.searchHint":"Prefer this for material already in the knowledge base.","tools.fetchAndIndex":"ctk_fetch_and_index (fetch a page straight into the index)","tools.fetchAndIndexHint":"When a whole page would flood the conversation; curl/wget are gated.","tools.purge":"ctk_purge (delete indexed content — permanent)","tools.purgeHint":"Clears the knowledge base; permanent, so the call must confirm:true.","tools.stats":"ctk_stats (this plugin's savings ledger and knowledge usage)","tools.statsHint":"How much this plugin saved, and how much it filed.","tools.doctor":"ctk_doctor (self-check: runtimes, scratch dir, knowledge round-trip)","tools.doctorHint":"Run it first when a child process or indexing misbehaves.","tools.contextReport":"ctk_context_report (session context occupancy report)","tools.contextReportHint":"Lets the model ask for this session context occupancy and composition.","restart":"needs a restart"};
|
|
22
22
|
//#endregion
|
|
23
23
|
|
|
24
24
|
/** Fill the {name} holes in a translated sentence. */
|
|
@@ -30,8 +30,8 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
//#region fields
|
|
33
|
-
const FIELDS = [{"field":"routing","kind":"bool"},{"field":"denyCurlWget","kind":"bool"},{"field":"bashNudgeMinCommandBytes","kind":"number"},{"field":"maxReadBytesBeforeAsk","kind":"number"},{"field":"nudgeFetchTools","kind":"bool"},{"field":"compressNudgeTokens","kind":"number"},{"field":"searchLimit","kind":"number"},{"field":"snippetChars","kind":"number"},{"field":"fetchMaxBytes","kind":"number"},{"field":"trustedReadBasenames","kind":"text"},{"field":"storageDir","kind":"text"}];
|
|
34
|
-
const TOOL_FIELDS = ["execute","executeFile","batchExecute","index","search","fetchAndIndex","purge","stats","contextReport","doctor"];
|
|
33
|
+
const FIELDS = [{"field":"routing","kind":"bool"},{"field":"denyCurlWget","kind":"bool"},{"field":"bashNudgeMinCommandBytes","kind":"number"},{"field":"maxReadBytesBeforeAsk","kind":"number"},{"field":"nudgeFetchTools","kind":"bool"},{"field":"compressNudgeTokens","kind":"number"},{"field":"searchLimit","kind":"number"},{"field":"snippetChars","kind":"number"},{"field":"fetchMaxBytes","kind":"number"},{"field":"slimOutputs","kind":"bool"},{"field":"slimMinBytes","kind":"number"},{"field":"slimErrorMinBytes","kind":"number"},{"field":"slimOriginalTtlMinutes","kind":"number"},{"field":"trustedReadBasenames","kind":"text"},{"field":"storageDir","kind":"text"}];
|
|
34
|
+
const TOOL_FIELDS = ["execute","executeFile","batchExecute","index","search","retrieve","fetchAndIndex","purge","stats","contextReport","doctor"];
|
|
35
35
|
const STATUS_PATH = "/_dsh/context-toolkit/status";
|
|
36
36
|
//#endregion
|
|
37
37
|
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
24
24
|
import { runProcess, availableRuntimes, probeRuntimes, shellPlan, scratchDir } from './lib/runner.js'
|
|
25
25
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
26
26
|
import { openStore } from './lib/store.js'
|
|
27
|
+
import { slimText, DEFAULT_LIMITS as SLIM_DEFAULTS } from './lib/compress.js'
|
|
27
28
|
|
|
28
29
|
export const name = 'dsh-context-toolkit'
|
|
29
30
|
export const inject = ['tools', 'systemPrompt']
|
|
@@ -136,6 +137,11 @@ export const Config = z.object({
|
|
|
136
137
|
denyCurlWget: z.boolean().default(true).volatile().description('硬拦 curl/wget 这类洪水工具'),
|
|
137
138
|
bashNudgeMinCommandBytes: z.number().min(0).default(4096).volatile().description('长命令软引导阈值(字节)'),
|
|
138
139
|
nudgeFetchTools: z.boolean().default(true).volatile().description('把宿主取网页工具(web_fetch/read_page)也纳入整页软引导'),
|
|
140
|
+
// The gate stops bulk output at the door; this handles what is already inside.
|
|
141
|
+
slimOutputs: z.boolean().default(true).volatile().description('工具执行后瘦身:结构感知压缩 + 可逆原文,双重门(≤72% 字节且 token 严格下降)'),
|
|
142
|
+
slimMinBytes: z.number().min(0).default(1400).volatile().description('普通输出进入瘦身的最小字节(0 = 关闭)'),
|
|
143
|
+
slimErrorMinBytes: z.number().min(0).default(6000).volatile().description('错误输出的最小字节(留高,调试场景不压)'),
|
|
144
|
+
slimOriginalTtlMinutes: z.number().min(0).default(1440).volatile().description('可逆原文保留时长(分钟),0 = 永久保留'),
|
|
139
145
|
maxReadBytesBeforeAsk: z.number().min(0).default(51200).volatile().description('大文件整读软引导阈值(字节)'),
|
|
140
146
|
trustedReadBasenames: z.array(z.string())
|
|
141
147
|
.default(['package.json', 'tsconfig.json', 'cordis.patch.yml', 'cordis.yml'])
|
|
@@ -149,6 +155,7 @@ export const Config = z.object({
|
|
|
149
155
|
'tools.batchExecute': z.boolean().default(true).volatile().description('注册 ctk_batch_execute'),
|
|
150
156
|
'tools.index': z.boolean().default(true).volatile().description('注册 ctk_index'),
|
|
151
157
|
'tools.search': z.boolean().default(true).volatile().description('注册 ctk_search'),
|
|
158
|
+
'tools.retrieve': z.boolean().default(true).volatile().description('注册 ctk_retrieve(读取瘦身前存的原文)'),
|
|
152
159
|
'tools.fetchAndIndex': z.boolean().default(true).volatile().description('注册 ctk_fetch_and_index'),
|
|
153
160
|
'tools.purge': z.boolean().default(true).volatile().description('注册 ctk_purge'),
|
|
154
161
|
'tools.stats': z.boolean().default(true).volatile().description('注册 ctk_stats'),
|
|
@@ -200,6 +207,7 @@ export function apply(ctx, rawConfig) {
|
|
|
200
207
|
'tools.batchExecute': true,
|
|
201
208
|
'tools.index': true,
|
|
202
209
|
'tools.search': true,
|
|
210
|
+
'tools.retrieve': true,
|
|
203
211
|
'tools.fetchAndIndex': true,
|
|
204
212
|
'tools.purge': true,
|
|
205
213
|
'tools.stats': true,
|
|
@@ -209,6 +217,11 @@ export function apply(ctx, rawConfig) {
|
|
|
209
217
|
searchLimit: 5,
|
|
210
218
|
snippetChars: 900,
|
|
211
219
|
fetchMaxBytes: 2000000,
|
|
220
|
+
nudgeFetchTools: true,
|
|
221
|
+
slimOutputs: true,
|
|
222
|
+
slimMinBytes: SLIM_DEFAULTS.minBytes,
|
|
223
|
+
slimErrorMinBytes: SLIM_DEFAULTS.errorMinBytes,
|
|
224
|
+
slimOriginalTtlMinutes: 1440,
|
|
212
225
|
}
|
|
213
226
|
const incoming = rawConfig || {}
|
|
214
227
|
const config = {
|
|
@@ -223,6 +236,7 @@ export function apply(ctx, rawConfig) {
|
|
|
223
236
|
'tools.batchExecute': readVolatile(incoming['tools.batchExecute'], defaults['tools.batchExecute']),
|
|
224
237
|
'tools.index': readVolatile(incoming['tools.index'], defaults['tools.index']),
|
|
225
238
|
'tools.search': readVolatile(incoming['tools.search'], defaults['tools.search']),
|
|
239
|
+
'tools.retrieve': readVolatile(incoming['tools.retrieve'], defaults['tools.retrieve']),
|
|
226
240
|
'tools.fetchAndIndex': readVolatile(incoming['tools.fetchAndIndex'], defaults['tools.fetchAndIndex']),
|
|
227
241
|
'tools.purge': readVolatile(incoming['tools.purge'], defaults['tools.purge']),
|
|
228
242
|
'tools.stats': readVolatile(incoming['tools.stats'], defaults['tools.stats']),
|
|
@@ -232,6 +246,11 @@ export function apply(ctx, rawConfig) {
|
|
|
232
246
|
searchLimit: readVolatile(incoming.searchLimit, defaults.searchLimit),
|
|
233
247
|
snippetChars: readVolatile(incoming.snippetChars, defaults.snippetChars),
|
|
234
248
|
fetchMaxBytes: readVolatile(incoming.fetchMaxBytes, defaults.fetchMaxBytes),
|
|
249
|
+
nudgeFetchTools: readVolatile(incoming.nudgeFetchTools, defaults.nudgeFetchTools),
|
|
250
|
+
slimOutputs: readVolatile(incoming.slimOutputs, defaults.slimOutputs),
|
|
251
|
+
slimMinBytes: readVolatile(incoming.slimMinBytes, defaults.slimMinBytes),
|
|
252
|
+
slimErrorMinBytes: readVolatile(incoming.slimErrorMinBytes, defaults.slimErrorMinBytes),
|
|
253
|
+
slimOriginalTtlMinutes: readVolatile(incoming.slimOriginalTtlMinutes, defaults.slimOriginalTtlMinutes),
|
|
235
254
|
}
|
|
236
255
|
|
|
237
256
|
const storageDir = (typeof config.storageDir === 'string' && config.storageDir.trim())
|
|
@@ -246,6 +265,14 @@ export function apply(ctx, rawConfig) {
|
|
|
246
265
|
registered: 0,
|
|
247
266
|
bytesIndexed: 0,
|
|
248
267
|
bytesSandboxed: 0,
|
|
268
|
+
// Output slimming: what the post-execute seam removed before the bytes reached
|
|
269
|
+
// the model, and what it stored so the removal stays reversible.
|
|
270
|
+
slimmed: 0,
|
|
271
|
+
slimmedBytes: 0,
|
|
272
|
+
slimmedTokens: 0,
|
|
273
|
+
slimSkipped: 0,
|
|
274
|
+
slimStrategies: {},
|
|
275
|
+
blobsStored: 0,
|
|
249
276
|
calls: {},
|
|
250
277
|
startedAt: Date.now(),
|
|
251
278
|
}
|
|
@@ -258,6 +285,7 @@ export function apply(ctx, rawConfig) {
|
|
|
258
285
|
}
|
|
259
286
|
|
|
260
287
|
registerRoutingGate(ctx, config, stats)
|
|
288
|
+
registerOutputSlimmer(ctx, config, stats, store, log)
|
|
261
289
|
registerTools(ctx, config, stats, storageDir, log)
|
|
262
290
|
if (store) registerKnowledgeTools(ctx, config, stats, store, log)
|
|
263
291
|
else log.info('knowledge tools skipped (no store)')
|
|
@@ -441,6 +469,121 @@ function baseNameOf(p) {
|
|
|
441
469
|
return i >= 0 ? s.slice(i + 1) : s
|
|
442
470
|
}
|
|
443
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Slave the seam that runs after a tool body settles but before its result reaches
|
|
474
|
+
* the session log and the model's history.
|
|
475
|
+
*
|
|
476
|
+
* The gate above stops bulk output at the door; this handles what is already inside.
|
|
477
|
+
* Both halves exist because they catch different traffic: the gate refuses a call
|
|
478
|
+
* whose *arguments* say it will flood, while this sees the bytes that actually came
|
|
479
|
+
* back — from a tool the gate does not know, from an entry point it does not cover,
|
|
480
|
+
* or from output that was simply never predictable from the call.
|
|
481
|
+
*
|
|
482
|
+
* Two rules keep it honest. Anything lossy stores the exact original under an id
|
|
483
|
+
* before returning slim text, so a restore is byte-for-byte; and the compressor's
|
|
484
|
+
* never-worse gates decide adoption, so the ledger never claims a saving that did
|
|
485
|
+
* not happen.
|
|
486
|
+
*/
|
|
487
|
+
function registerOutputSlimmer(ctx, config, stats, store, log) {
|
|
488
|
+
if (config.slimOutputs === false) return
|
|
489
|
+
if (!store || typeof ctx.on !== 'function') return
|
|
490
|
+
|
|
491
|
+
// Our own tools are exempt. ctk_retrieve exists to hand back a full original, so
|
|
492
|
+
// slimming its result would return the same elided preview the model just paid a
|
|
493
|
+
// turn to unfold — an expand loop with no exit.
|
|
494
|
+
const OWN_TOOLS = new Set(['ctk_retrieve', 'ctk_search', 'ctk_stats', 'ctk_context_report', 'ctk_doctor'])
|
|
495
|
+
// A file the model is about to edit must arrive exactly. The gate's read branch
|
|
496
|
+
// nudges an oversized whole-file read, and when it allows one the bytes matter.
|
|
497
|
+
const EXEMPT = new Set(['read'])
|
|
498
|
+
// Freshness-sensitive results must not be replayed: a stub saying "identical to
|
|
499
|
+
// 3s ago" is only useful for stable data.
|
|
500
|
+
const DEDUPE_TTL_MS = 90000
|
|
501
|
+
|
|
502
|
+
ctx.on('tools/post-execute', async function (exec, result, next) {
|
|
503
|
+
let decision
|
|
504
|
+
try {
|
|
505
|
+
decision = await next()
|
|
506
|
+
} catch (error) {
|
|
507
|
+
log.warn('post-execute next() failed: ' + String((error && error.message) || error))
|
|
508
|
+
throw error
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
if (!decision || decision.kind !== 'accept') return decision
|
|
512
|
+
if (Object.prototype.hasOwnProperty.call(decision, 'value')) return decision
|
|
513
|
+
|
|
514
|
+
const name = String((exec && exec.name) || '')
|
|
515
|
+
if (!name || OWN_TOOLS.has(name) || EXEMPT.has(name)) return decision
|
|
516
|
+
const args = (exec && exec.arguments) || {}
|
|
517
|
+
const isError = result && result.isError === true
|
|
518
|
+
const sourceBlocks = decision.content !== undefined ? decision.content : (result && result.content)
|
|
519
|
+
if (!Array.isArray(sourceBlocks) || !sourceBlocks.length) return decision
|
|
520
|
+
|
|
521
|
+
const limits = {
|
|
522
|
+
minBytes: Number(config.slimMinBytes) || 0,
|
|
523
|
+
errorMinBytes: Number(config.slimErrorMinBytes) || 0,
|
|
524
|
+
}
|
|
525
|
+
if (limits.minBytes <= 0 && limits.errorMinBytes <= 0) return decision
|
|
526
|
+
|
|
527
|
+
let touched = false
|
|
528
|
+
const rewritten = []
|
|
529
|
+
for (const block of sourceBlocks) {
|
|
530
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') {
|
|
531
|
+
rewritten.push(block)
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
const outcome = slimText(block.text, { limits, isError })
|
|
535
|
+
if (!outcome) { stats.slimSkipped += 1; rewritten.push(block); continue }
|
|
536
|
+
|
|
537
|
+
let marker = ''
|
|
538
|
+
if (!outcome.lossless) {
|
|
539
|
+
const id = 'ctk-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
540
|
+
try {
|
|
541
|
+
store.putBlob(id, block.text)
|
|
542
|
+
} catch (error) {
|
|
543
|
+
log.warn('could not store the original for ' + id + ': ' + String((error && error.message) || error))
|
|
544
|
+
stats.slimSkipped += 1
|
|
545
|
+
rewritten.push(block)
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
stats.blobsStored += 1
|
|
549
|
+
marker = '\n\n[ctk_retrieve id=' + id + '] the exact original (' + outcome.originalBytes
|
|
550
|
+
+ ' bytes) is stored verbatim; call ctk_retrieve with this id to read it back byte-for-byte.'
|
|
551
|
+
} else {
|
|
552
|
+
marker = '\n\n[lossless: keys written once instead of per row; no information was dropped.]'
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const slimmed = outcome.text + marker
|
|
556
|
+
// The never-worse gates already ran inside slimText. The marker is added
|
|
557
|
+
// afterwards and could in principle push the whole thing back over the line,
|
|
558
|
+
// so the byte gate is applied once more to the text that will actually land.
|
|
559
|
+
if (Buffer.byteLength(slimmed, 'utf8') >= outcome.originalBytes) {
|
|
560
|
+
stats.slimSkipped += 1
|
|
561
|
+
rewritten.push(block)
|
|
562
|
+
if (marker) { try { store.purgeBlobs({ all: false }) } catch { /* blob stays, harmless */ } }
|
|
563
|
+
continue
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
stats.slimmed += 1
|
|
567
|
+
stats.slimmedBytes += outcome.originalBytes - Buffer.byteLength(slimmed, 'utf8')
|
|
568
|
+
stats.slimmedTokens += outcome.savedTokens
|
|
569
|
+
stats.slimStrategies[outcome.strategy] = (stats.slimStrategies[outcome.strategy] || 0) + 1
|
|
570
|
+
void args
|
|
571
|
+
touched = true
|
|
572
|
+
rewritten.push({ ...block, text: slimmed })
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!touched) return decision
|
|
576
|
+
return { ...decision, content: rewritten }
|
|
577
|
+
} catch (error) {
|
|
578
|
+
// A slimmer that breaks a tool call is worse than one that misses a saving.
|
|
579
|
+
log.warn('output slimming failed for ' + String((exec && exec.name) || '?') + ': ' + String((error && error.message) || error))
|
|
580
|
+
return decision
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
void DEDUPE_TTL_MS
|
|
585
|
+
}
|
|
586
|
+
|
|
444
587
|
function registerRoutingGate(ctx, config, stats) {
|
|
445
588
|
if (!config.routing) return
|
|
446
589
|
|
|
@@ -918,6 +1061,47 @@ function registerKnowledgeTools(ctx, config, stats, store, log) {
|
|
|
918
1061
|
stats.registered += 1
|
|
919
1062
|
}
|
|
920
1063
|
|
|
1064
|
+
if (toolSwitch(config, 'retrieve')) {
|
|
1065
|
+
ctx.tools.register(textTool({
|
|
1066
|
+
name: 'ctk_retrieve',
|
|
1067
|
+
description: 'Read back a compressed tool output byte-for-byte. When output was slimmed, its exact original was stored under an id and the slim text carries an id marker; pass that id here to get the full original back. Use it when the excerpt you received is not enough to act on — never guess from the slimmed text alone.',
|
|
1068
|
+
parameters: {
|
|
1069
|
+
type: 'object',
|
|
1070
|
+
properties: {
|
|
1071
|
+
id: { type: 'string', description: 'The id from a [ctk_retrieve id=…] marker.' },
|
|
1072
|
+
},
|
|
1073
|
+
required: ['id'],
|
|
1074
|
+
},
|
|
1075
|
+
render: (_a, v) => [{ type: 'text', text: v && typeof v.text === 'string' ? v.text : 'ctk_retrieve finished' }],
|
|
1076
|
+
async execute(args) {
|
|
1077
|
+
const id = String((args && args.id) || '').trim()
|
|
1078
|
+
if (!id) return { text: 'id is required', isError: true }
|
|
1079
|
+
const found = store.getBlob(id)
|
|
1080
|
+
if (!found) {
|
|
1081
|
+
const totals = store.stats()
|
|
1082
|
+
return {
|
|
1083
|
+
text: 'no stored original for "' + id + '". It may have passed its retention window or belong '
|
|
1084
|
+
+ 'to another install. The knowledge base currently holds ' + totals.blobs + ' stored original(s).',
|
|
1085
|
+
isError: true,
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
stats.calls['ctk_retrieve'] = (stats.calls['ctk_retrieve'] || 0) + 1
|
|
1089
|
+
// isError must be present on every return: the host validates a tool's output
|
|
1090
|
+
// against the declared schema and rejects the call outright when it is missing.
|
|
1091
|
+
// Omitting it here made the whole tool unusable — the marker was injected, the
|
|
1092
|
+
// original was stored, and the read-back failed with "missing required property".
|
|
1093
|
+
return {
|
|
1094
|
+
text: '## ctk_retrieve ' + id + '\n'
|
|
1095
|
+
+ '- bytes: ' + found.bytes + ' (exact original, restored verbatim)\n'
|
|
1096
|
+
+ '- stored: ' + new Date(found.createdAtMs).toISOString() + '\n\n'
|
|
1097
|
+
+ found.text,
|
|
1098
|
+
isError: false,
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
}, 'ctk_retrieve'))
|
|
1102
|
+
stats.registered += 1
|
|
1103
|
+
}
|
|
1104
|
+
|
|
921
1105
|
if (toolSwitch(config, 'search')) {
|
|
922
1106
|
ctx.tools.register(textTool({
|
|
923
1107
|
name: 'ctk_search',
|
|
@@ -1232,8 +1416,11 @@ function registerObservabilityTools(ctx, config, stats, store, storageDir, log)
|
|
|
1232
1416
|
description: 'Report this plugin\'s savings ledger and knowledge-base usage. Shows how many calls the routing gate refused, how many bytes the sandbox kept out of the conversation, how many bytes were filed, and the per-tool call counts.',
|
|
1233
1417
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
1234
1418
|
async execute() {
|
|
1235
|
-
const kb = store ? store.stats() : { documents: 0, chunks: 0, bytes: 0 }
|
|
1236
|
-
|
|
1419
|
+
const kb = store ? store.stats() : { documents: 0, chunks: 0, bytes: 0, blobs: 0, blobBytes: 0 }
|
|
1420
|
+
// Slimmed bytes are kept out too — they were removed from output before it
|
|
1421
|
+
// reached the model. Leaving them out understated the ledger by exactly the
|
|
1422
|
+
// amount this half of the plugin exists to save.
|
|
1423
|
+
const produced = stats.bytesSandboxed + stats.bytesIndexed + stats.deniedBytes + stats.slimmedBytes
|
|
1237
1424
|
const keptOut = produced
|
|
1238
1425
|
const uptimeMs = Date.now() - stats.startedAt
|
|
1239
1426
|
const lines = [
|
|
@@ -1243,12 +1430,14 @@ function registerObservabilityTools(ctx, config, stats, store, storageDir, log)
|
|
|
1243
1430
|
'- 门禁拦截 denied: ' + stats.denied + ' 次(约 ' + stats.deniedBytes + ' 字节未进入上下文)',
|
|
1244
1431
|
'- 沙箱保留 bytesSandboxed: ' + stats.bytesSandboxed + ' 字节(子进程产出但只回结论的部分)',
|
|
1245
1432
|
'- 已归档 bytesIndexed: ' + stats.bytesIndexed + ' 字节(存入知识库、未留在对话里)',
|
|
1433
|
+
'- 输出瘦身 slimmed: ' + stats.slimmed + ' 块(' + stats.slimmedBytes + ' 字节 ≈ ' + stats.slimmedTokens + ' tokens 未进入上下文;跳过的块 ' + stats.slimSkipped + ')',
|
|
1246
1434
|
'- 合计 kept out of context: ' + keptOut + ' 字节 ≈ ' + Math.round(keptOut / 4) + ' tokens(按 4 字节/token 估算)',
|
|
1247
1435
|
'',
|
|
1248
1436
|
'### 知识库 / knowledge base',
|
|
1249
1437
|
'- 文档 documents: ' + kb.documents,
|
|
1250
1438
|
'- 片段 chunks: ' + kb.chunks,
|
|
1251
1439
|
'- 原文字节 bytes: ' + kb.bytes,
|
|
1440
|
+
'- 可逆原文 blobs: ' + (kb.blobs || 0) + '(' + (kb.blobBytes || 0) + ' 字节,可用 ctk_retrieve 逐字节取回)',
|
|
1252
1441
|
'- 位置: ' + storageDir,
|
|
1253
1442
|
'',
|
|
1254
1443
|
'### 本插件注册的工具',
|
|
@@ -1320,6 +1509,9 @@ function registerObservabilityTools(ctx, config, stats, store, storageDir, log)
|
|
|
1320
1509
|
lines.push('- 门禁拦截 denied: ' + stats.denied + ' 次(' + stats.deniedBytes + ' 字节未进入上下文)')
|
|
1321
1510
|
lines.push('- 沙箱保留 bytesSandboxed: ' + stats.bytesSandboxed + ' 字节')
|
|
1322
1511
|
lines.push('- 已归档 bytesIndexed: ' + stats.bytesIndexed + ' 字节')
|
|
1512
|
+
lines.push('- 输出瘦身 slimmed: ' + stats.slimmed + ' 块(' + stats.slimmedBytes + ' 字节 ≈ ' + stats.slimmedTokens + ' tokens 未进入上下文)')
|
|
1513
|
+
lines.push('- 可逆原文 blobsStored: ' + stats.blobsStored + ' 份(可用 ctk_retrieve 逐字节取回)')
|
|
1514
|
+
if (stats.slimmed > 0) lines.push('- 瘦身策略: ' + Object.keys(stats.slimStrategies).map((k) => k + '×' + stats.slimStrategies[k]).join(' · '))
|
|
1323
1515
|
lines.push('- 已注册 registered: ' + stats.registered)
|
|
1324
1516
|
return { text: lines.join('\n'), isError: false }
|
|
1325
1517
|
},
|
package/lib/compress.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic output slimming.
|
|
3
|
+
*
|
|
4
|
+
* The gate in index.js stops bulk output at the door; this module handles what is
|
|
5
|
+
* already inside. Nothing here calls a model: every strategy is a rule, so the same
|
|
6
|
+
* input always yields the same output and a regression is reproducible.
|
|
7
|
+
*
|
|
8
|
+
* The red line is reversibility. Anything lossy is accompanied by a blob marker, and
|
|
9
|
+
* the caller stores the exact original under that id before returning the slim text,
|
|
10
|
+
* so a restore is byte-for-byte. Two gates stand between a candidate and adoption:
|
|
11
|
+
* the result must be at most keepRatioMax of the original bytes AND its estimated
|
|
12
|
+
* token count must be strictly lower. A compression that saves neither is not a
|
|
13
|
+
* compression — it is a loss with a receipt.
|
|
14
|
+
*/
|
|
15
|
+
import { Buffer } from 'node:buffer'
|
|
16
|
+
|
|
17
|
+
/** Token estimate, matching the ledger's own accounting (4 bytes per token). */
|
|
18
|
+
export function estimateTokens(text) {
|
|
19
|
+
return Math.ceil(Buffer.byteLength(String(text == null ? '' : text), 'utf8') / 4)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LIMITS = {
|
|
23
|
+
/** Ordinary output below this stays untouched. */
|
|
24
|
+
minBytes: 1400,
|
|
25
|
+
/** Error output gets a much higher floor: debugging needs fidelity, not economy. */
|
|
26
|
+
errorMinBytes: 6000,
|
|
27
|
+
/** Byte gate: an adopted replacement may not exceed this fraction of the original. */
|
|
28
|
+
keepRatioMax: 0.72,
|
|
29
|
+
/** Window shape for tabular output: verbatim head, stride-sampled middle, verbatim tail. */
|
|
30
|
+
tableHeadRows: 60,
|
|
31
|
+
tableTailRows: 40,
|
|
32
|
+
tableStrideSamples: 40,
|
|
33
|
+
/** Window shape for prose and logs. */
|
|
34
|
+
headChars: 1200,
|
|
35
|
+
tailChars: 800,
|
|
36
|
+
/** Uniform JSON arrays up to this many rows are re-encoded losslessly. */
|
|
37
|
+
losslessArrayMaxRows: 200,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Line-numbered tool output is code: the model may be about to edit exact bytes. */
|
|
41
|
+
function looksLikeNumberedSource(text) {
|
|
42
|
+
const lines = text.split('\n')
|
|
43
|
+
let numbered = 0
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (/^\s*\d+[|\u2192]/.test(line)) numbered += 1
|
|
46
|
+
}
|
|
47
|
+
return lines.length > 4 && numbered / lines.length > 0.5
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What kind of output is this? The strategy follows the shape, not a guess. */
|
|
51
|
+
export function classify(text) {
|
|
52
|
+
const source = String(text || '')
|
|
53
|
+
const lines = source.split('\n').filter((l) => l.length > 0)
|
|
54
|
+
if (!lines.length) return 'empty'
|
|
55
|
+
|
|
56
|
+
const trimmed = source.trim()
|
|
57
|
+
if ((trimmed.startsWith('[') || trimmed.startsWith('{')) && trimmed.length > 40) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(trimmed)
|
|
60
|
+
if (Array.isArray(parsed) && parsed.length >= 5 && uniformObjectKeys(parsed)) return 'json-array'
|
|
61
|
+
} catch { /* not JSON after all: fall through to the shape tests */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (looksLikeNumberedSource(source)) return 'code'
|
|
65
|
+
|
|
66
|
+
// Two separators already mean three columns: a table does not have to be wide to be
|
|
67
|
+
// a table. The first cut required three, so an ordinary three-column report fell
|
|
68
|
+
// through to the prose window and lost its line coordinates along with the saving.
|
|
69
|
+
const pipeRows = lines.filter((l) => (l.match(/\|/g) || []).length >= 2)
|
|
70
|
+
if (lines.length >= 8 && pipeRows.length / lines.length >= 0.7) return 'table'
|
|
71
|
+
|
|
72
|
+
// Deciding this before the search test matters: a timestamped log (a date, a time,
|
|
73
|
+
// then a level) matches a naive path:line pattern, and classifying it as search
|
|
74
|
+
// folds a log by file and throws away the diagnostics that fold exists to protect.
|
|
75
|
+
//
|
|
76
|
+
// The test is the *shape* — a level prefix or a timestamp on most lines. Keying it
|
|
77
|
+
// on error density instead put a log with no errors into the prose bucket, which
|
|
78
|
+
// happens to be the same window today and stops being so the moment either changes.
|
|
79
|
+
const logShaped = lines.filter((l) => /^\s*[\[(]?\d{2}:\d{2}|^\s*\[[a-z]+\]|^\s*(INFO|DEBUG|WARN|ERROR|TRACE)\b|^\s*\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/i.test(l))
|
|
80
|
+
if (lines.length >= 20 && logShaped.length / lines.length >= 0.4) return 'log'
|
|
81
|
+
|
|
82
|
+
// Search output is a path, a line number, then a colon — and the path carries no
|
|
83
|
+
// whitespace. Without that last part the timestamp above matched it.
|
|
84
|
+
const hits = lines.filter((l) => /^[^\s:][^\s:]*:\d+(?::\d+)?[:.]/.test(l))
|
|
85
|
+
if (lines.length >= 8 && hits.length / lines.length >= 0.4) return 'search'
|
|
86
|
+
|
|
87
|
+
return 'prose'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether every element of an array is a flat object with the same keys. */
|
|
91
|
+
function uniformObjectKeys(rows) {
|
|
92
|
+
const first = rows[0]
|
|
93
|
+
if (first === null || typeof first !== 'object' || Array.isArray(first)) return false
|
|
94
|
+
const keys = Object.keys(first)
|
|
95
|
+
if (!keys.length) return false
|
|
96
|
+
const signature = keys.slice().sort().join('\u0000')
|
|
97
|
+
let same = 0
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
|
|
100
|
+
if (Object.keys(row).slice().sort().join('\u0000') === signature) same += 1
|
|
101
|
+
}
|
|
102
|
+
return same / rows.length >= 0.8
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Re-encode a uniform array without losing anything.
|
|
107
|
+
*
|
|
108
|
+
* Writing the keys once instead of once per row is where the saving comes from; the
|
|
109
|
+
* values stay JSON, so the encoding is exact rather than approximate. This is the
|
|
110
|
+
* route preferred whenever it applies, because a lossless saving needs no marker and
|
|
111
|
+
* no restore.
|
|
112
|
+
*/
|
|
113
|
+
export function toonEncode(rows, name = 'rows') {
|
|
114
|
+
const keys = Object.keys(rows[0])
|
|
115
|
+
const header = name + '[' + rows.length + ']{' + keys.join(',') + '}:'
|
|
116
|
+
const body = rows.map((row) => keys.map((k) => JSON.stringify(row[k] ?? null)).join(',')).join('\n')
|
|
117
|
+
return { text: header + '\n' + body, keys }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Keep the head, stride-sample the middle with original line numbers, keep the tail. */
|
|
121
|
+
function windowTable(lines, header, limits) {
|
|
122
|
+
const { tableHeadRows: head, tableTailRows: tail, tableStrideSamples: samples } = limits
|
|
123
|
+
if (lines.length <= head + tail) return null
|
|
124
|
+
const middle = lines.slice(head, lines.length - tail)
|
|
125
|
+
const stride = Math.max(1, Math.floor(middle.length / Math.max(1, samples)))
|
|
126
|
+
const picked = []
|
|
127
|
+
for (let i = 0; i < middle.length; i += stride) {
|
|
128
|
+
picked.push('L' + (head + i + 1) + ': ' + middle[i])
|
|
129
|
+
}
|
|
130
|
+
const kept = [...lines.slice(0, head), '… ' + (middle.length - picked.length) + ' rows sampled below (line numbers are original) …',
|
|
131
|
+
...picked, '… ' + tail + ' tail rows …', ...lines.slice(lines.length - tail)]
|
|
132
|
+
return header ? [header].concat(kept) : kept
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Keep a head and a tail of a plain-text body, with an explicit elision marker. */
|
|
136
|
+
function windowText(text, limits) {
|
|
137
|
+
const { headChars, tailChars } = limits
|
|
138
|
+
if (text.length <= headChars + tailChars) return null
|
|
139
|
+
const head = text.slice(0, headChars)
|
|
140
|
+
const tail = text.slice(text.length - tailChars)
|
|
141
|
+
const dropped = text.length - headChars - tailChars
|
|
142
|
+
return head + '\n… ' + dropped + ' characters elided …\n' + tail
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Try to slim one text block.
|
|
147
|
+
*
|
|
148
|
+
* Returns an outcome only when a gate accepted it, and null when the body is too
|
|
149
|
+
* small, is code, or when every candidate failed the never-worse gates. A null is
|
|
150
|
+
* the honest answer: it means "leave this text alone".
|
|
151
|
+
*/
|
|
152
|
+
export function slimText(text, options = {}) {
|
|
153
|
+
const limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) }
|
|
154
|
+
const source = String(text == null ? '' : text)
|
|
155
|
+
const originalBytes = Buffer.byteLength(source, 'utf8')
|
|
156
|
+
const floor = options.isError ? limits.errorMinBytes : limits.minBytes
|
|
157
|
+
if (originalBytes < floor) return null
|
|
158
|
+
|
|
159
|
+
const kind = classify(source)
|
|
160
|
+
if (kind === 'code' || kind === 'empty') return null
|
|
161
|
+
|
|
162
|
+
// Candidates are ranked. When a structural strategy cannot clear the never-worse
|
|
163
|
+
// gates it is not the end of the story — a plain window almost always can, and
|
|
164
|
+
// giving up instead left large, plainly savable bodies untouched (a grep result
|
|
165
|
+
// whose files each held one match folded to the same size, and was kept intact).
|
|
166
|
+
const candidates = []
|
|
167
|
+
const lossless = false
|
|
168
|
+
|
|
169
|
+
if (kind === 'json-array') {
|
|
170
|
+
const rows = JSON.parse(source.trim())
|
|
171
|
+
if (rows.length <= limits.losslessArrayMaxRows) {
|
|
172
|
+
const encoded = toonEncode(rows, options.label || 'rows')
|
|
173
|
+
candidates.push({ text: encoded.text, strategy: 'json-array lossless re-encoding', lossless: true })
|
|
174
|
+
} else {
|
|
175
|
+
const lines = rows.map((r) => JSON.stringify(r))
|
|
176
|
+
const windowed = windowTable(lines, '# ' + rows.length + ' rows, one JSON object per line:', limits)
|
|
177
|
+
if (windowed) candidates.push({ text: windowed.join('\n'), strategy: 'json-array windowed sampling' })
|
|
178
|
+
}
|
|
179
|
+
} else if (kind === 'table') {
|
|
180
|
+
const lines = source.split('\n')
|
|
181
|
+
const windowed = windowTable(lines, '', limits)
|
|
182
|
+
if (windowed) candidates.push({ text: windowed.join('\n'), strategy: 'table window with line coordinates' })
|
|
183
|
+
} else if (kind === 'search') {
|
|
184
|
+
const lines = source.split('\n').filter((l) => l.length > 0)
|
|
185
|
+
const byFile = new Map()
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const m = line.match(/^([^:]+):(\d+)/)
|
|
188
|
+
const file = m ? m[1] : '(unlabelled)'
|
|
189
|
+
if (!byFile.has(file)) byFile.set(file, [])
|
|
190
|
+
byFile.get(file).push(line)
|
|
191
|
+
}
|
|
192
|
+
const folded = []
|
|
193
|
+
for (const [file, group] of byFile) {
|
|
194
|
+
folded.push(file + ' (' + group.length + ' matches)')
|
|
195
|
+
for (const line of group.slice(0, 12)) folded.push(' ' + line)
|
|
196
|
+
if (group.length > 12) folded.push(' … ' + (group.length - 12) + ' more matches in this file …')
|
|
197
|
+
}
|
|
198
|
+
candidates.push({ text: folded.join('\n'), strategy: 'search folded by file' })
|
|
199
|
+
} else if (kind === 'log') {
|
|
200
|
+
const lines = source.split('\n')
|
|
201
|
+
const interesting = lines.filter((l) => /error|fail|exception|assert|traceback|panic|warn/i.test(l))
|
|
202
|
+
const others = lines.filter((l) => !interesting.includes(l))
|
|
203
|
+
if (interesting.length && interesting.length < lines.length) {
|
|
204
|
+
candidates.push({
|
|
205
|
+
text: ['# ' + lines.length + ' lines, ' + others.length + ' repeat/ordinary lines folded away, diagnostics kept verbatim:']
|
|
206
|
+
.concat(interesting.slice(0, 400)).join('\n'),
|
|
207
|
+
strategy: 'log fold keeping diagnostics',
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The window is the shape-independent fallback, and it runs for every kind, not
|
|
213
|
+
// only for prose. A structural strategy that cannot clear the gates should lose to
|
|
214
|
+
// it, not cancel the attempt.
|
|
215
|
+
const windowed = windowText(source, limits)
|
|
216
|
+
if (windowed) candidates.push({ text: windowed, strategy: kind === 'prose' ? 'prose head/tail window' : 'head/tail window (fallback)' })
|
|
217
|
+
|
|
218
|
+
if (!candidates.length) return null
|
|
219
|
+
|
|
220
|
+
// The never-worse gates, applied to every candidate. Bytes first, then a strictly
|
|
221
|
+
// lower token estimate: a replacement that saves neither is not a compression, it
|
|
222
|
+
// is a loss with a receipt, and adopting it would make the ledger a liar.
|
|
223
|
+
const tokensBefore = estimateTokens(source)
|
|
224
|
+
let best = null
|
|
225
|
+
for (const item of candidates) {
|
|
226
|
+
const bytes = Buffer.byteLength(item.text, 'utf8')
|
|
227
|
+
const ratio = bytes / Math.max(1, originalBytes)
|
|
228
|
+
if (ratio > limits.keepRatioMax) continue
|
|
229
|
+
const tokensAfter = estimateTokens(item.text)
|
|
230
|
+
if (!(tokensAfter < tokensBefore)) continue
|
|
231
|
+
// First passing candidate wins, and candidates are ordered by how much they
|
|
232
|
+
// preserve rather than by how small they are. Picking the smallest byte count
|
|
233
|
+
// instead let a plain window beat the table window — which threw away the line
|
|
234
|
+
// coordinates that exist precisely so the model can ask for a middle row.
|
|
235
|
+
best = { ...item, slimBytes: bytes, tokensAfter }
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
if (!best) return null
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
text: best.text,
|
|
242
|
+
strategy: best.strategy,
|
|
243
|
+
lossless: best.lossless === true,
|
|
244
|
+
originalBytes,
|
|
245
|
+
slimBytes: best.slimBytes,
|
|
246
|
+
savedBytes: originalBytes - best.slimBytes,
|
|
247
|
+
tokensBefore,
|
|
248
|
+
tokensAfter: best.tokensAfter,
|
|
249
|
+
savedTokens: tokensBefore - best.tokensAfter,
|
|
250
|
+
}
|
|
251
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -108,6 +108,18 @@ export function openStore(storageDir) {
|
|
|
108
108
|
ord UNINDEXED,
|
|
109
109
|
tokenize = 'unicode61'
|
|
110
110
|
);
|
|
111
|
+
-- Reversible storage. The chunks table is lossy by construction (it trims and
|
|
112
|
+
-- hard-splits at maxChars), so it can never hand back the exact bytes it was
|
|
113
|
+
-- given. Compression needs the opposite guarantee: whatever replaces the text
|
|
114
|
+
-- must be restorable byte-for-byte, which means the original has to be kept
|
|
115
|
+
-- somewhere verbatim. Keeping it in this same database rather than a second
|
|
116
|
+
-- store is the point — one file, two jobs: FTS5 finds sections, blobs restore them.
|
|
117
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
118
|
+
blob_id TEXT PRIMARY KEY,
|
|
119
|
+
body BLOB NOT NULL,
|
|
120
|
+
bytes INTEGER NOT NULL,
|
|
121
|
+
created_at_ms INTEGER NOT NULL
|
|
122
|
+
);
|
|
111
123
|
`)
|
|
112
124
|
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION))
|
|
113
125
|
return makeApi(db, dbPath, storageDir)
|
|
@@ -127,7 +139,46 @@ function makeApi(db, dbPath, storageDir) {
|
|
|
127
139
|
(doc_key, path, title, bytes, mtime_ms, chunks, indexed_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
128
140
|
const findDoc = db.prepare('SELECT * FROM documents WHERE doc_key = ?')
|
|
129
141
|
|
|
142
|
+
const insertBlob = db.prepare('INSERT OR REPLACE INTO blobs(blob_id, body, bytes, created_at_ms) VALUES (?, ?, ?, ?)')
|
|
143
|
+
const selectBlob = db.prepare('SELECT body, bytes, created_at_ms FROM blobs WHERE blob_id = ?')
|
|
144
|
+
|
|
130
145
|
return {
|
|
146
|
+
/**
|
|
147
|
+
* Store an original verbatim under a caller-chosen id.
|
|
148
|
+
*
|
|
149
|
+
* The bytes go in as a BLOB and come back as a Buffer, so nothing between the
|
|
150
|
+
* two ends re-encodes them: a restore is only meaningful if it is exact, and
|
|
151
|
+
* TEXT round-tripping is one surprise away from not being.
|
|
152
|
+
*/
|
|
153
|
+
putBlob(id, text) {
|
|
154
|
+
const blobId = String(id || '')
|
|
155
|
+
if (!blobId) throw new Error('putBlob needs an id')
|
|
156
|
+
const body = Buffer.from(String(text == null ? '' : text), 'utf8')
|
|
157
|
+
insertBlob.run(blobId, body, body.length, Date.now())
|
|
158
|
+
return { id: blobId, bytes: body.length }
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Read a stored original back byte-for-byte; null when the id is unknown. */
|
|
162
|
+
getBlob(id) {
|
|
163
|
+
const row = selectBlob.get(String(id || ''))
|
|
164
|
+
if (!row) return null
|
|
165
|
+
return { id: String(id), text: Buffer.from(row.body).toString('utf8'), bytes: Number(row.bytes) || 0, createdAtMs: Number(row.created_at_ms) || 0 }
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** Drop stored originals older than the given age, or all of them. */
|
|
169
|
+
purgeBlobs({ olderThanMs = 0, all = false } = {}) {
|
|
170
|
+
if (all) {
|
|
171
|
+
const n = db.prepare('SELECT COUNT(*) AS n FROM blobs').get().n
|
|
172
|
+
db.exec('DELETE FROM blobs;')
|
|
173
|
+
return { removed: Number(n) || 0 }
|
|
174
|
+
}
|
|
175
|
+
if (!(olderThanMs > 0)) return { removed: 0 }
|
|
176
|
+
const cutoff = Date.now() - olderThanMs
|
|
177
|
+
const n = db.prepare('SELECT COUNT(*) AS n FROM blobs WHERE created_at_ms < ?').get(cutoff).n
|
|
178
|
+
db.prepare('DELETE FROM blobs WHERE created_at_ms < ?').run(cutoff)
|
|
179
|
+
return { removed: Number(n) || 0 }
|
|
180
|
+
},
|
|
181
|
+
|
|
131
182
|
/** File one document, replacing any earlier version of the same path. */
|
|
132
183
|
indexDocument({ path, title, text, mtimeMs }) {
|
|
133
184
|
const abs = normalizeTarget(path)
|
|
@@ -231,7 +282,10 @@ function makeApi(db, dbPath, storageDir) {
|
|
|
231
282
|
const docs = db.prepare('SELECT COUNT(*) AS n FROM documents').get().n
|
|
232
283
|
const chunks = db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n
|
|
233
284
|
const bytes = db.prepare('SELECT COALESCE(SUM(bytes),0) AS n FROM documents').get().n
|
|
234
|
-
|
|
285
|
+
const blobs = db.prepare('SELECT COUNT(*) AS n FROM blobs').get().n
|
|
286
|
+
const blobBytes = db.prepare('SELECT COALESCE(SUM(bytes),0) AS n FROM blobs').get().n
|
|
287
|
+
return { documents: Number(docs) || 0, chunks: Number(chunks) || 0, bytes: Number(bytes) || 0,
|
|
288
|
+
blobs: Number(blobs) || 0, blobBytes: Number(blobBytes) || 0, dbPath }
|
|
235
289
|
},
|
|
236
290
|
|
|
237
291
|
close() { try { db.close() } catch { /* already closed */ } },
|
package/locale/en.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
"tools.indexHint": "Archive long docs, logs or specs, then pull sections with ctk_search.",
|
|
69
69
|
"tools.search": "ctk_search (retrieve excerpts on demand, not the whole thing)",
|
|
70
70
|
"tools.searchHint": "Prefer this for material already in the knowledge base.",
|
|
71
|
+
"tools.retrieve": "ctk_retrieve (read back a stored original byte-for-byte)",
|
|
72
|
+
"tools.retrieveHint": "When slimmed text carries a [ctk_retrieve id=...] marker, the exact original was stored; call this to get it back rather than guessing from the condensed text.",
|
|
71
73
|
"tools.fetchAndIndex": "ctk_fetch_and_index (fetch a page straight into the index)",
|
|
72
74
|
"tools.fetchAndIndexHint": "When a whole page would flood the conversation; curl/wget are gated.",
|
|
73
75
|
"tools.purge": "ctk_purge (delete indexed content — permanent)",
|
package/locale/zh.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
"tools.indexHint": "把长文档、日志、规范归档,之后用 ctk_search 按需取用。",
|
|
69
69
|
"tools.search": "ctk_search(按需检索知识库,取片段而非整份)",
|
|
70
70
|
"tools.searchHint": "知识库里已有的内容优先用它检索,不要整份读进来。",
|
|
71
|
+
"tools.retrieve": "ctk_retrieve(取回瘦身前存的逐字节原文)",
|
|
72
|
+
"tools.retrieveHint": "收到带有 [ctk_retrieve id=…] 标记的瘦身文本时,说明原文被逐字节存下了;片段不够用就调它取回全文,不要凭精简文本猜。",
|
|
71
73
|
"tools.fetchAndIndex": "ctk_fetch_and_index(抓网页并直接并入索引)",
|
|
72
74
|
"tools.fetchAndIndexHint": "抓整页网页又不让它淹没对话时用;curl/wget 会被门禁拦掉。",
|
|
73
75
|
"tools.purge": "ctk_purge(删除已索引内容,不可恢复)",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-context-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DeepSeek Harness bundle: native ctk_* sandbox tools, a routing gate that keeps bulk output out of the transcript, and a local full-text knowledge base. No third-party runtime dependencies.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|