dsh-session-grep 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1 — 2026-11-09
4
+
5
+ - Fixed: cross-session results were unreachable. Search stopped as soon as the row limit was reached, so the longest conversation (scanned first) filled the whole list. Hits are now collected per session and merged round-robin by newest match.
6
+ - Fixed: one row per event with an "N matches" count, instead of repeating the same event for every occurrence.
7
+ - Fixed: selecting a row could be swallowed by the input blur; selection now runs on `mousedown`.
8
+ - Fixed: side threads (`Side: …`) open through their durable `subagent.address` when a bare session id does not navigate.
9
+ - Changed: rows show the session suffix; a hit already on screen only scrolls and highlights instead of navigating.
10
+ - Added: `navFailed` string in both locales.
11
+
12
+ ## 1.0.0 — 2026-11-09
13
+
14
+ First release.
15
+
16
+ - Host service `dsh-session-grep` with `GET /dsh-session-grep/api/search`, `/sessions` and `/health`.
17
+ - Decodes the multi-frame `session.v4.jsonl.zstd` log format and caches parses by `mtime + size`.
18
+ - Literal, case-insensitive substring matching (Chinese fragments included), 3 hits per message, 40 rows max.
19
+ - Client search pill in `conversation.header.leading`: debounced query, keyboard navigation, match highlight, open-and-flash on select.
20
+ - Optional tool call / tool result / reasoning coverage.
21
+ - English and Chinese interface strings.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chenshanhan0711
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # dsh-session-grep
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Substring search across every message in a DeepSeek Harness workspace — the kind of search where `搜索输出` finds the sentence `…只能搜索输出`, and `插件` finds words inside longer words.
4
+
5
+ ## Why this exists
6
+
7
+ The built-in session search (sidebar `Ctrl+K`) runs on an SQLite FTS5 index created with the `unicode61` tokenizer. That tokenizer matches **whole tokens and phrases**, not arbitrary substrings:
8
+
9
+ - Contiguous Chinese has no spaces, so an entire clause is one token. `搜索输出` is a fragment of the token `只能搜索输出` and therefore matches nothing.
10
+ - The upstream documentation says it plainly: *"`unicode61` tokenizer matches tokens and phrases rather than arbitrary substrings"* (`@deepseek-ai/dsh-session-query-sqlite`, README line 63).
11
+
12
+ This plugin keeps its own literal index instead: it reads the session logs, decompresses them and does a case-insensitive `indexOf`. Any fragment matches, Chinese included.
13
+
14
+ ## What it looks like
15
+
16
+ - A **搜索 / Search** pill in the conversation header (the same slot the session title sits next to).
17
+ - Click it, type, and get one row per matching message: role, title, session suffix, snippet with the match highlighted.
18
+ - Results rotate across sessions, so one very long conversation cannot crowd out the others.
19
+ - `↑` `↓` to move, `Enter` to open, `Esc` to close.
20
+ - Picking a row opens that conversation and flashes the matched text for ~2.6 s. A hit in the conversation already on screen only scrolls and highlights — no pointless navigation.
21
+ - Toggle **工具输出 / Tool output** to also search tool calls, tool results and reasoning.
22
+ - Results are scoped to the current workspace, 40 rows max.
23
+
24
+ ## Install
25
+
26
+ From npm:
27
+
28
+ ```
29
+ dsh plugin --profile <profile> add dsh-session-grep
30
+ ```
31
+
32
+ From a clone:
33
+
34
+ ```
35
+ git clone https://github.com/chenshanhan0711/dsh-session-grep
36
+ dsh plugin --profile <profile> add ./dsh-session-grep
37
+ ```
38
+
39
+ Or in the app: **Plugins → Add plugin** and point it at that folder. The bundle adds one Host service and one client slot; the pill appears after a restart.
40
+
41
+ Remove it the same way you removed any plugin; it never touches the DSH install directory.
42
+
43
+ ## How it works
44
+
45
+ **Host** (`index.js`)
46
+
47
+ - Finds logs at `$DSH_HOME/sessions/<cwd-key>/<session-id>/session.v4.jsonl.zstd`.
48
+ - Each file is a sequence of concatenated zstd frames with no length prefix: split on the zstd magic, inflate each frame, and merge a slice into the next candidate when inflation fails (so a stray magic inside compressed bytes costs a retry, not an event).
49
+ - Parses the JSONL, extracts text per event type (`user/message`, `assistant/message`, optionally `tool/call` / `tool/result` / reasoning), and caches parses by `mtime + size` (max 64 sessions).
50
+ - One hit per event with an "N matches" count; each session collects up to `max(10, limit/2)` first, then the sessions are merged round-robin by their newest match — a long conversation cannot swallow the list.
51
+ - Serves three read-only routes under `/dsh-session-grep/api`: `search`, `sessions`, `health`.
52
+ - Scanning a 900-event / 3 MB session takes ~20 ms; a query over it is single-digit milliseconds.
53
+
54
+ **Client** (`client.js`)
55
+
56
+ - Registers into the `conversation.header.leading` slot.
57
+ - 200 ms debounce, one in-flight request at a time (the previous one is aborted).
58
+ - Results panel is `position: fixed` from the input rect, so it is never clipped by the header.
59
+ - Clicking a result calls `uiWorkspace.openSession(...)`; for a side thread it first retains the session and opens through the durable `subagent.address` from its snapshot. If the view has not switched after 1 s it retries once, and only then reports failure.
60
+ - Then it walks the rendered text nodes to wrap the match in a `<mark>`, scrolling it into view; if the message is not rendered yet it retries for ~9 s and then says so.
61
+ - Selection runs on `mousedown`, not `click`, so a blur cannot unmount the row before the handler runs.
62
+
63
+ ## Limits
64
+
65
+ - Matches **text**, not rendered Markdown: a match split across `**bold**` boundaries may be found by the scanner but not highlighted on screen.
66
+ - Tool output, reasoning and JSON payloads are off by default; switch on **Tool output** to include them.
67
+ - Only sessions of the current workspace directory are scanned.
68
+ - Live sessions are re-parsed when the file changes; there is no incremental tailing yet.
69
+ - Very large histories are scanned fully per query (no persistent on-disk index), which is why results cap at 40.
70
+ - Changing the **Host** half needs an app restart; changing only `client.js` needs a page refresh (the Host hot-loads client bundles).
71
+ - Archived sessions still match; the app refuses to open them.
72
+
73
+ ## Privacy
74
+
75
+ Zero network requests. The Host reads local session files and answers its own loopback route; the client only calls that route. No telemetry, no remote endpoints, no hardcoded credentials.
76
+
77
+ ## License
78
+
79
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,80 @@
1
+ # dsh-session-grep · 对话正文搜索
2
+
3
+ 在工作区所有对话正文里做**真正的子串搜索**:输入 `搜索输出` 能命中「…只能搜索输出」这句话,输入 `插件` 能命中「插件市场」这类词中间的字。
4
+
5
+ ## 为什么需要它
6
+
7
+ DSH 内置的会话搜索(侧栏 `Ctrl+K`)走的是 SQLite FTS5 索引,分词器是 `unicode61`。它匹配的是**整 token 和短语**,不是任意子串:
8
+
9
+ - 中文没有空格,一整句是一个 token。`搜索输出` 只是 token `只能搜索输出` 的中间片段,所以一条都搜不到;
10
+ - 上游文档写得很清楚:*"`unicode61` tokenizer matches tokens and phrases rather than arbitrary substrings"*(`@deepseek-ai/dsh-session-query-sqlite` README 第 63 行)。
11
+
12
+ 本插件不用那套索引:它直接读会话日志、解压、做大小写不敏感的 `indexOf`,所以任何片段都能命中,中文也不例外。
13
+
14
+ ## 使用
15
+
16
+ - 对话页头部(会话标题左侧)有一个 **搜索** 胶囊按钮。
17
+ - 点开输入关键词,每条命中的消息一行:角色、会话标题、会话尾号、命中的片段(高亮)。
18
+ - 结果按会话轮转排列:同一个超长对话不会占满整个列表,每个对话都能看到。
19
+ - `↑` `↓` 选择,`Enter` 打开,`Esc` 关闭。
20
+ - 选中一行会切到那个会话,并把命中的文字**闪一下高亮**约 2.6 秒。
21
+ - 命中就在当前对话里时不会做多余的跳转,只滚动并高亮。
22
+ - 打开「工具输出」开关,可把工具调用、工具结果和推理内容也纳入搜索。
23
+ - 结果只覆盖当前工作区,最多 40 条。
24
+
25
+ ## 安装
26
+
27
+ 从 npm:
28
+
29
+ ```
30
+ dsh plugin --profile <profile> add dsh-session-grep
31
+ ```
32
+
33
+ 从仓库:
34
+
35
+ ```
36
+ git clone https://github.com/chenshanhan0711/dsh-session-grep
37
+ dsh plugin --profile <profile> add ./dsh-session-grep
38
+ ```
39
+
40
+ 或在应用内 **插件 → 添加插件** 选择该目录。bundle 只加一个 Host 服务和一个客户端插槽,重启后搜索胶囊出现。
41
+
42
+ 卸载:与其他插件相同,不会改动 DSH 安装目录。
43
+
44
+ ## 实现
45
+
46
+ **Host(`index.js`)**
47
+
48
+ - 日志位置:`$DSH_HOME/sessions/<cwd-key>/<session-id>/session.v4.jsonl.zstd`。
49
+ - 文件是多个 zstd frame 顺序拼接、无长度前缀:按 zstd magic 切分,逐段解压;解压失败说明切点落在压缩数据内部,把该段并入下一段重试(不会丢事件)。
50
+ - 解析 JSONL,按事件类型取文本(`user/message`、`assistant/message`,可选 `tool/call`、`tool/result`、reasoning),并按 `mtime + size` 缓存解析结果(上限 64 个会话)。
51
+ - 每个事件只贡献一条命中,附「共 N 处」计数;每个会话先各自收集(上限 `max(10, limit/2)`),再按「会话内最新命中时间」轮转合并 —— 长对话无法挤掉其他对话。
52
+ - 在 `/dsh-session-grep/api` 下暴露三个只读接口:`search`、`sessions`、`health`。
53
+ - 扫描一个 900 事件 / 3 MB 的会话约 20 ms,单次查询个位数毫秒。
54
+
55
+ **Client(`client.js`)**
56
+
57
+ - 注册到 `conversation.header.leading` 插槽。
58
+ - 200 ms 防抖,同一时刻只有一个请求在飞(新的会中止旧的)。
59
+ - 结果面板用输入框的矩形做 `position: fixed` 定位,不会被头部裁掉。
60
+ - 点击结果调用 `uiWorkspace.openSession(...)`;如果是子会话(`Side:` 线程),先用 `ctx.sessions.retain()` 拿到快照里的 `subagent.address` 再按地址打开。打开后 1 秒仍未切换就重试一次,仍失败才提示。
61
+ - 命中文字在已渲染文本节点里被包进 `<mark>` 并滚动到可见;消息还没渲染就重试约 9 秒,最后给出提示。
62
+ - 选中用 `mousedown` 而不是 `click`,避免输入框失焦先把面板关掉。
63
+
64
+ ## 限制
65
+
66
+ - 搜的是**文本**,不是渲染后的 Markdown:命中跨在 `**粗体**` 两侧时,扫描能命中,但屏幕上可能高亮不到。
67
+ - 工具输出、推理和 JSON 需要手动打开开关。
68
+ - 只扫描当前工作区目录下的会话。
69
+ - 会话文件变化会重新解析;暂未做增量 tail。
70
+ - 每次查询全量扫描、不落盘索引,所以结果上限 40 条。
71
+ - 改了 **Host 半边** 的代码需要重启应用;只改 `client.js` 刷新页面即可(Host 会热载客户端 bundle)。
72
+ - 会话列表里被归档的对话仍能被搜到,点开时会被拒绝打开。
73
+
74
+ ## 隐私
75
+
76
+ **零网络请求**。Host 只读本地会话文件并响应自己的 loopback 路由,客户端只调这个路由;无遥测、无外部端点、无硬编码密钥。
77
+
78
+ ## 许可证
79
+
80
+ MIT
package/client.js ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * dsh-session-grep — Client half.
3
+ *
4
+ * A search pill in the conversation header. Types into the Host's literal
5
+ * substring index and shows one row per matching message; picking a row opens
6
+ * that conversation and flashes the matched text in place.
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-session-grep',
10
+ factory(require) {
11
+ const React = require('react');
12
+ const h = React.createElement;
13
+
14
+ const NS = 'dsh-session-grep';
15
+ const API = '/dsh-session-grep/api';
16
+ const STYLE_ID = 'dsh-session-grep-style';
17
+ const LIMIT = 40;
18
+
19
+ const DICT = {
20
+ en: {
21
+ 'meta.title': 'Conversation grep',
22
+ 'meta.description': 'Substring search across every message in this workspace — fragments inside a sentence match too.',
23
+ search: 'Search',
24
+ opened: 'Switched to',
25
+ placeholder: 'Search all messages, e.g. 搜索输出',
26
+ hint: 'Matches any fragment, including inside a Chinese sentence.',
27
+ empty: 'No message contains that fragment.',
28
+ failed: 'Search failed',
29
+ tools: 'Tool output',
30
+ hits: 'hits',
31
+ ms: 'ms',
32
+ truncated: 'stopped at limit',
33
+ noHistory: 'No conversation history found in this workspace.',
34
+ notLocated: 'Opened, but the text is not rendered yet — scroll up in that conversation.',
35
+ navFailed: 'Could not open that conversation',
36
+ loading: 'Searching…',
37
+ user: 'you',
38
+ assistant: 'assistant',
39
+ tool: 'tool',
40
+ },
41
+ zh: {
42
+ 'meta.title': '对话正文搜索',
43
+ 'meta.description': '在工作区全部对话正文里做真正的子串搜索,中文句子中间的片段也能命中。',
44
+ search: '搜索',
45
+ opened: '已切换到',
46
+ placeholder: '搜索所有对话正文,例如:搜索输出',
47
+ hint: '任意片段都能命中,包括中文句子中间的字。',
48
+ empty: '没有消息包含这个片段。',
49
+ failed: '搜索失败',
50
+ tools: '工具输出',
51
+ hits: '处命中',
52
+ ms: '毫秒',
53
+ truncated: '已达条数上限',
54
+ noHistory: '这个工作区还没有对话记录。',
55
+ notLocated: '已打开对话,但这句还没渲染出来——往上翻一下。',
56
+ navFailed: '没能打开那个对话',
57
+ loading: '搜索中…',
58
+ user: '我',
59
+ assistant: '助手',
60
+ tool: '工具',
61
+ },
62
+ };
63
+
64
+ const runtime = {
65
+ locale: null,
66
+ notify: () => {},
67
+ openSession: () => null,
68
+ currentSession: () => null,
69
+ t: (key) => DICT.en[key] ?? key,
70
+ };
71
+
72
+ // Never read the locale service while React renders: capture it at apply
73
+ // time and keep it fresh through the locale subscription instead.
74
+ let activeLocale = 'en';
75
+
76
+ function translate(key) {
77
+ try {
78
+ const active = String(activeLocale).toLowerCase();
79
+ const dict = active.startsWith('zh') ? DICT.zh : DICT.en;
80
+ return dict[key] ?? DICT.en[key] ?? key;
81
+ } catch {
82
+ return DICT.en[key] ?? key;
83
+ }
84
+ }
85
+
86
+ const ICON = h(
87
+ 'svg',
88
+ { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
89
+ h('circle', { cx: 7, cy: 7, r: 4.6, stroke: 'currentColor', strokeWidth: 1.4 }),
90
+ h('path', { d: 'M10.6 10.6 L14 14', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }),
91
+ );
92
+
93
+ function ensureStyle() {
94
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;
95
+ const style = document.createElement('style');
96
+ style.id = STYLE_ID;
97
+ style.textContent = `
98
+ .dsg-root{position:relative;display:inline-flex;align-items:center;min-width:0}
99
+ .dsg-trigger{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 11px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;cursor:pointer;transition:color .12s,border-color .12s}
100
+ .dsg-trigger:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
101
+ .dsg-field{display:flex;align-items:center;gap:6px;height:30px;width:340px;max-width:46vw;padding:0 9px;border:1px solid var(--dsw-alias-brand-primary);border-radius:9px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary)}
102
+ .dsg-field input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit}
103
+ .dsg-kbd{font-size:10px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:4px;padding:1px 4px}
104
+ .dsg-panel{position:fixed;z-index:80;display:flex;flex-direction:column;max-height:min(62vh,520px);border:1px solid var(--dsw-alias-border-l1);border-radius:11px;background:var(--dsw-alias-bg-overlay);box-shadow:0 14px 36px rgba(0,0,0,.26);overflow:hidden}
105
+ .dsg-bar{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}
106
+ .dsg-chip{cursor:pointer;user-select:none;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);white-space:nowrap}
107
+ .dsg-chip[data-on="1"]{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}
108
+ .dsg-list{overflow:auto;padding:4px}
109
+ .dsg-item{display:flex;flex-direction:column;gap:3px;padding:7px 9px;border-radius:7px;cursor:pointer;border-left:2px solid transparent}
110
+ .dsg-item[data-active="1"],.dsg-item:hover{background:var(--dsw-alias-bg-layer-1);border-left-color:var(--dsw-alias-brand-primary)}
111
+ .dsg-meta{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
112
+ .dsg-title{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
113
+ .dsg-role{flex:none;padding:0 5px;border-radius:4px;border:1px solid var(--dsw-alias-border-l1);font-size:10px;line-height:15px}
114
+ .dsg-sid{flex:none;margin-left:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.75;letter-spacing:.02em}
115
+ .dsg-snip{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary);word-break:break-word}
116
+ .dsg-snip mark{background:transparent;color:var(--dsw-alias-brand-primary);font-weight:600}
117
+ .dsg-empty,.dsg-err{padding:18px 12px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);text-align:center}
118
+ .dsg-err{color:var(--dsw-alias-state-error-primary)}
119
+ .dsg-foot{display:flex;justify-content:space-between;gap:10px;padding:5px 10px;border-top:1px solid var(--dsw-alias-border-l1);font-size:10px;color:var(--dsw-alias-label-secondary)}
120
+ .dsg-toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:90;max-width:70vw;padding:7px 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.22)}
121
+ mark.dsg-flash{background:var(--dsw-alias-brand-primary);color:#fff;border-radius:3px;padding:0 1px}
122
+ @media (max-width:720px){.dsg-trigger-label{display:none}}
123
+ `;
124
+ document.head.appendChild(style);
125
+ }
126
+
127
+ function removeStyle() {
128
+ document.getElementById(STYLE_ID)?.remove();
129
+ }
130
+
131
+ // ---------------------------------------------------------------- locate
132
+
133
+ function clearFlash() {
134
+ for (const mark of Array.from(document.querySelectorAll('mark.dsg-flash'))) {
135
+ try {
136
+ mark.replaceWith(document.createTextNode(mark.textContent || ''));
137
+ } catch {
138
+ mark.remove();
139
+ }
140
+ }
141
+ }
142
+
143
+ /** Wrap the first visible occurrence of `target` in a flashing mark. */
144
+ function flashText(target) {
145
+ const needle = String(target || '').trim();
146
+ if (needle.length < 2 || typeof document === 'undefined') return false;
147
+ const lowered = needle.toLowerCase();
148
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
149
+ acceptNode(node) {
150
+ const parent = node.parentElement;
151
+ if (!parent) return NodeFilter.FILTER_REJECT;
152
+ if (parent.closest('.dsg-root') || parent.closest('mark.dsg-flash')) return NodeFilter.FILTER_REJECT;
153
+ if (/^(SCRIPT|STYLE|TEXTAREA|INPUT|NOSCRIPT)$/.test(parent.tagName)) return NodeFilter.FILTER_REJECT;
154
+ return (node.nodeValue || '').length >= needle.length ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
155
+ },
156
+ });
157
+ let node = walker.nextNode();
158
+ while (node) {
159
+ const text = node.nodeValue;
160
+ const at = text.toLowerCase().indexOf(lowered);
161
+ if (at !== -1) {
162
+ try {
163
+ const range = document.createRange();
164
+ range.setStart(node, at);
165
+ range.setEnd(node, at + needle.length);
166
+ const mark = document.createElement('mark');
167
+ mark.className = 'dsg-flash';
168
+ range.surroundContents(mark);
169
+ mark.scrollIntoView({ block: 'center', behavior: 'smooth' });
170
+ setTimeout(clearFlash, 2600);
171
+ return true;
172
+ } catch {
173
+ // cross-element selection: keep looking
174
+ }
175
+ }
176
+ node = walker.nextNode();
177
+ }
178
+ return false;
179
+ }
180
+
181
+ function locateAfterOpen(target) {
182
+ let tries = 0;
183
+ const timer = setInterval(() => {
184
+ tries += 1;
185
+ if (flashText(target)) {
186
+ clearInterval(timer);
187
+ } else if (tries >= 30) {
188
+ clearInterval(timer);
189
+ runtime.notify?.();
190
+ }
191
+ }, 300);
192
+ }
193
+
194
+ // ------------------------------------------------------------- component
195
+
196
+ function GrepWidget() {
197
+ const [open, setOpen] = React.useState(false);
198
+ const [text, setText] = React.useState('');
199
+ const [tools, setTools] = React.useState(false);
200
+ const [result, setResult] = React.useState({ status: 'idle', hits: [], tookMs: 0, truncated: false, scanned: 0 });
201
+ const [cursor, setCursor] = React.useState(0);
202
+ const [pos, setPos] = React.useState(null);
203
+ const [toast, setToast] = React.useState('');
204
+ const inputRef = React.useRef(null);
205
+ const rootRef = React.useRef(null);
206
+ const abortRef = React.useRef(null);
207
+ const t = runtime.t;
208
+
209
+ // 200 ms debounce, one in-flight request at a time.
210
+ const [query, setQuery] = React.useState('');
211
+ React.useEffect(() => {
212
+ const timer = setTimeout(() => setQuery(text.trim()), 200);
213
+ return () => clearTimeout(timer);
214
+ }, [text]);
215
+
216
+ React.useEffect(() => {
217
+ if (!open) return;
218
+ if (!query) {
219
+ setResult({ status: 'idle', hits: [], tookMs: 0, truncated: false, scanned: 0 });
220
+ return;
221
+ }
222
+ abortRef.current?.abort();
223
+ const ac = new AbortController();
224
+ abortRef.current = ac;
225
+ setResult((prev) => ({ ...prev, status: 'loading' }));
226
+ const url = `${API}/search?q=${encodeURIComponent(query)}&limit=${LIMIT}&context=70&tools=${tools ? 1 : 0}`;
227
+ fetch(url, { signal: ac.signal, headers: { accept: 'application/json' } })
228
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
229
+ .then((data) => {
230
+ setResult({
231
+ status: 'done',
232
+ hits: Array.isArray(data.hits) ? data.hits : [],
233
+ tookMs: data.tookMs ?? 0,
234
+ truncated: !!data.truncated,
235
+ scanned: data.scannedEvents ?? 0,
236
+ });
237
+ setCursor(0);
238
+ })
239
+ .catch((error) => {
240
+ if (error?.name === 'AbortError') return;
241
+ setResult({ status: 'error', hits: [], tookMs: 0, truncated: false, scanned: 0, message: String(error?.message ?? error) });
242
+ });
243
+ return () => ac.abort();
244
+ }, [query, tools, open]);
245
+
246
+ const place = React.useCallback(() => {
247
+ const el = inputRef.current;
248
+ if (!el) return;
249
+ const rect = el.getBoundingClientRect();
250
+ const width = Math.max(rect.width, 440);
251
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
252
+ setPos({ left, top: rect.bottom + 6, width });
253
+ }, []);
254
+
255
+ React.useEffect(() => {
256
+ if (!open) return;
257
+ place();
258
+ const onScroll = () => place();
259
+ window.addEventListener('resize', onScroll);
260
+ window.addEventListener('scroll', onScroll, true);
261
+ const focus = setTimeout(() => inputRef.current?.focus(), 0);
262
+ return () => {
263
+ clearTimeout(focus);
264
+ window.removeEventListener('resize', onScroll);
265
+ window.removeEventListener('scroll', onScroll, true);
266
+ };
267
+ }, [open, place]);
268
+
269
+ React.useEffect(() => {
270
+ if (!open) return;
271
+ const onDown = (event) => {
272
+ if (!rootRef.current?.contains(event.target)) setOpen(false);
273
+ };
274
+ document.addEventListener('mousedown', onDown);
275
+ return () => document.removeEventListener('mousedown', onDown);
276
+ }, [open]);
277
+
278
+ React.useEffect(() => {
279
+ runtime.notify = () => setToast(translate('notLocated'));
280
+ return () => {
281
+ runtime.notify = () => {};
282
+ };
283
+ }, []);
284
+
285
+ React.useEffect(() => {
286
+ if (!toast) return;
287
+ const timer = setTimeout(() => setToast(''), 4200);
288
+ return () => clearTimeout(timer);
289
+ }, [toast]);
290
+
291
+ React.useEffect(() => {
292
+ clearFlash();
293
+ return () => {
294
+ abortRef.current?.abort();
295
+ clearFlash();
296
+ };
297
+ }, []);
298
+
299
+ const choose = React.useCallback((hit) => {
300
+ if (!hit) return;
301
+ setOpen(false);
302
+ clearFlash();
303
+ const wanted = String(hit.sessionId || '');
304
+ const target = (hit.match && hit.match.length >= 2 ? hit.match : hit.snippet || '').trim();
305
+ const current = runtime.currentSession();
306
+ if (current && current === wanted) {
307
+ if (target) locateAfterOpen(target);
308
+ return;
309
+ }
310
+ const failure = runtime.openSession(wanted);
311
+ if (failure) {
312
+ setToast(`${t('failed')}: ${failure}`);
313
+ return;
314
+ }
315
+ setTimeout(() => {
316
+ if (runtime.currentSession() === wanted) {
317
+ if (target) locateAfterOpen(target);
318
+ return;
319
+ }
320
+ // A subagent (side) Session is only addressable through its durable
321
+ // direct-parent address, not by bare id: resolve it and try once more.
322
+ runtime.openByAddress(wanted).then((opened) => {
323
+ if (!opened) {
324
+ setToast(`${t('navFailed')} ${wanted.slice(-6)}`);
325
+ return;
326
+ }
327
+ setTimeout(() => {
328
+ if (target) locateAfterOpen(target);
329
+ }, 600);
330
+ });
331
+ }, 1000);
332
+ }, []);
333
+
334
+ const onKeyDown = (event) => {
335
+ if (event.key === 'Escape') {
336
+ event.preventDefault();
337
+ setOpen(false);
338
+ inputRef.current?.blur();
339
+ } else if (event.key === 'ArrowDown') {
340
+ event.preventDefault();
341
+ setCursor((c) => Math.min(c + 1, Math.max(0, result.hits.length - 1)));
342
+ } else if (event.key === 'ArrowUp') {
343
+ event.preventDefault();
344
+ setCursor((c) => Math.max(c - 1, 0));
345
+ } else if (event.key === 'Enter' && !event.nativeEvent?.isComposing) {
346
+ event.preventDefault();
347
+ choose(result.hits[cursor]);
348
+ }
349
+ };
350
+
351
+ const roleLabel = (role) => (role === 'user' ? t('user') : role === 'assistant' ? t('assistant') : role === 'tool' ? t('tool') : role);
352
+
353
+ const rows = result.hits.map((hit, index) => {
354
+ const start = typeof hit.matchStart === 'number' && hit.matchStart >= 0 ? hit.matchStart : -1;
355
+ const end = start >= 0 ? start + (hit.matchLength || 0) : -1;
356
+ const snippet = hit.snippet || '';
357
+ return h(
358
+ 'div',
359
+ {
360
+ key: `${hit.sessionId}:${hit.seq}:${index}`,
361
+ className: 'dsg-item',
362
+ 'data-active': index === cursor ? '1' : '0',
363
+ onMouseEnter: () => setCursor(index),
364
+ onMouseDown: (event) => {
365
+ event.preventDefault();
366
+ choose(hit);
367
+ },
368
+ },
369
+ h(
370
+ 'div',
371
+ { className: 'dsg-meta' },
372
+ h('span', { className: 'dsg-role' }, roleLabel(hit.role)),
373
+ h('span', { className: 'dsg-title' }, hit.title || hit.sessionId.slice(0, 10)),
374
+ h('span', { className: 'dsg-sid' }, String(hit.sessionId).slice(-6)),
375
+ hit.count > 1 ? h('span', null, `×${hit.count}`) : null,
376
+ ),
377
+ h(
378
+ 'div',
379
+ { className: 'dsg-snip' },
380
+ start >= 0 && end <= snippet.length
381
+ ? [
382
+ snippet.slice(0, start),
383
+ h('mark', { key: 'm' }, snippet.slice(start, end)),
384
+ snippet.slice(end),
385
+ ]
386
+ : snippet,
387
+ ),
388
+ );
389
+ });
390
+
391
+ const body = (() => {
392
+ if (result.status === 'loading' && result.hits.length === 0) return h('div', { className: 'dsg-empty' }, t('loading'));
393
+ if (result.status === 'error') return h('div', { className: 'dsg-err' }, `${t('failed')}: ${result.message || ''}`);
394
+ if (result.status === 'done' && result.hits.length === 0) return h('div', { className: 'dsg-empty' }, t('empty'));
395
+ if (result.status === 'idle') return h('div', { className: 'dsg-empty' }, t('hint'));
396
+ return h('div', { className: 'dsg-list' }, rows);
397
+ })();
398
+
399
+ return h(
400
+ 'div',
401
+ { className: 'dsg-root', ref: rootRef },
402
+ open
403
+ ? h(
404
+ 'div',
405
+ { className: 'dsg-field' },
406
+ ICON,
407
+ h('input', {
408
+ ref: inputRef,
409
+ value: text,
410
+ placeholder: t('placeholder'),
411
+ onChange: (event) => setText(event.target.value),
412
+ onKeyDown,
413
+ spellCheck: false,
414
+ 'aria-label': t('placeholder'),
415
+ }),
416
+ h('span', { className: 'dsg-kbd' }, 'Esc'),
417
+ )
418
+ : h(
419
+ 'button',
420
+ {
421
+ type: 'button',
422
+ className: 'dsg-trigger',
423
+ title: t('placeholder'),
424
+ onClick: () => setOpen(true),
425
+ },
426
+ ICON,
427
+ h('span', { className: 'dsg-trigger-label' }, t('search')),
428
+ ),
429
+ open && pos
430
+ ? h(
431
+ 'div',
432
+ {
433
+ className: 'dsg-panel',
434
+ style: { left: `${pos.left}px`, top: `${pos.top}px`, width: `${pos.width}px` },
435
+ },
436
+ h(
437
+ 'div',
438
+ { className: 'dsg-bar' },
439
+ h('span', null, result.status === 'done' ? `${result.hits.length} ${t('hits')}` : t('hint')),
440
+ h(
441
+ 'span',
442
+ {
443
+ className: 'dsg-chip',
444
+ 'data-on': tools ? '1' : '0',
445
+ role: 'switch',
446
+ 'aria-checked': tools ? 'true' : 'false',
447
+ onClick: () => setTools((value) => !value),
448
+ },
449
+ t('tools'),
450
+ ),
451
+ ),
452
+ body,
453
+ result.status === 'done'
454
+ ? h(
455
+ 'div',
456
+ { className: 'dsg-foot' },
457
+ h('span', null, `${result.tookMs} ${t('ms')} · ${result.scanned} events`),
458
+ h('span', null, result.truncated ? t('truncated') : `${LIMIT} max`),
459
+ )
460
+ : null,
461
+ )
462
+ : null,
463
+ toast ? h('div', { className: 'dsg-toast' }, toast) : null,
464
+ );
465
+ }
466
+
467
+ return {
468
+ inject: ['slots', 'locale', 'uiWorkspace', 'sessions'],
469
+ apply(ctx) {
470
+ ensureStyle();
471
+ runtime.locale = ctx.locale ?? null;
472
+ runtime.t = translate;
473
+ runtime.notify = () => {};
474
+ runtime.currentSession = () => {
475
+ try {
476
+ const node =
477
+ document.querySelector('[data-conversation-session]') ||
478
+ document.querySelector('[data-session-id]');
479
+ return (
480
+ node?.getAttribute('data-conversation-session') ||
481
+ node?.getAttribute('data-session-id') ||
482
+ null
483
+ );
484
+ } catch {
485
+ return null;
486
+ }
487
+ };
488
+ runtime.openSession = (sessionId) => {
489
+ try {
490
+ ctx.uiWorkspace.openSession(sessionId);
491
+ return null;
492
+ } catch (error) {
493
+ const message = error?.message || String(error);
494
+ console.warn('[dsh-session-grep] openSession failed', message);
495
+ return message;
496
+ }
497
+ };
498
+
499
+ // Side Sessions are addressed by their durable direct-parent address.
500
+ runtime.openByAddress = async (sessionId) => {
501
+ try {
502
+ const sessions =
503
+ ctx.sessions || (typeof ctx.get === 'function' ? ctx.get('sessions') : null);
504
+ if (!sessions || typeof sessions.retain !== 'function') return false;
505
+ const reference = sessions.retain(sessionId, { source: 'controllerOperation' });
506
+ try {
507
+ await reference.ready;
508
+ const snapshot = reference.binding?.session?.getSnapshot?.();
509
+ const address = snapshot?.subagent?.address;
510
+ if (!address) return false;
511
+ ctx.uiWorkspace.openSession(address);
512
+ return true;
513
+ } finally {
514
+ if (typeof reference.release === 'function') reference.release();
515
+ }
516
+ } catch (error) {
517
+ console.warn('[dsh-session-grep] openByAddress failed', error?.message || error);
518
+ return false;
519
+ }
520
+ };
521
+
522
+ try {
523
+ activeLocale = String(ctx.locale?.getLocale?.()?.active ?? 'en');
524
+ if (typeof ctx.locale?.subscribe === 'function') {
525
+ ctx.locale.subscribe(() => {
526
+ try {
527
+ activeLocale = String(ctx.locale?.getLocale?.()?.active ?? activeLocale);
528
+ } catch {
529
+ /* keep previous */
530
+ }
531
+ });
532
+ }
533
+ } catch (error) {
534
+ console.warn('[dsh-session-grep] locale capture failed', error);
535
+ }
536
+
537
+ for (const [locale, dict] of [
538
+ ['en', DICT.en],
539
+ ['zh', DICT.zh],
540
+ ['zh-CN', DICT.zh],
541
+ ]) {
542
+ try {
543
+ ctx.locale.register(NS, locale, dict);
544
+ } catch (error) {
545
+ console.warn('[dsh-session-grep] locale register failed', locale, error);
546
+ }
547
+ }
548
+ ctx.slots.inject('conversation.header.leading', () =>
549
+ ctx.slots.register(
550
+ {
551
+ name: 'conversation.header.leading',
552
+ id: 'dsh-session-grep',
553
+ order: 10,
554
+ },
555
+ GrepWidget,
556
+ ),
557
+ );
558
+ },
559
+ };
560
+ },
561
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-session-grep
3
+ name: dsh-session-grep
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
2
+ <path d="M3 6.8A3 3 0 0 1 6 3.8h12a3 3 0 0 1 3 3v6.4a3 3 0 0 1-3 3H9.6L5.2 20v-3.8A3 3 0 0 1 3 13.2Z" fill="currentColor" opacity="0.14"/>
3
+ <path d="M3 6.8A3 3 0 0 1 6 3.8h12a3 3 0 0 1 3 3v6.4a3 3 0 0 1-3 3H9.6L5.2 20v-3.8A3 3 0 0 1 3 13.2Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
4
+ <circle cx="10" cy="9.2" r="2.9" stroke="currentColor" stroke-width="1.6"/>
5
+ <path d="M12.2 11.4 14.6 13.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * dsh-session-grep — Host half.
3
+ *
4
+ * Serves a tiny read-only JSON API over the session logs on disk:
5
+ *
6
+ * GET /dsh-session-grep/api/search?q=&limit=&context=&tools=&session=
7
+ * GET /dsh-session-grep/api/sessions
8
+ * GET /dsh-session-grep/api/health
9
+ *
10
+ * Why the logs and not the session-query service: that service builds an
11
+ * FTS5 index with the `unicode61` tokenizer, which matches whole tokens and
12
+ * phrases. Contiguous Chinese is one token, so a fragment inside a sentence
13
+ * ("搜索输出" inside "...只能搜索输出") never matches. This plugin decompresses
14
+ * the same `session.v4.jsonl.zstd` files (a sequence of concatenated zstd
15
+ * frames) and does a literal, case-insensitive substring scan — the same
16
+ * thing `String.prototype.indexOf` does.
17
+ *
18
+ * Zero network access: it reads the local session directory and answers its
19
+ * own loopback route.
20
+ */
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import zlib from 'node:zlib';
25
+
26
+ export const inject = ['webServer'];
27
+
28
+ const VERSION = '1.0.1';
29
+ const API = '/dsh-session-grep/api';
30
+ const SESSION_FILE = 'session.v4.jsonl.zstd';
31
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); // zstd frame magic
32
+ const MAX_HITS_PER_DOC = 3;
33
+ const MAX_LIMIT = 200;
34
+
35
+ /** file path -> parsed entry */
36
+ const cache = new Map();
37
+
38
+ function sessionsRoot() {
39
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
40
+ return path.join(home, 'sessions');
41
+ }
42
+
43
+ // ---------------------------------------------------------------- decoding
44
+
45
+ /**
46
+ * One log file is several zstd frames concatenated with no length prefix.
47
+ * Split on the magic and inflate each frame; a magic sequence that shows up
48
+ * inside compressed bytes simply fails to inflate and is merged into the next
49
+ * candidate, so a false positive costs one retry instead of a lost event.
50
+ */
51
+ function decodeFrames(buf) {
52
+ const cuts = [];
53
+ let at = 0;
54
+ while ((at = buf.indexOf(MAGIC, at)) !== -1) {
55
+ cuts.push(at);
56
+ at += 4;
57
+ }
58
+ if (cuts.length === 0) return buf;
59
+ const parts = [];
60
+ let start = cuts[0];
61
+ for (let k = 1; k <= cuts.length; k++) {
62
+ const end = k < cuts.length ? cuts[k] : buf.length;
63
+ try {
64
+ parts.push(zlib.zstdDecompressSync(buf.subarray(start, end)));
65
+ start = end;
66
+ } catch {
67
+ // Not a frame boundary after all: keep `start` and try a longer slice.
68
+ }
69
+ }
70
+ return Buffer.concat(parts);
71
+ }
72
+
73
+ function parseEvents(file) {
74
+ const text = decodeFrames(fs.readFileSync(file)).toString('utf8');
75
+ const events = [];
76
+ for (const line of text.split('\n')) {
77
+ if (!line) continue;
78
+ try {
79
+ events.push(JSON.parse(line));
80
+ } catch {
81
+ // A torn last line is expected while a session is still being written.
82
+ }
83
+ }
84
+ return events;
85
+ }
86
+
87
+ // ------------------------------------------------------------ text of event
88
+
89
+ function textOf(ev, withTools) {
90
+ const d = ev.data;
91
+ if (!d) return '';
92
+ if (ev.type === 'user/message') {
93
+ return (d.content ?? [])
94
+ .filter((c) => c && c.type === 'text')
95
+ .map((c) => c.text)
96
+ .join('\n');
97
+ }
98
+ if (ev.type === 'assistant/message') {
99
+ const parts = d.message?.content ?? d.content ?? [];
100
+ const out = [];
101
+ for (const c of parts) {
102
+ if (!c) continue;
103
+ if (c.type === 'text') out.push(c.text);
104
+ else if (withTools && c.type === 'reasoning') out.push(c.text);
105
+ else if (withTools && (c.type === 'tool-call' || c.type === 'tool_call')) {
106
+ out.push(JSON.stringify(c.arguments ?? c.input ?? ''));
107
+ }
108
+ }
109
+ return out.join('\n');
110
+ }
111
+ if (withTools && ev.type === 'developer/message') {
112
+ return JSON.stringify(d.content ?? '');
113
+ }
114
+ if (withTools && ev.type === 'tool/call') return JSON.stringify(d);
115
+ if (withTools && ev.type === 'tool/result') return JSON.stringify(d).slice(0, 20000);
116
+ return '';
117
+ }
118
+
119
+ function roleOf(type) {
120
+ if (type === 'user/message') return 'user';
121
+ if (type === 'assistant/message') return 'assistant';
122
+ if (type === 'tool/call' || type === 'tool/result') return 'tool';
123
+ return 'other';
124
+ }
125
+
126
+ function timeOf(ev) {
127
+ const value = ev?.time;
128
+ if (typeof value === 'number') return value;
129
+ if (typeof value === 'string') {
130
+ const parsed = Date.parse(value);
131
+ if (Number.isFinite(parsed)) return parsed;
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ function titleOf(events) {
137
+ for (const ev of events) {
138
+ if (ev.type === 'session/title') {
139
+ const t = ev.data?.title ?? ev.data?.text;
140
+ if (typeof t === 'string' && t.trim()) return t.trim();
141
+ }
142
+ }
143
+ for (const ev of events) {
144
+ if (ev.type === 'user/message') {
145
+ const first = textOf(ev, false).replace(/\s+/g, ' ').trim();
146
+ if (first) return first.length > 48 ? `${first.slice(0, 48)}…` : first;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // ------------------------------------------------------------------- cache
153
+
154
+ function loadSession(file) {
155
+ let stat;
156
+ try {
157
+ stat = fs.statSync(file);
158
+ } catch {
159
+ return null;
160
+ }
161
+ const cached = cache.get(file);
162
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
163
+
164
+ const events = parseEvents(file);
165
+ if (events.length === 0) return null;
166
+ const header = events[0] ?? {};
167
+ let updatedAt = 0;
168
+ for (const ev of events) {
169
+ const at = timeOf(ev);
170
+ if (at > updatedAt) updatedAt = at;
171
+ }
172
+ const entry = {
173
+ file,
174
+ id: path.basename(path.dirname(file)),
175
+ cwd: typeof header.cwd === 'string' ? header.cwd : null,
176
+ title: titleOf(events),
177
+ events,
178
+ updatedAt,
179
+ mtimeMs: stat.mtimeMs,
180
+ size: stat.size,
181
+ };
182
+ cache.set(file, entry);
183
+ if (cache.size > 64) {
184
+ const oldest = cache.keys().next().value;
185
+ if (oldest !== file) cache.delete(oldest);
186
+ }
187
+ return entry;
188
+ }
189
+
190
+ function listSessions() {
191
+ const root = sessionsRoot();
192
+ const out = [];
193
+ let cwdDirs = [];
194
+ try {
195
+ cwdDirs = fs.readdirSync(root);
196
+ } catch {
197
+ return { root, sessions: out };
198
+ }
199
+ for (const cwdDir of cwdDirs) {
200
+ const dir = path.join(root, cwdDir);
201
+ let entries = [];
202
+ try {
203
+ if (!fs.statSync(dir).isDirectory()) continue;
204
+ entries = fs.readdirSync(dir);
205
+ } catch {
206
+ continue;
207
+ }
208
+ for (const name of entries) {
209
+ const file = path.join(dir, name, SESSION_FILE);
210
+ if (!fs.existsSync(file)) continue;
211
+ const entry = loadSession(file);
212
+ if (entry) out.push(entry);
213
+ }
214
+ }
215
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
216
+ return { root, sessions: out };
217
+ }
218
+
219
+ // ------------------------------------------------------------------ search
220
+
221
+ function snippetOf(text, at, length, context) {
222
+ const from = Math.max(0, at - context);
223
+ const to = Math.min(text.length, at + length + context);
224
+ const collapsed = text.slice(from, to).replace(/\s+/g, ' ');
225
+ const match = text.slice(at, at + length);
226
+ return { snippet: collapsed, match, before: at > from, after: to < text.length };
227
+ }
228
+
229
+ function search(query, options) {
230
+ const needle = query.toLowerCase();
231
+ const limit = Math.min(Math.max(1, options.limit | 0 || 30), MAX_LIMIT);
232
+ const context = Math.min(Math.max(10, options.context | 0 || 60), 400);
233
+ const { tools, session } = options;
234
+ const started = Date.now();
235
+ const { root, sessions } = listSessions();
236
+ const perSessionCap = Math.max(10, Math.ceil(limit / 2));
237
+ const groups = [];
238
+ let scannedSessions = 0;
239
+ let scannedEvents = 0;
240
+ let overflow = false;
241
+
242
+ for (const s of sessions) {
243
+ if (session && !s.id.startsWith(session)) continue;
244
+ scannedSessions++;
245
+ const local = [];
246
+ const seen = new Set();
247
+ for (const ev of s.events) {
248
+ const text = textOf(ev, tools);
249
+ if (!text) continue;
250
+ scannedEvents++;
251
+ const low = text.toLowerCase();
252
+ const at = low.indexOf(needle);
253
+ if (at === -1) continue;
254
+ // One hit per event, with the number of in-event occurrences: repeating
255
+ // the same event for every occurrence crowds out other sessions.
256
+ const key = typeof ev.seq === 'number' ? `s${ev.seq}` : `e${scannedEvents}`;
257
+ if (seen.has(key)) continue;
258
+ seen.add(key);
259
+ let count = 1;
260
+ for (let probe = low.indexOf(needle, at + needle.length); probe !== -1; probe = low.indexOf(needle, probe + needle.length)) {
261
+ count += 1;
262
+ }
263
+ const built = snippetOf(text, at, needle.length, context);
264
+ local.push({
265
+ sessionId: s.id,
266
+ title: s.title,
267
+ cwd: s.cwd,
268
+ seq: typeof ev.seq === 'number' ? ev.seq : null,
269
+ type: ev.type,
270
+ role: roleOf(ev.type),
271
+ time: timeOf(ev) || null,
272
+ snippet: built.snippet,
273
+ match: built.match,
274
+ matchStart: built.snippet.toLowerCase().indexOf(needle),
275
+ matchLength: needle.length,
276
+ truncatedBefore: built.before,
277
+ truncatedAfter: built.after,
278
+ count,
279
+ });
280
+ if (local.length >= perSessionCap) {
281
+ overflow = true;
282
+ break;
283
+ }
284
+ }
285
+ if (local.length > 0) {
286
+ const newest = local.reduce((max, hit) => Math.max(max, Number.isFinite(hit.time) ? hit.time : 0), 0);
287
+ groups.push({ hits: local, newest });
288
+ }
289
+ }
290
+
291
+ // Round-robin across sessions so one long conversation cannot swallow the
292
+ // whole result list; sessions with the newest matches contribute first.
293
+ groups.sort((a, b) => b.newest - a.newest);
294
+ const hits = [];
295
+ for (let round = 0; hits.length < limit; round += 1) {
296
+ let added = 0;
297
+ for (const group of groups) {
298
+ const hit = group.hits[round];
299
+ if (!hit) continue;
300
+ hits.push(hit);
301
+ added += 1;
302
+ if (hits.length >= limit) break;
303
+ }
304
+ if (added === 0) break;
305
+ }
306
+
307
+ return {
308
+ query,
309
+ hits,
310
+ limit,
311
+ total: hits.length,
312
+ truncated: overflow || hits.length >= limit,
313
+ scannedSessions,
314
+ scannedEvents,
315
+ tookMs: Date.now() - started,
316
+ sessionsRoot: root,
317
+ };
318
+ }
319
+
320
+ // ------------------------------------------------------------------- routes
321
+
322
+ function sendJson(res, status, body) {
323
+ const payload = JSON.stringify(body);
324
+ res.writeHead(status, {
325
+ 'content-type': 'application/json; charset=utf-8',
326
+ 'cache-control': 'no-store',
327
+ 'content-length': Buffer.byteLength(payload),
328
+ });
329
+ res.end(payload);
330
+ }
331
+
332
+ function methodNotAllowed(res) {
333
+ res.writeHead(405, { allow: 'GET' });
334
+ res.end();
335
+ }
336
+
337
+ export function apply(ctx) {
338
+ const disposeSearch = ctx.webServer.register({
339
+ kind: 'exact',
340
+ path: `${API}/search`,
341
+ handler: (req, res) => {
342
+ if (req.method !== 'GET') return methodNotAllowed(res);
343
+ const url = new URL(req.url ?? '/', 'http://localhost');
344
+ const q = (url.searchParams.get('q') ?? '').trim();
345
+ if (!q) return sendJson(res, 200, { query: '', hits: [], total: 0, tookMs: 0 });
346
+ try {
347
+ sendJson(
348
+ res,
349
+ 200,
350
+ search(q, {
351
+ limit: Number(url.searchParams.get('limit')) || 30,
352
+ context: Number(url.searchParams.get('context')) || 60,
353
+ tools: url.searchParams.get('tools') === '1',
354
+ session: url.searchParams.get('session') || null,
355
+ }),
356
+ );
357
+ } catch (error) {
358
+ sendJson(res, 500, { error: String(error?.message ?? error) });
359
+ }
360
+ },
361
+ });
362
+
363
+ const disposeSessions = ctx.webServer.register({
364
+ kind: 'exact',
365
+ path: `${API}/sessions`,
366
+ handler: (req, res) => {
367
+ if (req.method !== 'GET') return methodNotAllowed(res);
368
+ try {
369
+ const { root, sessions } = listSessions();
370
+ sendJson(res, 200, {
371
+ sessionsRoot: root,
372
+ sessions: sessions.map((s) => ({
373
+ sessionId: s.id,
374
+ title: s.title,
375
+ cwd: s.cwd,
376
+ events: s.events.length,
377
+ updatedAt: s.updatedAt,
378
+ bytes: s.size,
379
+ })),
380
+ });
381
+ } catch (error) {
382
+ sendJson(res, 500, { error: String(error?.message ?? error) });
383
+ }
384
+ },
385
+ });
386
+
387
+ const disposeHealth = ctx.webServer.register({
388
+ kind: 'exact',
389
+ path: `${API}/health`,
390
+ handler: (req, res) => {
391
+ if (req.method !== 'GET') return methodNotAllowed(res);
392
+ let sessions = 0;
393
+ try {
394
+ sessions = listSessions().sessions.length;
395
+ } catch {
396
+ // reported as zero below
397
+ }
398
+ sendJson(res, 200, {
399
+ ok: true,
400
+ version: VERSION,
401
+ sessionsRoot: sessionsRoot(),
402
+ sessions,
403
+ cached: cache.size,
404
+ });
405
+ },
406
+ });
407
+
408
+ return () => {
409
+ disposeSearch();
410
+ disposeSessions();
411
+ disposeHealth();
412
+ };
413
+ }
package/locale/en.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "meta": {
3
+ "title": "Conversation grep",
4
+ "description": "Substring search across every message in this workspace, Chinese fragments included."
5
+ },
6
+ "search": "Search",
7
+ "placeholder": "Search all messages, e.g. 搜索输出",
8
+ "hint": "Matches any fragment, including inside a Chinese sentence.",
9
+ "empty": "No message contains that fragment.",
10
+ "failed": "Search failed",
11
+ "tools": "Tool output",
12
+ "hits": "hits",
13
+ "ms": "ms",
14
+ "truncated": "stopped at limit",
15
+ "noHistory": "No conversation history found in this workspace.",
16
+ "notLocated": "Opened, but the text is not rendered yet — scroll up in that conversation.",
17
+ "loading": "Searching…",
18
+ "user": "you",
19
+ "assistant": "assistant",
20
+ "tool": "tool"
21
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "meta": {
3
+ "title": "对话正文搜索",
4
+ "description": "在工作区全部对话正文里做真正的子串搜索,中文句子中间的片段也能命中。"
5
+ },
6
+ "search": "搜索",
7
+ "placeholder": "搜索所有对话正文,例如:搜索输出",
8
+ "hint": "任意片段都能命中,包括中文句子中间的字。",
9
+ "empty": "没有消息包含这个片段。",
10
+ "failed": "搜索失败",
11
+ "tools": "工具输出",
12
+ "hits": "处命中",
13
+ "ms": "毫秒",
14
+ "truncated": "已达条数上限",
15
+ "noHistory": "这个工作区还没有对话记录。",
16
+ "notLocated": "已打开对话,但这句还没渲染出来——往上翻一下。",
17
+ "loading": "搜索中…",
18
+ "user": "我",
19
+ "assistant": "助手",
20
+ "tool": "工具"
21
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "dsh-session-grep",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Substring search across every conversation in a workspace, Chinese included: the built-in tokenizer only matches whole tokens, this matches any fragment. · 在对话正文里做真正的子串搜索(中文片段也能命中)。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "chenshanhan0711",
8
+ "main": "index.js",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/chenshanhan0711/dsh-session-grep.git"
12
+ },
13
+ "homepage": "https://github.com/chenshanhan0711/dsh-session-grep#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/chenshanhan0711/dsh-session-grep/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "keywords": [
21
+ "deepseek",
22
+ "harness",
23
+ "dsh",
24
+ "dsh-plugin",
25
+ "search",
26
+ "session",
27
+ "conversation",
28
+ "grep"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "exports": {
34
+ ".": "./index.js",
35
+ "./client": "./client.js",
36
+ "./cordis.patch.yml": "./cordis.patch.yml",
37
+ "./locale/*.json": "./locale/*.json",
38
+ "./icon": "./icon.svg",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "index.js",
43
+ "client.js",
44
+ "cordis.patch.yml",
45
+ "locale/*.json",
46
+ "icon.svg",
47
+ "README.md",
48
+ "README.zh.md",
49
+ "CHANGELOG.md",
50
+ "LICENSE"
51
+ ],
52
+ "dsh": {
53
+ "bundle": {
54
+ "patch": "./cordis.patch.yml"
55
+ },
56
+ "client": {
57
+ "platform": "web",
58
+ "immediately": true,
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-ui-conversation"
61
+ ]
62
+ }
63
+ }
64
+ }