dsh-cmdk 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.3.2 — 2026-11-08
4
+
5
+ ### 修复
6
+
7
+ - 无标题会话不再显示 `undefined`,改为「无标题 / Untitled」。
8
+ - 英文界面的失败提示改用 `": "`,不再混入中文全角冒号。
9
+ - 导出改为调用宿主原生的 `/api/session.markdown?sessionId=`,不再依赖系统 `zstd` 命令,也不再自己解析会话日志。
10
+
11
+ ### 改进
12
+
13
+ - 导出的 Markdown 现在包含工具调用、图片 / 文件附件和折叠的中间消息,与 README 的描述一致。
14
+ - 搜索分层:宿主一次下发完整会话目录,标题与拼音在浏览器内即时匹配;正文命中由宿主扫描后合并进来,输入不再等待网络。
15
+ - 正文搜索不再跳过无标题会话。
16
+ - 「最近使用」改为 frecency(使用次数 × 7 天半衰期的时间衰减),与 README 描述一致。
17
+ - 快捷键提示按平台显示 `⌘K` 或 `Ctrl+K`。
18
+
19
+ ### 移除
20
+
21
+ - `GET /cmdk/export` 路由及其 zstd 解压 / Markdown 渲染代码(宿主减少约 150 行)。
22
+
23
+ 更早版本见 git 历史。
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-cmdk — ⌘K 命令面板
2
2
 
3
- 当前版本 **0.3.1**:会话 / 工作区 / 插件 / 动作 / 外观一个入口,插件可扩展。
3
+ 当前版本 **0.3.2**:会话 / 工作区 / 插件 / 动作 / 外观一个入口,插件可扩展。
4
4
 
5
5
  一个快捷键跳到任何东西:会话、工作区、常用动作,还能对选中的会话直接做事。
6
6
  装完即用,无配置,零依赖。
@@ -13,10 +13,10 @@
13
13
 
14
14
  | 输入 | 结果 |
15
15
  | --- | --- |
16
- | `⌘K` / `Ctrl+K` | 打开 / 关闭面板(侧栏底部也有「⌘K 搜索」按钮) |
17
- | 输入会话标题 | 模糊匹配全部会话(前缀 > 子串 > 拼音首字母 > 子序列),回车直达 |
16
+ | `⌘K` / `Ctrl+K` | 打开 / 关闭面板(侧栏底部也有同款按钮) |
17
+ | 输入会话标题 | 浏览器内即时模糊匹配全部会话(前缀 > 子串 > 拼音首字母 > 子序列),回车直达 |
18
18
  | 输入中文首字母 | `cj` → 「插件…」,`cjgc` → 「插件广场」 |
19
- | 输入消息内容 | 宿主扫描最近 12 个会话的日志文本,带上下文片段 |
19
+ | 输入消息内容 | 标题先出结果;宿主扫描最近 12 个会话的日志文本,命中后带上下文片段补进来 |
20
20
  | 输入工作区名 | 直接切换工作区 |
21
21
  | `深色` / `浅色` / `跟随系统` | 换主题 |
22
22
  | `中文` / `English` | 换界面语言 |
@@ -63,15 +63,19 @@ window.__DSH_CMDK__?.unregister('my-plugin.open-docs');
63
63
  ## 记忆
64
64
 
65
65
  用过的动作会记在 `localStorage["dsh-cmdk/recent"]`(次数 + 时间,最多 60 条),
66
- 下次空查询时按使用频率排在最上面。纯本地,不上传。
66
+ 下次空查询时按 frecency(次数 × 7 天半衰期的时间衰减)排在最上面。纯本地,不上传。
67
67
 
68
68
  ## 设计取舍
69
69
 
70
70
  - **跳转与动作走公开 Client 服务**:切会话、Fork、归档、重命名、换主题、换语言都用
71
71
  `uiWorkspace` / `sessions` / `theme` / `locale`,不碰内部模块。
72
- - **列表数据走宿主**:浏览器侧拿不到"全部历史会话"和工作区清单,所以宿主读
72
+ - **列表数据走宿主,过滤走本地**:浏览器侧拿不到"全部历史会话"和工作区清单,所以宿主读
73
73
  `sessionQuery.listSessions()` + `readTitleSnapshots()`(标题)与
74
- `~/.dsh/storages/workspace.json`(工作区、已归档会话),客户端只做展示。
74
+ `~/.dsh/storages/workspace.json`(工作区、已归档会话);宿主一次下发完整目录,
75
+ 客户端在本地做模糊 + 拼音过滤,输入不等网络,正文命中回来后再合并。
76
+ - **导出复用宿主原生实现**:走同源的 `/api/session.markdown?sessionId=`,直接拿到
77
+ DSH 自带 `formatSessionMarkdown` 的结果(工具调用、图片附件、折叠段齐全),
78
+ 不再自己解压 `zstd`、也不再依赖系统里有没有 `zstd` 命令。
75
79
  - **内容搜索不依赖 SQLite 索引**:用 `filterEvents({kind:'text'})` 扫正文——实测有些
76
80
  机器上原生「搜索会话」本身就是空的(全文索引没建起来),照抄索引会一起空。
77
81
  代价是只扫最近 12 个会话,且只跳到会话顶部而不是那一条消息(API 不返回消息序号)。
@@ -88,9 +92,8 @@ window.__DSH_CMDK__?.unregister('my-plugin.open-docs');
88
92
  | 方法 | 路径 | 作用 |
89
93
  | --- | --- | --- |
90
94
  | GET | `/cmdk/info` | home / profile / platform |
91
- | GET | `/cmdk/sessions?q=&limit=` | 会话目录 + 标题匹配 + 正文命中片段 |
95
+ | GET | `/cmdk/sessions?q=&limit=` | 完整会话目录 + 正文命中片段(标题匹配在客户端做) |
92
96
  | GET | `/cmdk/workspaces` | 工作区清单 + 已归档会话 ID |
93
- | GET | `/cmdk/export?id=` | 把一段会话渲染成 Markdown |
94
97
  | GET | `/cmdk/plugins` | 已安装插件(名称 / 版本 / 启用状态 / 目录) |
95
98
  | POST | `/cmdk/plugin` | `{ name, enabled }` 启用或禁用某个插件 |
96
99
  | POST | `/cmdk/open-profile` | 在访达/资源管理器打开目录(可选 `{path}`) |
@@ -110,7 +113,8 @@ dsh plugin add dsh-cmdk
110
113
  ```
111
114
  package.json # dsh.bundle.patch + dsh.client(platform web, immediately)
112
115
  cordis.patch.yml # insert: dsh-cmdk
113
- index.js # Host:/cmdk/{info,sessions,workspaces,plugins,plugin,export,open-profile}
116
+ CHANGELOG.md
117
+ index.js # Host:/cmdk/{info,sessions,workspaces,plugins,plugin,open-profile}
114
118
  client.js # Client:shell.overlay 面板 + sidebar.footer.action 按钮
115
119
  locale/zh.json # meta.title / meta.description
116
120
  locale/en.json
package/client.js CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * One overlay (shell.overlay) plus a small sidebar footer button. Navigation,
5
5
  * renaming, archiving, forking and theming all go through public Client
6
- * services; only the things a browser cannot do (reveal a directory, export a
7
- * transcript) come from the tiny Host route.
6
+ * services; only the things a browser cannot do (reveal a directory) come
7
+ * from the tiny Host route. Transcript export uses the native
8
+ * /api/session.markdown route.
8
9
  */
9
10
  window.__ModuleLoader__.load({
10
11
  id: 'dsh-cmdk',
@@ -21,6 +22,9 @@ window.__ModuleLoader__.load({
21
22
  var API = '/cmdk';
22
23
  var RECENT_KEY = 'dsh-cmdk/recent';
23
24
  var MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
25
+ var IS_MAC = /Mac|iPhone|iPad|iPod/.test(String(navigator.platform || navigator.userAgent || ''));
26
+ var MOD_KEY = IS_MAC ? '⌘' : 'Ctrl';
27
+ var HOTKEY = MOD_KEY + 'K';
24
28
 
25
29
  var C = {
26
30
  text: 'var(--dsw-alias-label-primary)',
@@ -56,6 +60,7 @@ window.__ModuleLoader__.load({
56
60
  searching: '正在搜索…',
57
61
  action: '动作',
58
62
  session: '会话',
63
+ untitled: '无标题',
59
64
  workspace: '工作区',
60
65
  theme: '外观',
61
66
  system: '系统',
@@ -110,6 +115,7 @@ window.__ModuleLoader__.load({
110
115
  copied: '已复制',
111
116
  done: '完成',
112
117
  failed: '失败',
118
+ failedSep: ':',
113
119
  fontNow: '字号',
114
120
  },
115
121
  en: {
@@ -131,6 +137,7 @@ window.__ModuleLoader__.load({
131
137
  searching: 'Searching…',
132
138
  action: 'Actions',
133
139
  session: 'Sessions',
140
+ untitled: 'Untitled',
134
141
  workspace: 'Workspaces',
135
142
  theme: 'Appearance',
136
143
  system: 'System',
@@ -184,6 +191,7 @@ window.__ModuleLoader__.load({
184
191
  copied: 'Copied',
185
192
  done: 'Done',
186
193
  failed: 'Failed',
194
+ failedSep: ': ',
187
195
  fontNow: 'Font size',
188
196
  },
189
197
  };
@@ -415,6 +423,17 @@ window.__ModuleLoader__.load({
415
423
  }
416
424
  }
417
425
 
426
+ /**
427
+ * Frecency: how often it was used, discounted by a one-week half-life.
428
+ * A brand-new action (count 1) beats something used twice a month ago.
429
+ */
430
+ function frecency(hit, now) {
431
+ if (!hit || (!hit.at && !hit.count)) return 0;
432
+ var count = Math.max(1, Math.min(hit.count || 1, 40));
433
+ var ageDays = Math.max(0, ((now || Date.now()) - (hit.at || 0)) / 86400000);
434
+ return count * Math.pow(0.5, ageDays / 7);
435
+ }
436
+
418
437
  // ---------------------------------------------------------------------
419
438
  // item helpers
420
439
  // ---------------------------------------------------------------------
@@ -532,12 +551,19 @@ window.__ModuleLoader__.load({
532
551
  revealPath(cwd).then(() => ({ status: t.done })),
533
552
  ),
534
553
  item('action:export', 'action', t.exportMd, t.exportMdHint, () =>
535
- getJson(API + '/export?id=' + encodeURIComponent(id)).then((payload) => {
536
- var blob = new Blob([payload.markdown], { type: 'text/markdown;charset=utf-8' });
554
+ fetch('/api/session.markdown?sessionId=' + encodeURIComponent(id), {
555
+ headers: { Accept: 'text/markdown' },
556
+ }).then((response) => {
557
+ if (!response.ok) throw new Error('HTTP ' + response.status);
558
+ return response.text();
559
+ }).then((markdown) => {
560
+ var fallback = entry.title || id || 'session';
561
+ var filename = String(fallback).replace(/[\\/:*?"<>|]+/g, '-').slice(0, 80) + '.md';
562
+ var blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
537
563
  var url = URL.createObjectURL(blob);
538
564
  var anchor = document.createElement('a');
539
565
  anchor.href = url;
540
- anchor.download = payload.filename || 'session.md';
566
+ anchor.download = filename;
541
567
  document.body.appendChild(anchor);
542
568
  anchor.click();
543
569
  anchor.remove();
@@ -718,23 +744,46 @@ window.__ModuleLoader__.load({
718
744
  return t.recent;
719
745
  }
720
746
 
721
- function buildRoot(ctx, lang, query, catalog, workspaces, recent, plugins, ext, reloadPlugins) {
747
+ function buildRoot(ctx, lang, query, catalog, hits, workspaces, recent, plugins, ext, reloadPlugins) {
722
748
  var items = [];
723
- // keep the verbs reachable: a long transcript list would push them away
724
- catalog.slice(0, query.trim() ? catalog.length : 5).forEach((entry) => items.push(sessionItem(ctx, lang, entry)));
725
- (plugins || []).slice(0, query.trim() ? plugins.length : 6).forEach((plugin) => items.push(pluginItem(ctx, lang, plugin, reloadPlugins)));
749
+ var text = query.trim();
750
+ var now = Date.now();
751
+ var seenSessions = new Set();
752
+ var sessionRows = [];
753
+ // Local-first: every catalogue row is fuzzy-matched in the browser, so
754
+ // keystrokes never wait for the Host. Content hits arrive separately.
755
+ var requested = text ? catalog : catalog.slice(0, 5);
756
+ requested.forEach(function (entry) {
757
+ var row = sessionItem(ctx, lang, entry);
758
+ if (text && scoreOf(query, row) <= 0) return;
759
+ seenSessions.add(entry.sessionId);
760
+ sessionRows.push(row);
761
+ });
762
+ if (text) {
763
+ (hits || []).forEach(function (entry) {
764
+ if (seenSessions.has(entry.sessionId)) return;
765
+ seenSessions.add(entry.sessionId);
766
+ var row = sessionItem(ctx, lang, entry);
767
+ row.force = true;
768
+ sessionRows.push(row);
769
+ });
770
+ }
771
+ sessionRows.forEach(function (row) {
772
+ items.push(row);
773
+ });
774
+ (plugins || []).slice(0, text ? plugins.length : 6).forEach((plugin) => items.push(pluginItem(ctx, lang, plugin, reloadPlugins)));
726
775
  buildActions(ctx, lang).forEach((entry) => items.push(entry));
727
776
  workspaces.forEach((workspace) => items.push(workspaceItem(ctx, lang, workspace)));
728
777
  (ext || []).forEach((command) => items.push(extItem(ctx, lang, command)));
729
778
  buildThemes(ctx, lang).forEach((entry) => items.push(entry));
730
779
  buildLanguages(ctx, lang).forEach((entry) => items.push(entry));
731
780
 
732
- if (!query.trim()) {
781
+ if (!text) {
733
782
  // a short "recent" band on top, then the natural groups
734
783
  var ranked = items
735
784
  .map((entry) => ({ entry: entry, hit: recent[entry.key] }))
736
- .filter((row) => row.hit && row.hit.at)
737
- .sort((a, b) => (b.hit.at || 0) - (a.hit.at || 0))
785
+ .filter((row) => row.hit && (row.hit.at || row.hit.count))
786
+ .sort((a, b) => frecency(b.hit, now) - frecency(a.hit, now))
738
787
  .slice(0, 4)
739
788
  .map((row) => row.entry);
740
789
  var seen = new Set(ranked.map((entry) => entry.key));
@@ -745,12 +794,10 @@ window.__ModuleLoader__.load({
745
794
 
746
795
  var scored = [];
747
796
  items.forEach((entry) => {
748
- var score = scoreOf(query, entry);
749
- if (!entry.key.startsWith('session:')) {
750
- // the Host already filtered sessions; local items need their own match
751
- if (score <= 0) return;
752
- }
753
- var bump = recent[entry.key] && recent[entry.key].at ? Math.min(recent[entry.key].count || 0, 6) * 0.02 : 0;
797
+ var score = entry.force ? Math.max(scoreOf(query, entry), 0.95) : scoreOf(query, entry);
798
+ if (score <= 0) return;
799
+ var hit = recent[entry.key];
800
+ var bump = hit ? Math.min(frecency(hit, now), 8) * 0.015 : 0;
754
801
  scored.push({ entry: entry, score: score + bump });
755
802
  });
756
803
  // keep every group contiguous: a group's place comes from its best hit, the
@@ -1164,7 +1211,7 @@ window.__ModuleLoader__.load({
1164
1211
  var t = copy(lang);
1165
1212
  var open = useOpen();
1166
1213
  var [query, setQuery] = useState('');
1167
- var [catalog, setCatalog] = useState({ items: [], loading: true });
1214
+ var [catalog, setCatalog] = useState({ items: [], hits: [], hitsFor: '', loading: true });
1168
1215
  var [index, setIndex] = useState(0);
1169
1216
  var [stack, setStack] = useState([]);
1170
1217
  var [input, setInput] = useState(null);
@@ -1253,16 +1300,17 @@ window.__ModuleLoader__.load({
1253
1300
  setStatus(null);
1254
1301
  var timer = setTimeout(
1255
1302
  () => {
1256
- setCatalog((prev) => (prev.items.length ? prev : { items: [], loading: true }));
1303
+ setCatalog((prev) => (prev.items.length ? prev : { items: [], hits: [], hitsFor: '', loading: true }));
1257
1304
  getJson(API + '/sessions?limit=24' + (text ? '&q=' + encodeURIComponent(text) : ''))
1258
1305
  .then((payload) => {
1259
1306
  if (!alive) return;
1260
1307
  var live = (payload.items || []).filter((entry) => !archived[entry.sessionId]);
1261
- setCatalog({ items: live, loading: false });
1308
+ var liveHits = (payload.hits || []).filter((entry) => !archived[entry.sessionId]);
1309
+ setCatalog({ items: live, hits: liveHits, hitsFor: text, loading: false });
1262
1310
  })
1263
1311
  .catch(() => {
1264
1312
  if (!alive) return;
1265
- setCatalog({ items: [], loading: false });
1313
+ setCatalog({ items: [], hits: [], hitsFor: text, loading: false });
1266
1314
  });
1267
1315
  },
1268
1316
  text ? 180 : 0,
@@ -1311,7 +1359,12 @@ window.__ModuleLoader__.load({
1311
1359
  for (var vi = 0; vi < catalog.items.length; vi += 1) {
1312
1360
  if (!archived[catalog.items[vi].sessionId]) visible.push(catalog.items[vi]);
1313
1361
  }
1314
- var root = buildRoot(ctx, lang, query, visible, workspaces, recent, plugins, ext, reloadPlugins);
1362
+ var visibleHits = [];
1363
+ var freshHits = catalog.hitsFor === query.trim() ? catalog.hits : [];
1364
+ for (var hi = 0; hi < freshHits.length; hi += 1) {
1365
+ if (!archived[freshHits[hi].sessionId]) visibleHits.push(freshHits[hi]);
1366
+ }
1367
+ var root = buildRoot(ctx, lang, query, visible, visibleHits, workspaces, recent, plugins, ext, reloadPlugins);
1315
1368
  var current = stack.length ? stack[stack.length - 1] : null;
1316
1369
  var items = input ? [] : current ? current.items : root;
1317
1370
  var active = Math.min(index, Math.max(items.length - 1, 0));
@@ -1384,7 +1437,7 @@ window.__ModuleLoader__.load({
1384
1437
  try {
1385
1438
  result = entry.run();
1386
1439
  } catch (error) {
1387
- setStatus(t.failed + ':' + String((error && error.message) || error));
1440
+ setStatus(t.failed + t.failedSep + String((error && error.message) || error));
1388
1441
  return;
1389
1442
  }
1390
1443
  if (result && typeof result.then === 'function') {
@@ -1396,7 +1449,7 @@ window.__ModuleLoader__.load({
1396
1449
  },
1397
1450
  (error) => {
1398
1451
  setBusy(false);
1399
- setStatus(t.failed + ':' + String((error && error.message) || error));
1452
+ setStatus(t.failed + t.failedSep + String((error && error.message) || error));
1400
1453
  },
1401
1454
  );
1402
1455
  return;
@@ -1423,7 +1476,7 @@ window.__ModuleLoader__.load({
1423
1476
  try {
1424
1477
  pending = input.submit(value);
1425
1478
  } catch (error) {
1426
- setStatus(t.failed + ':' + String((error && error.message) || error));
1479
+ setStatus(t.failed + t.failedSep + String((error && error.message) || error));
1427
1480
  return;
1428
1481
  }
1429
1482
  setBusy(true);
@@ -1435,7 +1488,7 @@ window.__ModuleLoader__.load({
1435
1488
  },
1436
1489
  (error) => {
1437
1490
  setBusy(false);
1438
- setStatus(t.failed + ':' + String((error && error.message) || error));
1491
+ setStatus(t.failed + t.failedSep + String((error && error.message) || error));
1439
1492
  },
1440
1493
  );
1441
1494
  }
@@ -2117,7 +2170,7 @@ window.__ModuleLoader__.load({
2117
2170
  {
2118
2171
  type: 'button',
2119
2172
  onClick: () => store.toggle(),
2120
- title: '⌘K / Ctrl+K',
2173
+ title: HOTKEY,
2121
2174
  onMouseEnter: () => setHover(true),
2122
2175
  onMouseLeave: () => setHover(false),
2123
2176
  style: {
@@ -2144,7 +2197,7 @@ window.__ModuleLoader__.load({
2144
2197
  { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
2145
2198
  t.footer || '搜索',
2146
2199
  ),
2147
- h(Kbd, null, '⌘K'),
2200
+ h(Kbd, null, HOTKEY),
2148
2201
  );
2149
2202
  }
2150
2203
 
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * sandbox cannot reach:
7
7
  *
8
8
  * GET /cmdk/info → where this profile lives (home + profile dir)
9
- * GET /cmdk/sessions?q= → session titles + message-content hits
9
+ * GET /cmdk/sessions?q= → full session catalogue + message-content hits
10
10
  * POST /cmdk/open-profile → reveal the profile directory in Finder/Explorer
11
11
  * GET /cmdk/plugins → installed bundles (name, version, enabled, dir)
12
12
  * POST /cmdk/plugin → enable/disable one bundle via pluginManager
@@ -170,24 +170,6 @@ async function sessionCatalog(ctx) {
170
170
  return items;
171
171
  }
172
172
 
173
- /** Title hits from the warm catalogue, strongest first (prefix > substring). */
174
- function matchTitles(items, text) {
175
- const lower = text.toLowerCase();
176
- const scored = [];
177
- for (const item of items) {
178
- if (!item.title) continue;
179
- const title = item.title.toLowerCase();
180
- let score = -1;
181
- if (title === lower) score = 100;
182
- else if (title.startsWith(lower)) score = 80;
183
- else if (title.includes(lower)) score = 60;
184
- else if (item.cwd && item.cwd.toLowerCase().includes(lower)) score = 20;
185
- if (score > 0) scored.push({ item, score });
186
- }
187
- scored.sort((a, b) => b.score - a.score);
188
- return scored.map((entry) => entry.item);
189
- }
190
-
191
173
  /**
192
174
  * Content hits. The SQLite full-text index can be empty in the wild (the
193
175
  * native session search reports "no matches" on such machines), so this walks
@@ -202,7 +184,7 @@ async function matchContent(ctx, text, limit) {
202
184
  const cached = contentCache.get(text);
203
185
  if (cached && Date.now() - cached.at < CONTENT_TTL) return cached.items.slice(0, limit);
204
186
  const catalog = await sessionCatalog(ctx);
205
- const scan = catalog.filter((item) => item.title).slice(0, CONTENT_SCAN_SIZE);
187
+ const scan = catalog.slice(0, CONTENT_SCAN_SIZE);
206
188
 
207
189
  const hits = [];
208
190
  for (const item of scan) {
@@ -240,11 +222,6 @@ function snippetAround(text, needle) {
240
222
  }
241
223
 
242
224
 
243
- // ---------------------------------------------------------------------------
244
- // transcript export: the session log is a multi-frame zstd file, so it is
245
- // decompressed through the zstd CLI (Node's zstd only reads the first frame).
246
- // ---------------------------------------------------------------------------
247
-
248
225
  function workspaceStorePath() {
249
226
  return path.join(dshHome(), 'storages', 'workspace.json');
250
227
  }
@@ -277,71 +254,6 @@ function readWorkspaces() {
277
254
  };
278
255
  }
279
256
 
280
- function sessionDirOf(sessionId) {
281
- const root = path.join(dshHome(), 'sessions');
282
- let workspaces;
283
- try {
284
- workspaces = fs.readdirSync(root, { withFileTypes: true });
285
- } catch {
286
- return null;
287
- }
288
- const bare = sessionId.startsWith('session-') ? sessionId.slice('session-'.length) : sessionId;
289
- const wanted = `session-${bare}`;
290
- for (const workspace of workspaces) {
291
- if (!workspace.isDirectory()) continue;
292
- const candidate = path.join(root, workspace.name, wanted);
293
- if (fs.existsSync(path.join(candidate, 'session.v4.jsonl.zstd'))) return candidate;
294
- }
295
- return null;
296
- }
297
-
298
- function run(file, args) {
299
- return new Promise((resolve) => {
300
- execFile(file, args, { maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
301
- resolve({ ok: !error, stdout: stdout || '', stderr: stderr || (error && error.message) || '' });
302
- });
303
- });
304
- }
305
-
306
- function textOf(content) {
307
- if (!Array.isArray(content)) return '';
308
- const parts = [];
309
- for (const block of content) {
310
- if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
311
- }
312
- return parts.join('\n').trim();
313
- }
314
-
315
- function renderMarkdown(events, fallbackTitle) {
316
- let title = fallbackTitle || '会话导出';
317
- const lines = [];
318
- for (const event of events) {
319
- if (!event || typeof event !== 'object') continue;
320
- if (event.type === 'session/title' && event.data && typeof event.data.title === 'string') {
321
- title = event.data.title;
322
- }
323
- }
324
- lines.push(`# ${title}`, '');
325
- let turns = 0;
326
- for (const event of events) {
327
- if (!event || typeof event !== 'object') continue;
328
- const data = event.data || {};
329
- if (event.type === 'user/message') {
330
- if (!data.source || data.source.kind !== 'user') continue;
331
- const text = textOf(data.content || (data.message && data.message.content));
332
- if (!text) continue;
333
- turns += 1;
334
- lines.push(`## 你`, '', text, '');
335
- } else if (event.type === 'assistant/message') {
336
- const text = textOf(data.content || (data.message && data.message.content));
337
- if (!text) continue;
338
- lines.push(`## 助手`, '', text, '');
339
- }
340
- }
341
- lines.push('---', `共 ${turns} 轮对话`);
342
- return { title, markdown: lines.join('\n'), turns };
343
- }
344
-
345
257
  export function apply(ctx, config) {
346
258
  const server = ctx.get('webServer') || ctx.webServer;
347
259
  if (!server || typeof server.register !== 'function') {
@@ -388,24 +300,13 @@ export function apply(ctx, config) {
388
300
  try {
389
301
  const catalog = await sessionCatalog(ctx);
390
302
  if (!text) {
391
- const titled = catalog.filter((item) => item.title).slice(0, limit);
392
- sendJson(res, 200, { ok: true, items: titled, total: catalog.length });
303
+ sendJson(res, 200, { ok: true, items: catalog, hits: [], total: catalog.length });
393
304
  return;
394
305
  }
395
- const merged = [];
396
- const seen = new Set();
397
- for (const item of matchTitles(catalog, text).slice(0, limit)) {
398
- if (seen.has(item.sessionId)) continue;
399
- seen.add(item.sessionId);
400
- merged.push(item);
401
- }
402
- for (const item of await matchContent(ctx, text, limit)) {
403
- if (seen.has(item.sessionId)) continue;
404
- seen.add(item.sessionId);
405
- merged.push(item);
406
- if (merged.length >= limit) break;
407
- }
408
- sendJson(res, 200, { ok: true, items: merged, total: catalog.length });
306
+ // The client filters titles locally (fuzzy + pinyin); the Host only
307
+ // contributes the expensive part: message-content hits.
308
+ const hits = await matchContent(ctx, text, limit);
309
+ sendJson(res, 200, { ok: true, items: catalog, hits, total: catalog.length });
409
310
  } catch (error) {
410
311
  sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
411
312
  }
@@ -530,60 +431,6 @@ export function apply(ctx, config) {
530
431
  }),
531
432
  );
532
433
 
533
- disposers.push(
534
- server.register({
535
- kind: 'exact',
536
- path: ROUTE + '/export',
537
- handler: async (req, res) => {
538
- if (refused(ctx, req, res)) return;
539
- let url;
540
- try {
541
- url = new URL(req.url || ROUTE, 'http://localhost');
542
- } catch {
543
- sendJson(res, 400, { ok: false, error: 'bad url' });
544
- return;
545
- }
546
- const sessionId = (url.searchParams.get('id') || '').trim();
547
- if (!/^[\w-]{6,80}$/.test(sessionId)) {
548
- sendJson(res, 400, { ok: false, error: 'bad session id' });
549
- return;
550
- }
551
- try {
552
- const dir = sessionDirOf(sessionId);
553
- if (!dir) {
554
- sendJson(res, 404, { ok: false, error: 'session log not found' });
555
- return;
556
- }
557
- const zstd = await run('zstd', ['-d', '-c', path.join(dir, 'session.v4.jsonl.zstd')]);
558
- if (!zstd.ok) {
559
- sendJson(res, 500, { ok: false, error: 'zstd failed: ' + zstd.stderr.slice(0, 200) });
560
- return;
561
- }
562
- const events = [];
563
- for (const line of zstd.stdout.split('\n')) {
564
- const text = line.trim();
565
- if (!text) continue;
566
- try {
567
- events.push(JSON.parse(text));
568
- } catch {
569
- /* skip a broken line rather than losing the export */
570
- }
571
- }
572
- const rendered = renderMarkdown(events, sessionId);
573
- sendJson(res, 200, {
574
- ok: true,
575
- title: rendered.title,
576
- turns: rendered.turns,
577
- filename: rendered.title.replace(/[\\/:*?"<>|]+/g, '-').slice(0, 80) + '.md',
578
- markdown: rendered.markdown,
579
- });
580
- } catch (error) {
581
- sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
582
- }
583
- },
584
- }),
585
- );
586
-
587
434
  ctx.effect(() => () => {
588
435
  for (const dispose of disposers) {
589
436
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cmdk",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "⌘K command palette for DeepSeek Harness: jump to sessions, search message content, switch workspaces, toggle plugins.",
6
6
  "icon": "icon.svg",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "README.md",
22
+ "CHANGELOG.md",
22
23
  "LICENSE",
23
24
  "screenshot.png",
24
25
  "screenshot-actions.png",