dsh-nerd-mode 0.4.6 → 0.5.0

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 CHANGED
@@ -48,7 +48,8 @@ A chat lesson ends when the answer arrives. Nothing checks that you understood i
48
48
  - **Graded checks.** Unseen questions and a graded teach-back, with the rubric written before the question. Only an answer you produced changes the record.
49
49
  - **A record that outlives the chat.** Graph, states, and dated log survive restarts, and the HTML Book has full-text search.
50
50
  - **Progress without opening a file.** The Study tab reads the Tracker for this conversation and shows one progress percentage, the previous, current, and next Section, the shaky Concepts, and the three most recent Concept cycles. It never writes.
51
- - **Lectures you can find again.** The Lectures tab lists the saved lectures for the selected Course, and marks read, unread, and archived rows.
51
+ - **Lectures you can find again.** The Lectures tab keeps the course picker in the page header, lists only the courses that exist, and marks each lecture as a draft, a dated lecture, or an archived one. The archived lectures fold behind a count.
52
+ - **Your study conversations in one place.** The Study Sessions panel in the left sidebar lists every conversation where Nerd Mode was switched on at least once, in three groups: In-depth, Quick, and Past. Each group folds from its heading, In-depth and Quick start open, Past starts closed, and a click opens that conversation.
52
53
 
53
54
  ## Install
54
55
 
@@ -225,10 +226,11 @@ package.json
225
226
  │ ├── dsh-nerd-mode → lib/index.js
226
227
  │ │ ├── session projection — on/off state, folded from the session log
227
228
  │ │ ├── prompt section — assets/section.md at order 550, while on
228
- │ │ └── /nerd-mode — the switch
229
+ │ │ ├── /nerd-mode — the switch
230
+ │ │ └── GET /api/study-sessions — the read-only session index
229
231
  │ ├── study-plus-skills — skills/ as a filesystem skill provider
230
232
  │ └── goal packages — disabled
231
- └── dsh.client → client.js: pill, Study tab, Lectures tab, Settings section
233
+ └── dsh.client → client.js: pill, Study tab, Lectures tab, Study Sessions panel, Settings section
232
234
  ```
233
235
 
234
236
  **State** lives in a session projection. It folds from the session log, so compaction, resume, and fork keep it. The prompt section has text only while that state is on.
@@ -237,6 +239,8 @@ package.json
237
239
 
238
240
  **Settings** reach the model as a short defaults block appended to that section, only during a tracked session. They never change the tool set.
239
241
 
242
+ **Session history** is a second, read-only store. `lib/study-sessions.js` scans the conversation logs in chunks, keys each file by its size and modification time, and writes `~/.dsh/study-sessions-index.json`, which sits outside every workspace. The panel reads it through one route that answers only GET, refuses a request with no session cookie (401) and a foreign host or origin (403).
243
+
240
244
  **Patch scope** covers the whole profile. It loads `skills/` with a `!!js` expression, so review patch changes as host-process code. It also disables the three goal packages, because the built-in goal tool turns one study answer into an auto-continuing run.
241
245
 
242
246
  ## What the switch changes
@@ -277,9 +281,10 @@ npm run check
277
281
 
278
282
  | Path | Holds |
279
283
  |---|---|
280
- | `lib/index.js` | plugin entry: projection, prompt section, command, session defaults |
284
+ | `lib/index.js` | plugin entry: projection, prompt section, command, session defaults, session index route |
281
285
  | `lib/fold.js` | the state fold and its constants |
282
- | `client.js` | composer pill, Study and Lectures tabs, Settings section |
286
+ | `lib/study-sessions.js` | the session history scan and its index |
287
+ | `client.js` | composer pill, Study and Lectures tabs, Study Sessions panel, Settings section |
283
288
  | `assets/section.md` | the teaching policy prompt |
284
289
  | `skills/` | bundled skills |
285
290
  | `test/` | one file per surface, run by `node --test` |
package/README.zh.md CHANGED
@@ -48,7 +48,8 @@
48
48
  - **有评分的检查。** 没见过的题加上要评分的复述,评分标准在出题之前就写好。只有你自己答出来的内容才会改写记录。
49
49
  - **记录比对话活得久。** 概念图、状态和带日期的日志能跨重启保留,HTML 书支持全文搜索。
50
50
  - **不用翻文件就能看进度。** Study 标签页读当前对话的记录文件,显示进度百分比,上一节、当前节、下一节三格,状态是 shaky 的概念,以及最近三轮概念循环。它只读,不写。
51
- - **讲义找得回来。** Lectures 标签页按选中的 Course 列出保存过的讲义,并标出已读、未读和已归档的行。
51
+ - **讲义找得回来。** Lectures 标签页把课程下拉放在页头,只列出真实存在的 Course,并把每份讲义标成草稿、带日期或已归档。已归档的讲义收在一个折叠区里,标题旁显示数量。
52
+ - **学习会话都在一处。** 左侧边栏的 Study Sessions 面板列出所有至少打开过一次 Nerd Mode 的会话,分「深入」「快速」「过去」三组。每组都能点标题折叠,深入和快速默认展开,过去默认收起,点一行就打开那个会话。
52
53
 
53
54
  ## 安装
54
55
 
@@ -225,10 +226,11 @@ package.json
225
226
  │ ├── dsh-nerd-mode → lib/index.js
226
227
  │ │ ├── session projection — 开关状态,从会话日志折叠出来
227
228
  │ │ ├── prompt section — assets/section.md,顺序 550,只在开着时注入
228
- │ │ └── /nerd-mode — 开关
229
+ │ │ ├── /nerd-mode — 开关
230
+ │ │ └── GET /api/study-sessions — 只读的学习会话索引
229
231
  │ ├── study-plus-skills — skills/ 作为文件系统技能来源
230
232
  │ └── goal packages — 已禁用
231
- └── dsh.client → client.js:胶囊、Study 标签页、Lectures 标签页、设置页
233
+ └── dsh.client → client.js:胶囊、Study 标签页、Lectures 标签页、Study Sessions 面板、设置页
232
234
  ```
233
235
 
234
236
  **状态**存在一个 session projection 里,从会话日志折叠出来,所以压缩上下文、恢复会话、分叉都不丢。只有那个状态是「开着」时,提示段落才有内容。
@@ -237,6 +239,8 @@ package.json
237
239
 
238
240
  **设置**只在带记录的会话里,以一小段默认值附在那段提示后面。它们从不改变工具目录。
239
241
 
242
+ **会话历史**是第二份只读记录。`lib/study-sessions.js` 分块扫描会话日志,按文件大小和修改时间记账,写到 `~/.dsh/study-sessions-index.json`,这个文件在每个工作区之外。面板通过一条路由读它:只接受 GET,没有会话 cookie 的请求返回 401,主机名或来源不对返回 403。
243
+
240
244
  **补丁范围**覆盖整个 profile。补丁用 `!!js` 表达式加载 `skills/`,改之前要按宿主进程代码来审。补丁还禁用了三个 goal 包,因为内置的 goal 工具会把一次讲解变成自动续跑的循环。
241
245
 
242
246
  ## 开关改了什么
@@ -277,9 +281,10 @@ npm run check
277
281
 
278
282
  | 路径 | 内容 |
279
283
  |---|---|
280
- | `lib/index.js` | 插件入口:投影、提示段落、命令、会话默认值 |
284
+ | `lib/index.js` | 插件入口:投影、提示段落、命令、会话默认值、会话索引路由 |
281
285
  | `lib/fold.js` | 状态折叠与常量 |
282
- | `client.js` | 输入框胶囊、Study 与 Lectures 标签页、设置页 |
286
+ | `lib/study-sessions.js` | 会话历史扫描与索引 |
287
+ | `client.js` | 输入框胶囊、Study 与 Lectures 标签页、Study Sessions 面板、设置页 |
283
288
  | `assets/section.md` | 教学策略提示词 |
284
289
  | `skills/` | 随包的技能 |
285
290
  | `test/` | 每个界面一个文件,由 `node --test` 运行 |
package/client.js CHANGED
@@ -1556,52 +1556,37 @@ window.__ModuleLoader__.load({
1556
1556
  return model;
1557
1557
  }
1558
1558
 
1559
- // The page fills the dock body so the picker and course name stay in view
1560
- // while only the lecture rows scroll. The scrollport owns the side padding,
1561
- // so its bar sits at the page edge rather than inside the text.
1562
- const lecturesPanelStyle = { ...panelStyle, height: '100%', padding: '24px 0 0' };
1563
- const lecturesHeaderStyle = { ...panelHeaderStyle, padding: '0 16px' };
1564
- const lecturesMessageStyle = { ...panelMutedStyle, padding: '0 16px' };
1565
- const lecturesHeadingStyle = { ...panelHeadingStyle, padding: '0 16px' };
1566
- const lecturesSectionStyle = { ...panelSectionStyle, flex: 1, minHeight: 0 };
1567
- const lecturesScrollStyle = {
1568
- display: 'flex',
1569
- flex: 1,
1570
- flexDirection: 'column',
1571
- gap: 10,
1572
- minHeight: 0,
1573
- overflowY: 'auto',
1574
- padding: '0 16px 40px',
1575
- };
1576
-
1577
- // The rows and the fold are the only elements the host cannot style for us.
1578
- const LECTURES_CSS = '.lectures-entry:focus-visible,.lectures-fold:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary, currentColor);outline-offset:2px}';
1579
- const lectureRowStyle = {
1580
- background: 'none',
1581
- border: 'none',
1582
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1583
- color: 'inherit',
1584
- cursor: 'pointer',
1585
- font: 'inherit',
1586
- lineHeight: '22px',
1587
- overflowWrap: 'anywhere',
1588
- padding: '2px 4px',
1589
- textAlign: 'left',
1590
- whiteSpace: 'normal',
1591
- width: '100%',
1592
- };
1593
- const lectureFoldStyle = {
1594
- background: 'none',
1595
- border: '1px solid var(--dsw-alias-border-l2)',
1596
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1597
- color: 'var(--dsw-alias-label-tertiary)',
1598
- cursor: 'pointer',
1599
- font: 'inherit',
1600
- lineHeight: '22px',
1601
- padding: '6px 10px',
1602
- textAlign: 'left',
1603
- width: '100%',
1604
- };
1559
+ // The picker and Course name stay in view while only the lecture rows
1560
+ // scroll. The scroller owns the side padding, so its bar sits at the page
1561
+ // edge rather than inside the text.
1562
+ const LECTURES_CSS = `.lectures-page{box-sizing:border-box;height:100%;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
1563
+ .lectures-head{display:flex;align-items:center;gap:12px;padding:24px 32px 0}
1564
+ .lectures-picker{display:flex;align-items:center;gap:8px;flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1565
+ .lectures-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}
1566
+ .lectures-group{display:flex;flex-direction:column;flex:1;min-height:0}
1567
+ .lectures-group-head{display:flex;align-items:center;gap:8px;padding:20px 32px 8px}
1568
+ .lectures-group-title{margin:0;font-size:14px;line-height:22px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1569
+ .lectures-count{flex:none;font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
1570
+ .lectures-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px;padding:0 32px 32px}
1571
+ .lectures-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
1572
+ .lectures-item{margin:0 -8px}
1573
+ .lectures-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:28px minmax(0,1fr);grid-template-areas:"tile title" "tile meta";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
1574
+ .lectures-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
1575
+ .lectures-row:focus-visible{outline:none}
1576
+ .lectures-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1577
+ .lectures-tile{grid-area:tile;align-self:center;width:28px;height:28px;display:grid;place-items:center;border-radius:6px;background:var(--dsw-alias-fill-tsp-secondary);color:var(--dsw-alias-label-secondary);font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
1578
+ .lectures-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow-wrap:anywhere}
1579
+ .lectures-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
1580
+ .lectures-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1581
+ .lectures-fold{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;margin-top:12px;padding:0;border:none;background:none;font-size:14px;line-height:22px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:pointer}
1582
+ .lectures-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1583
+ .lectures-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
1584
+ .lectures-fold[aria-expanded="false"] .lectures-chev{transform:rotate(-90deg)}
1585
+ .lectures-status{margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1586
+ .lectures-error{display:flex;align-items:center;gap:8px;margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary)}
1587
+ .lectures-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
1588
+ .lectures-empty{margin:0;padding:48px 20px;text-align:center;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary)}
1589
+ .lectures-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
1605
1590
 
1606
1591
  const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1607
1592
 
@@ -1636,26 +1621,38 @@ window.__ModuleLoader__.load({
1636
1621
  return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
1637
1622
  }
1638
1623
 
1639
- // The whole row is the button, so its text is also its accessible name.
1624
+ // The visible title is clipped, so the label repeats it in full.
1640
1625
  function lectureRow(entry, onOpen) {
1641
1626
  const mark = lectureMark(entry);
1642
- return React.createElement('li', { key: entry.number },
1627
+ const label = mark === null
1628
+ ? `${entry.number}, ${entry.title}`
1629
+ : `${entry.number}, ${entry.title}, ${mark}`;
1630
+ return React.createElement('li', { key: entry.number, className: 'lectures-item' },
1643
1631
  React.createElement('button', {
1644
1632
  type: 'button',
1645
- className: 'lectures-entry',
1633
+ className: 'lectures-row',
1634
+ 'aria-label': label,
1646
1635
  onClick: () => onOpen(entry),
1647
- style: lectureRowStyle,
1648
- }, `${entry.number} · ${entry.title}${mark === null ? '' : ` · ${mark}`}`));
1636
+ },
1637
+ React.createElement('span', { className: 'lectures-tile', 'aria-hidden': 'true' }, entry.number),
1638
+ React.createElement('span', { className: 'lectures-title' }, entry.title),
1639
+ mark === null
1640
+ ? null
1641
+ : React.createElement('span', { className: 'lectures-meta' },
1642
+ entry.mark === 'dated'
1643
+ ? mark
1644
+ : React.createElement('span', { className: 'lectures-chip' }, mark))));
1649
1645
  }
1650
1646
 
1651
1647
  function lectureTopic(group, open, onToggle, onOpen) {
1652
1648
  const main = group.entries.filter((entry) => entry.mark !== 'archived');
1653
1649
  const archived = group.entries.filter((entry) => entry.mark === 'archived');
1650
+ const archiveId = `lectures-archive-${group.topic}`;
1654
1651
  const rows = [];
1655
1652
  if (group.entries.length === 0) {
1656
- rows.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
1653
+ rows.push(React.createElement('p', { key: 'empty', className: 'lectures-empty' }, 'No lectures yet.'));
1657
1654
  } else if (main.length > 0) {
1658
- rows.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
1655
+ rows.push(React.createElement('ul', { key: 'main', className: 'lectures-rows' }, ...main.map((entry) => lectureRow(entry, onOpen))));
1659
1656
  }
1660
1657
  if (archived.length > 0) {
1661
1658
  rows.push(React.createElement('button', {
@@ -1663,17 +1660,35 @@ window.__ModuleLoader__.load({
1663
1660
  type: 'button',
1664
1661
  className: 'lectures-fold',
1665
1662
  'aria-expanded': open,
1663
+ // A closed fold has no list to control, so the reference would dangle.
1664
+ 'aria-controls': open ? archiveId : undefined,
1665
+ 'aria-label': `Archived lectures ${archived.length}`,
1666
1666
  onClick: () => onToggle(group.topic),
1667
- style: lectureFoldStyle,
1668
- }, `Archived lectures (${archived.length})`));
1667
+ },
1668
+ React.createElement('span', null, 'Archived lectures'),
1669
+ React.createElement('span', { className: 'lectures-count' }, String(archived.length)),
1670
+ React.createElement('svg', {
1671
+ 'aria-hidden': 'true',
1672
+ className: 'lectures-chev',
1673
+ fill: 'none',
1674
+ height: 12,
1675
+ stroke: 'currentColor',
1676
+ strokeLinecap: 'round',
1677
+ strokeLinejoin: 'round',
1678
+ strokeWidth: 1.6,
1679
+ viewBox: '0 0 16 16',
1680
+ width: 12,
1681
+ }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' }))));
1669
1682
  if (open) {
1670
- rows.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1683
+ rows.push(React.createElement('ul', { key: 'archive', className: 'lectures-rows', id: archiveId }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1671
1684
  }
1672
1685
  }
1673
1686
  // The Course key remounts the section on a switch, so its scrollport starts at the top.
1674
- return React.createElement('section', { key: group.topic, style: lecturesSectionStyle },
1675
- React.createElement('h2', { key: 'heading', style: lecturesHeadingStyle }, group.topic),
1676
- React.createElement('div', { style: lecturesScrollStyle }, ...rows));
1687
+ return React.createElement('section', { key: group.topic, className: 'lectures-group' },
1688
+ React.createElement('div', { className: 'lectures-group-head' },
1689
+ React.createElement('h2', { className: 'lectures-group-title' }, group.topic),
1690
+ React.createElement('span', { className: 'lectures-count' }, String(main.length))),
1691
+ React.createElement('div', { className: 'lectures-scroll' }, ...rows));
1677
1692
  }
1678
1693
 
1679
1694
  function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
@@ -1707,15 +1722,18 @@ window.__ModuleLoader__.load({
1707
1722
 
1708
1723
  let nodes;
1709
1724
  if (content.status === 'loading') {
1710
- nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: lecturesMessageStyle }, 'Loading the lectures…')];
1725
+ nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, className: 'lectures-status' }, 'Loading the lectures…')];
1711
1726
  } else if (content.error === null && !content.rootFound) {
1712
- nodes = [React.createElement('p', { style: lecturesMessageStyle }, `No Course in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
1727
+ nodes = [React.createElement('p', { className: 'lectures-empty' },
1728
+ `No Course in ${TRACKER_ROOT}/. Run `,
1729
+ React.createElement('code', null, '/study-plus'),
1730
+ ' to create one.')];
1713
1731
  } else {
1714
1732
  nodes = content.error === null
1715
1733
  ? []
1716
- : [React.createElement('p', { role: 'alert', style: lecturesMessageStyle }, `The lecture library could not be read: ${content.error}.`)];
1717
- const picker = topicPicker(content.courses, content.course ?? selected, choose);
1718
- if (picker !== null) nodes.push(React.createElement('div', { style: lecturesHeaderStyle }, picker));
1734
+ : [React.createElement('p', { role: 'alert', className: 'lectures-error' },
1735
+ React.createElement('span', { className: 'lectures-dot', 'aria-hidden': 'true' }),
1736
+ `The lecture library could not be read: ${content.error}.`)];
1719
1737
  if (content.course !== null) {
1720
1738
  nodes.push(lectureTopic(
1721
1739
  { topic: content.course, entries: content.entries },
@@ -1725,11 +1743,24 @@ window.__ModuleLoader__.load({
1725
1743
  ));
1726
1744
  }
1727
1745
  }
1746
+
1747
+ // One Course needs no picker: the page already shows that Course.
1748
+ const courses = content.courses ?? [];
1749
+ const picker = courses.length < 2 ? null : React.createElement('label', { className: 'lectures-picker' },
1750
+ React.createElement('span', null, 'Course'),
1751
+ React.createElement('select', {
1752
+ 'aria-label': 'Course',
1753
+ className: 'lectures-select',
1754
+ value: content.course ?? selected,
1755
+ onChange: (event) => choose(event.target.value),
1756
+ }, ...courses.map((course) => React.createElement('option', { key: course, value: course }, course))));
1728
1757
  return React.createElement('div', {
1729
1758
  role: 'region',
1730
1759
  'aria-label': LECTURES_LABEL,
1731
- style: lecturesPanelStyle,
1732
- }, React.createElement('style', null, LECTURES_CSS), ...nodes);
1760
+ className: 'lectures-page',
1761
+ }, React.createElement('style', null, LECTURES_CSS),
1762
+ React.createElement('header', { className: 'lectures-head' }, picker),
1763
+ ...nodes);
1733
1764
  }
1734
1765
 
1735
1766
  // A page type names no resource pattern and no `multiple`, so the host
@@ -1747,6 +1778,365 @@ window.__ModuleLoader__.load({
1747
1778
  }],
1748
1779
  };
1749
1780
 
1781
+ // --- Study Sessions panel ------------------------------------------------
1782
+
1783
+ const STUDY_SESSIONS_ID = 'study-sessions';
1784
+ const STUDY_SESSIONS_ORDER = 30;
1785
+ const STUDY_SESSIONS_API = '/dsh-nerd-mode/api/study-sessions';
1786
+ const STUDY_SESSIONS_TITLE = { en: 'Study Sessions', zh: '学习会话' };
1787
+ const STUDY_QUICK = { en: 'Quick', zh: '快速' };
1788
+ const STUDY_IN_DEPTH = { en: 'In-depth', zh: '深入' };
1789
+ const STUDY_PAST = { en: 'Past', zh: '过去' };
1790
+
1791
+ // A host without the session package still gets the page and its empty line.
1792
+ const NO_SESSIONS_HOOK = () => null;
1793
+
1794
+ // A failed rescan or a remount must not blank the panel, so the last good scan outlives the component.
1795
+ let studySessionsCache = null;
1796
+
1797
+ /**
1798
+ * Read one history chunk from the host route.
1799
+ *
1800
+ * Returns `{ phase, done, total, sessions, cursor }`. Throws an Error whose message names the failure:
1801
+ * a missing route (404/405 or a body that is not JSON) and any non-OK reply both count.
1802
+ */
1803
+ async function readSessionChunk(cursor) {
1804
+ const response = await fetch(`${STUDY_SESSIONS_API}?cursor=${cursor}`);
1805
+ // The static fallback owns an unregistered path, so it answers with an empty body.
1806
+ if (response.status === 404 || response.status === 405) throw new Error('the host route is not available');
1807
+ // A rejected request carries plain text, so name the status before the body is parsed.
1808
+ if (response.status === 401 || response.status === 403) {
1809
+ throw new Error(`the host denied the request (HTTP ${response.status})`);
1810
+ }
1811
+ const body = await response.json().catch(() => null);
1812
+ if (body === null) throw new Error('the host returned no JSON');
1813
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1814
+ const sessions = body?.sessions;
1815
+ if (typeof sessions !== 'object' || sessions === null) throw new Error('the host returned no session history');
1816
+ return {
1817
+ phase: body.phase === 'ready' ? 'ready' : 'scanning',
1818
+ done: Number.isFinite(body.done) ? body.done : 0,
1819
+ total: Number.isFinite(body.total) ? body.total : 0,
1820
+ sessions,
1821
+ // The spec names no cursor field, so accept one or fall back to the processed count.
1822
+ cursor: Number.isFinite(body.cursor) ? body.cursor : Number.isFinite(body.done) ? body.done : cursor,
1823
+ };
1824
+ }
1825
+
1826
+ /**
1827
+ * Scan the host history index from cursor 0, merging each chunk into `sessions`.
1828
+ *
1829
+ * Calls `onChunk` after every reply so the page can move its progress line. Returns the final
1830
+ * scan state. A host that repeats a cursor cannot make progress, so the loop stops there.
1831
+ */
1832
+ async function scanSessionHistory(sessions, onChunk) {
1833
+ let state = { status: 'scanning', sessions, done: 0, total: 0, cursor: 0 };
1834
+ let previous = 0;
1835
+ for (;;) {
1836
+ const chunk = await readSessionChunk(state.cursor);
1837
+ state = {
1838
+ status: chunk.phase,
1839
+ // A ready reply carries the host's whole index, so it replaces stale cached ids.
1840
+ sessions: chunk.phase === 'ready' ? chunk.sessions : { ...state.sessions, ...chunk.sessions },
1841
+ done: chunk.done,
1842
+ total: chunk.total,
1843
+ cursor: chunk.cursor,
1844
+ };
1845
+ onChunk(state);
1846
+ if (state.status === 'ready' || chunk.cursor <= previous) return state;
1847
+ previous = chunk.cursor;
1848
+ }
1849
+ }
1850
+
1851
+ /** Return the last folder in a workspace path; Windows and POSIX separators both count. */
1852
+ function workspaceName(cwd) {
1853
+ if (typeof cwd !== 'string') return '';
1854
+ return cwd.split(/[\\/]+/).filter((part) => part !== '').pop() ?? '';
1855
+ }
1856
+
1857
+ const RELATIVE_UNITS = [
1858
+ ['year', 31536000000],
1859
+ ['month', 2592000000],
1860
+ ['day', 86400000],
1861
+ ['hour', 3600000],
1862
+ ['minute', 60000],
1863
+ ['second', 1000],
1864
+ ];
1865
+
1866
+ /** Return a locale-aware relative time such as `2 hours ago`; a missing time reads as an empty string. */
1867
+ function relativeTime(updatedAt, language) {
1868
+ if (!Number.isFinite(updatedAt)) return '';
1869
+ const elapsed = updatedAt - Date.now();
1870
+ const [unit, size] = RELATIVE_UNITS.find(([, step]) => Math.abs(elapsed) >= step) ?? RELATIVE_UNITS[5];
1871
+ return new Intl.RelativeTimeFormat(language, { numeric: 'auto' }).format(Math.round(elapsed / size), unit);
1872
+ }
1873
+
1874
+ /** Join the live session list with the history index. A history id with no live row is dropped. */
1875
+ function sessionRows(snapshot, history) {
1876
+ const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1877
+ const byId = snapshot?.byId ?? {};
1878
+ const rows = [];
1879
+ for (const id of ids) {
1880
+ const row = byId[id];
1881
+ if (row === undefined || !Object.hasOwn(history, id)) continue;
1882
+ rows.push({
1883
+ id,
1884
+ title: String(row.displayTitle ?? id),
1885
+ workspace: workspaceName(row.cwd),
1886
+ updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : 0,
1887
+ active: row.projectionValues?.studyMode?.active === true,
1888
+ studyPlus: history[id]?.studyPlus === true,
1889
+ });
1890
+ }
1891
+ return rows.sort((left, right) => right.updatedAt - left.updatedAt);
1892
+ }
1893
+
1894
+ /** Split rows into Quick, In-depth, and Past; a missing live switch value counts as off. */
1895
+ function groupSessionRows(rows) {
1896
+ const groups = { quick: [], inDepth: [], past: [] };
1897
+ for (const row of rows) {
1898
+ if (!row.active) groups.past.push(row);
1899
+ else if (row.studyPlus) groups.inDepth.push(row);
1900
+ else groups.quick.push(row);
1901
+ }
1902
+ return groups;
1903
+ }
1904
+
1905
+ // The tile is decoration; the group heading and the row label carry the meaning.
1906
+ function studyTile(kind) {
1907
+ const glyph = (children) => React.createElement('svg', {
1908
+ fill: 'none',
1909
+ height: 16,
1910
+ stroke: 'currentColor',
1911
+ strokeLinecap: 'round',
1912
+ strokeLinejoin: 'round',
1913
+ strokeWidth: 1.6,
1914
+ viewBox: '0 0 16 16',
1915
+ width: 16,
1916
+ }, ...children);
1917
+ if (kind === 'book') return glyph(BOOK.props.children);
1918
+ if (kind === 'quick') {
1919
+ return glyph([
1920
+ React.createElement('path', { d: 'M13.4 8.9a3.7 3.7 0 0 1-3.7 3.7H6.4L3.4 14.6v-2.5A3.7 3.7 0 0 1 1.8 9V7.5a3.7 3.7 0 0 1 3.7-3.7h4.2a3.7 3.7 0 0 1 3.7 3.7Z' }),
1921
+ React.createElement('path', { d: 'M5.2 7.3h5.6M5.2 9.3h3.4' }),
1922
+ ]);
1923
+ }
1924
+ return glyph([
1925
+ React.createElement('circle', { cx: 8, cy: 8, r: 5.6 }),
1926
+ React.createElement('path', { d: 'M8 5.2V8l1.9 1.3' }),
1927
+ ]);
1928
+ }
1929
+
1930
+ // Inline because the client bundle cannot read assets/.
1931
+ function StudySessionsIcon({ size = 16, active = false }) {
1932
+ return React.createElement(
1933
+ 'svg',
1934
+ {
1935
+ 'aria-hidden': 'true',
1936
+ fill: 'none',
1937
+ height: size,
1938
+ stroke: 'currentColor',
1939
+ strokeLinecap: 'round',
1940
+ strokeLinejoin: 'round',
1941
+ strokeWidth: active ? 2 : 1.6,
1942
+ viewBox: '0 0 16 16',
1943
+ width: size,
1944
+ },
1945
+ React.createElement('path', { d: 'M2.5 4h11M2.5 8h11M2.5 12h7' }),
1946
+ React.createElement('circle', {
1947
+ cx: 12.5,
1948
+ cy: 12,
1949
+ fill: 'currentColor',
1950
+ opacity: active ? 1 : 0.45,
1951
+ r: 1.2,
1952
+ stroke: 'none',
1953
+ }),
1954
+ );
1955
+ }
1956
+
1957
+ function StudySessionsPage({ useSessions, locale, openSession }) {
1958
+ const t = useT(locale);
1959
+ const language = locale.getSnapshot()?.active ?? 'en';
1960
+ const [scan, setScan] = React.useState(() => studySessionsCache === null
1961
+ ? { status: 'scanning', sessions: {}, done: 0, total: 0, cursor: 0 }
1962
+ : { ...studySessionsCache, status: 'scanning' });
1963
+ // Only a folded key reads true: In-depth and Quick start open, Past starts folded.
1964
+ const [folded, setFolded] = React.useState({ inDepth: false, quick: false, past: true });
1965
+ const snapshot = useSessions((state) => state);
1966
+
1967
+ React.useEffect(() => {
1968
+ let live = true;
1969
+ // A cached list seeds the scan, so rows stay while the host rescans.
1970
+ scanSessionHistory(studySessionsCache?.sessions ?? {}, (chunk) => {
1971
+ if (!live) return;
1972
+ studySessionsCache = chunk;
1973
+ setScan(chunk);
1974
+ }).catch((error) => {
1975
+ if (!live) return;
1976
+ setScan((previous) => ({
1977
+ ...previous,
1978
+ status: 'failed',
1979
+ reason: error?.message ?? 'the host did not answer',
1980
+ }));
1981
+ });
1982
+ return () => { live = false; };
1983
+ }, []);
1984
+
1985
+ const grouped = groupSessionRows(sessionRows(snapshot, scan.sessions));
1986
+
1987
+ // The row is one target, so its label names the title, workspace, time, and mode.
1988
+ const rowNode = (row, past) => {
1989
+ const time = relativeTime(row.updatedAt, language);
1990
+ const chip = past ? t(row.studyPlus ? STUDY_IN_DEPTH : STUDY_QUICK) : null;
1991
+ const label = [row.title, row.workspace, time, chip]
1992
+ .filter((part) => part !== '' && part !== null)
1993
+ .join(', ');
1994
+ return React.createElement('li', { className: 'study-ss-item', key: row.id },
1995
+ React.createElement('button', {
1996
+ type: 'button',
1997
+ className: 'study-ss-row',
1998
+ 'aria-label': label,
1999
+ onClick: () => openSession(row.id),
2000
+ },
2001
+ React.createElement('span', { className: 'study-ss-tile', 'aria-hidden': 'true' },
2002
+ studyTile(past ? 'past' : row.studyPlus ? 'book' : 'quick')),
2003
+ React.createElement('span', { className: 'study-ss-title' }, row.title),
2004
+ React.createElement('span', { className: 'study-ss-meta' },
2005
+ row.workspace === '' ? null : React.createElement('span', { className: 'study-ss-workspace' }, row.workspace),
2006
+ chip === null ? null : React.createElement('span', {
2007
+ className: 'study-ss-chip',
2008
+ 'data-study-chip': '',
2009
+ }, chip)),
2010
+ time === '' ? null : React.createElement('span', { className: 'study-ss-time' }, time)));
2011
+ };
2012
+
2013
+ const nodes = [];
2014
+ if (scan.status === 'scanning') {
2015
+ const percent = scan.total > 0 ? Math.round((scan.done / scan.total) * 100) : 0;
2016
+ nodes.push(React.createElement('div', { key: 'progress', className: 'study-ss-scan' },
2017
+ React.createElement('p', {
2018
+ role: 'status',
2019
+ 'aria-busy': true,
2020
+ className: 'study-ss-status',
2021
+ }, t({
2022
+ en: `Scanning session history… ${scan.done} of ${scan.total}`,
2023
+ zh: `正在扫描会话历史… ${scan.done} / ${scan.total}`,
2024
+ })),
2025
+ React.createElement('div', { className: 'study-ss-track', 'aria-hidden': 'true' },
2026
+ React.createElement('div', { className: 'study-ss-fill', style: { width: `${percent}%` } }))));
2027
+ }
2028
+ if (scan.status === 'failed') {
2029
+ nodes.push(React.createElement('p', {
2030
+ key: 'failure',
2031
+ role: 'alert',
2032
+ className: 'study-ss-error',
2033
+ },
2034
+ React.createElement('span', { className: 'study-ss-dot', 'aria-hidden': 'true' }),
2035
+ t({
2036
+ en: `The session index could not be read: ${scan.reason}`,
2037
+ zh: `无法读取会话索引:${scan.reason}`,
2038
+ })));
2039
+ }
2040
+ if (scan.status === 'ready' && Object.keys(scan.sessions).length === 0) {
2041
+ nodes.push(React.createElement('p', { key: 'empty', className: 'study-ss-empty' },
2042
+ t({
2043
+ en: 'No study session yet. Turn on Nerd Mode from the composer bar, or run',
2044
+ zh: '还没有学习会话。在输入栏打开 Nerd Mode,或运行',
2045
+ }),
2046
+ ' ',
2047
+ React.createElement('code', null, '/nerd-mode on'),
2048
+ t({ en: '.', zh: '。' })));
2049
+ }
2050
+
2051
+ const group = (key, label, list, past) => {
2052
+ if (list.length === 0) return null;
2053
+ const open = folded[key] === false;
2054
+ const listId = `study-ss-rows-${key}`;
2055
+ return React.createElement('section', { key, className: 'study-ss-group' },
2056
+ React.createElement('h2', { className: 'study-ss-group-title' },
2057
+ React.createElement('button', {
2058
+ type: 'button',
2059
+ className: 'study-ss-fold',
2060
+ 'aria-expanded': open,
2061
+ // A folded group has no list to control, so the reference would dangle.
2062
+ 'aria-controls': open ? listId : undefined,
2063
+ 'aria-label': `${t(label)} ${list.length}`,
2064
+ onClick: () => setFolded((previous) => ({ ...previous, [key]: !previous[key] })),
2065
+ },
2066
+ React.createElement('span', null, t(label)),
2067
+ React.createElement('span', { className: 'study-ss-count' }, String(list.length)),
2068
+ React.createElement('svg', {
2069
+ 'aria-hidden': 'true',
2070
+ className: 'study-ss-chev',
2071
+ fill: 'none',
2072
+ height: 12,
2073
+ stroke: 'currentColor',
2074
+ strokeLinecap: 'round',
2075
+ strokeLinejoin: 'round',
2076
+ strokeWidth: 1.6,
2077
+ viewBox: '0 0 16 16',
2078
+ width: 12,
2079
+ }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' })))),
2080
+ open
2081
+ ? React.createElement('ul', { className: 'study-ss-rows', id: listId },
2082
+ ...list.map((row) => rowNode(row, past)))
2083
+ : null);
2084
+ };
2085
+ nodes.push(group('inDepth', STUDY_IN_DEPTH, grouped.inDepth, false));
2086
+ nodes.push(group('quick', STUDY_QUICK, grouped.quick, false));
2087
+ nodes.push(group('past', STUDY_PAST, grouped.past, true));
2088
+
2089
+ return React.createElement('div', {
2090
+ role: 'region',
2091
+ 'aria-label': t(STUDY_SESSIONS_TITLE),
2092
+ className: 'study-ss-page',
2093
+ }, React.createElement('style', null, STUDY_SESSIONS_CSS),
2094
+ React.createElement('header', { className: 'study-ss-head', 'data-window-drag': '' },
2095
+ React.createElement('h1', { className: 'study-ss-title' }, t(STUDY_SESSIONS_TITLE)),
2096
+ React.createElement('p', { className: 'study-ss-intro' }, t({
2097
+ en: 'Every conversation where Nerd Mode was on.',
2098
+ zh: '开启过 Nerd Mode 的会话都在这里。',
2099
+ }))),
2100
+ ...nodes);
2101
+ }
2102
+
2103
+ // The page shell, rows, and fold live in CSS so a container query can fold
2104
+ // the time under the title on narrow panes. Tokens come from the host theme.
2105
+ const STUDY_SESSIONS_CSS = `.study-ss-page{box-sizing:border-box;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:32px;padding:0 clamp(24px,4vw,48px) 48px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);container-type:inline-size}
2106
+ .study-ss-page>*{width:100%;max-width:960px}
2107
+ .study-ss-head{display:flex;flex-direction:column;padding-top:28px}
2108
+ [data-platform="darwin"] .study-ss-head{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px))}
2109
+ .study-ss-title{font-size:20px;line-height:28px;font-weight:500;margin:0}
2110
+ .study-ss-intro{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}
2111
+ .study-ss-group{display:flex;flex-direction:column;gap:8px}
2112
+ .study-ss-group-title{font-size:14px;line-height:22px;font-weight:500;margin:0}
2113
+ .study-ss-fold{display:inline-flex;align-items:center;gap:8px;padding:0;background:none;border:none;font:inherit;color:inherit;cursor:pointer}
2114
+ .study-ss-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2115
+ .study-ss-count{font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
2116
+ .study-ss-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
2117
+ .study-ss-fold[aria-expanded="false"] .study-ss-chev{transform:rotate(-90deg)}
2118
+ .study-ss-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
2119
+ .study-ss-item{margin:0 -8px}
2120
+ .study-ss-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:32px minmax(0,1fr) auto;grid-template-areas:"tile title time" "tile meta time";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
2121
+ .study-ss-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
2122
+ .study-ss-row:focus-visible{outline:none}
2123
+ .study-ss-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2124
+ .study-ss-tile{grid-area:tile;align-self:center;width:32px;height:32px;display:grid;place-items:center;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
2125
+ .study-ss-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2126
+ .study-ss-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary);flex-wrap:wrap}
2127
+ .study-ss-workspace{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2128
+ .study-ss-time{grid-area:time;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;margin-top:2px}
2129
+ .study-ss-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
2130
+ @container (max-width:520px){.study-ss-row{grid-template-areas:"tile title title" "tile meta time"}.study-ss-time{margin-top:0}}
2131
+ .study-ss-scan{display:flex;flex-direction:column;gap:10px}
2132
+ .study-ss-status{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:0}
2133
+ .study-ss-track{height:2px;background:var(--dsw-alias-border-l3);border-radius:999px;overflow:hidden}
2134
+ .study-ss-fill{height:100%;background:var(--dsw-alias-state-business-primary)}
2135
+ .study-ss-error{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary);margin:0}
2136
+ .study-ss-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
2137
+ .study-ss-empty{text-align:center;padding:48px 20px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary);margin:0}
2138
+ .study-ss-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
2139
+
1750
2140
  return {
1751
2141
  inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
1752
2142
  apply(ctx) {
@@ -1774,6 +2164,35 @@ window.__ModuleLoader__.load({
1774
2164
  locale: localeCtx.locale,
1775
2165
  })),
1776
2166
  );
2167
+ // One id addresses both seats: the sidebar row selects the keyed main page.
2168
+ ctx.slots.inject('sidebar.panellist', () =>
2169
+ ctx.slots.register({
2170
+ name: 'sidebar.panellist',
2171
+ id: STUDY_SESSIONS_ID,
2172
+ order: STUDY_SESSIONS_ORDER,
2173
+ // The label owns its translator: the slot's `t` prop belongs to the icon.
2174
+ label: () => localeCtx.locale.resolveText(STUDY_SESSIONS_TITLE),
2175
+ }, StudySessionsIcon),
2176
+ );
2177
+ ctx.slots.inject('main', () =>
2178
+ ctx.slots.register({
2179
+ name: 'main',
2180
+ key: STUDY_SESSIONS_ID,
2181
+ inject: () => ({
2182
+ locale: localeCtx.locale,
2183
+ openSession: (id) => {
2184
+ try {
2185
+ ctx.get('uiWorkspace')?.openSession(id);
2186
+ } catch {
2187
+ // A stale row must not take the panel down.
2188
+ }
2189
+ },
2190
+ }),
2191
+ }, (props) => React.createElement(StudySessionsPage, {
2192
+ ...props,
2193
+ useSessions: typeof props.useSessions === 'function' ? props.useSessions : NO_SESSIONS_HOOK,
2194
+ })),
2195
+ );
1777
2196
  });
1778
2197
  ctx.slots.inject(VIEW_SLOT, () =>
1779
2198
  ctx.slots.register({
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { COMMAND, KEY, fold, init } from './fold.js'
14
+ import { scan } from './study-sessions.js'
14
15
  import z from '@deepseek-ai/schemastery'
15
16
 
16
17
  export const name = 'dsh-nerd-mode'
@@ -38,6 +39,7 @@ export const Config = z.object({
38
39
  const SECTION = 'study:policy'
39
40
  // Between PLAN_POLICY (500) and TEAM_POLICY (600).
40
41
  const SECTION_ORDER = 550
42
+ const STUDY_SESSIONS_PATH = '/dsh-nerd-mode/api/study-sessions'
41
43
  const studyPlusDefaultsBySession = new WeakMap()
42
44
  const directStudyPlusBySession = new WeakMap()
43
45
  // A PTC skill load leaves no message in the history, so keep its id for later
@@ -162,6 +164,53 @@ const projection = {
162
164
  },
163
165
  }
164
166
 
167
+ function sendJson(res, status, body) {
168
+ res.writeHead(status, {
169
+ 'content-type': 'application/json; charset=utf-8',
170
+ 'cache-control': 'no-store',
171
+ })
172
+ res.end(JSON.stringify(body))
173
+ }
174
+
175
+ function cursorOf(req) {
176
+ const match = /[?&]cursor=(\d+)/.exec(req?.url ?? '')
177
+ return match === null ? 0 : Number.parseInt(match[1], 10)
178
+ }
179
+
180
+ /**
181
+ * Build the read-only history route handler.
182
+ *
183
+ * `webServer.register` adds no auth fence, so the connection service decides
184
+ * first: a missing cookie is 401 and a bad Host or Origin is 403.
185
+ *
186
+ * @param options
187
+ * @param options.connection - the host connection service
188
+ * @param options.indexFile - forwarded to `scan`; tests point it at a temp file
189
+ * @param options.sessionsRoot - forwarded to `scan`; tests point it at a temp tree
190
+ * @returns {(req, res) => Promise<void>} replies `{ phase, cursor, done,
191
+ * total, sessions }` with `no-store`, `{ error }` with status 500, or the
192
+ * rejection status
193
+ */
194
+ export function createStudySessionsHandler({ connection, ...scanOptions }) {
195
+ return async function studySessionsRoute(req, res) {
196
+ const rejection = connection.requestRejection(req)
197
+ if (rejection !== undefined) {
198
+ res.writeHead(rejection)
199
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
200
+ return
201
+ }
202
+ if (req.method !== 'GET') {
203
+ sendJson(res, 405, { error: 'Method not allowed.' })
204
+ return
205
+ }
206
+ try {
207
+ sendJson(res, 200, scan({ ...scanOptions, cursor: cursorOf(req) }))
208
+ } catch (error) {
209
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
210
+ }
211
+ }
212
+ }
213
+
165
214
  /**
166
215
  * @param config - `section` is an optional overlay text; a missing value uses
167
216
  * the bundled `assets/section.md`.
@@ -220,4 +269,18 @@ export function apply(ctx, config) {
220
269
  },
221
270
  })
222
271
  })
272
+
273
+ // The web server is optional: a host without it still loads the switch and
274
+ // the prompt section, and the panel shows its failure line.
275
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
276
+ webCtx.effect(
277
+ () =>
278
+ webCtx.webServer.register({
279
+ kind: 'exact',
280
+ path: STUDY_SESSIONS_PATH,
281
+ handler: createStudySessionsHandler({ connection: webCtx.connection }),
282
+ }),
283
+ 'dsh-nerd-mode: study sessions route',
284
+ )
285
+ })
223
286
  }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The Study Sessions history index. It reads the session logs the host writes
3
+ * and caches the two facts the panel needs: whether Nerd Mode was ever on, and
4
+ * whether a completed `/study-plus` invocation appears. Kept free of host
5
+ * imports like `lib/fold.js`, so it runs under plain node. See ADR 0021.
6
+ */
7
+
8
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { zstdDecompressSync } from 'node:zlib'
12
+
13
+ const COMMAND = 'nerd-mode'
14
+ const STUDY_PLUS = 'study-plus'
15
+ const SESSION_FILE = 'session.v4.jsonl.zstd'
16
+ const DEFAULT_INDEX_FILE = join(homedir(), '.dsh', 'study-sessions-index.json')
17
+ const DEFAULT_SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
18
+ const DEFAULT_CHUNK_SIZE = 20
19
+ const MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
20
+ // The token must stand alone: `not/study-plus` and `/study-plussed` are not
21
+ // invocations. This is the same boundary the live command parser uses.
22
+ const DIRECT_COMMAND = /(^|\s)\/study-plus(?=\s|$)/
23
+
24
+ function isRecord(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
28
+ function messageText(message) {
29
+ const content = message?.content
30
+ if (!Array.isArray(content)) return ''
31
+ return content
32
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
33
+ .map((block) => block.text)
34
+ .join('\n')
35
+ }
36
+
37
+ function skillNameOf(argumentText) {
38
+ if (typeof argumentText !== 'string') return undefined
39
+ try {
40
+ return JSON.parse(argumentText)?.name
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read the two history facts from one session's decoded events.
48
+ * @param events - decoded session log events, in log order
49
+ * @returns {{ nerd: boolean, studyPlus: boolean }} `nerd` means Nerd Mode was
50
+ * switched on at least once, `studyPlus` means a completed `/study-plus`
51
+ * invocation appears. Malformed events are ignored.
52
+ */
53
+ export function classify(events) {
54
+ const successfulCommands = new Set()
55
+ const wantedCommands = new Set()
56
+ const skillCalls = new Set()
57
+ const completedCalls = new Set()
58
+ let studyPlus = false
59
+
60
+ for (const event of events) {
61
+ const data = event?.data
62
+ if (event?.type === 'command/run') {
63
+ if (data?.name !== COMMAND) continue
64
+ // A bare /nerd-mode means on, matching lib/fold.js.
65
+ const args = typeof data.args === 'string' ? data.args : ''
66
+ if (args.trim().toLowerCase() !== 'off' && typeof data.commandId === 'string') {
67
+ wantedCommands.add(data.commandId)
68
+ }
69
+ } else if (event?.type === 'command/done') {
70
+ if (data?.kind === 'success' && typeof data.commandId === 'string') {
71
+ successfulCommands.add(data.commandId)
72
+ }
73
+ } else if (event?.type === 'user/message') {
74
+ const source = data?.source
75
+ if (source?.kind === 'skill-invocation') {
76
+ if (source.name === STUDY_PLUS) studyPlus = true
77
+ } else if (source?.kind === 'user' && DIRECT_COMMAND.test(messageText(data))) {
78
+ studyPlus = true
79
+ }
80
+ } else if (event?.type === 'tool/call') {
81
+ if (data?.name === 'skill' && typeof data.callId === 'string' && skillNameOf(data.arguments) === STUDY_PLUS) {
82
+ skillCalls.add(data.callId)
83
+ }
84
+ } else if (event?.type === 'tool/result') {
85
+ const message = data?.message
86
+ if (message?.isError !== true && typeof message?.source?.callId === 'string') {
87
+ completedCalls.add(message.source.callId)
88
+ }
89
+ } else if (event?.type === 'tool/ptc-dispatch') {
90
+ // A skill load inside run_code arrives as a dispatch and carries its own outcome.
91
+ if (data?.name === 'skill' && data?.arguments?.name === STUDY_PLUS && data?.isError !== true) {
92
+ studyPlus = true
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!studyPlus) {
98
+ studyPlus = [...skillCalls].some((callId) => completedCalls.has(callId))
99
+ }
100
+ return {
101
+ nerd: [...wantedCommands].some((commandId) => successfulCommands.has(commandId)),
102
+ studyPlus,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Decode a session log into events.
108
+ *
109
+ * The file is a chain of zstd frames, one per append batch. Whole-file
110
+ * decompression stops at the first frame, which holds only the header, so each
111
+ * frame is decoded on its own.
112
+ *
113
+ * @param file - path to a `session.v4.jsonl.zstd` file
114
+ * @returns the decoded events; malformed frames and lines are dropped
115
+ * @throws when the file itself cannot be read
116
+ */
117
+ export function decodeSessionFile(file) {
118
+ const bytes = readFileSync(file)
119
+ const starts = []
120
+ for (let offset = 0; offset <= bytes.length - MAGIC.length; offset += 1) {
121
+ if (MAGIC.every((byte, index) => bytes[offset + index] === byte)) starts.push(offset)
122
+ }
123
+
124
+ const events = []
125
+ for (let index = 0; index < starts.length; index += 1) {
126
+ let text
127
+ try {
128
+ text = zstdDecompressSync(bytes.subarray(starts[index], starts[index + 1] ?? bytes.length)).toString('utf8')
129
+ } catch {
130
+ // A magic byte sequence can occur inside compressed data, so a failed
131
+ // slice is expected. See docs/agents/dsh-session-api.md.
132
+ continue
133
+ }
134
+ for (const line of text.split('\n')) {
135
+ if (line === '') continue
136
+ try {
137
+ events.push(JSON.parse(line))
138
+ } catch {
139
+ // A crash can leave a partial trailing line. One bad record must not
140
+ // hide the rest of the log.
141
+ }
142
+ }
143
+ }
144
+ return events
145
+ }
146
+
147
+ function readIndex(file) {
148
+ let text
149
+ try {
150
+ text = readFileSync(file, 'utf8')
151
+ } catch (error) {
152
+ if (error?.code === 'ENOENT') return {}
153
+ // A missing index is a first run. Any other failure reaches the panel.
154
+ throw error
155
+ }
156
+ try {
157
+ const index = JSON.parse(text)
158
+ return isRecord(index) ? index : {}
159
+ } catch {
160
+ // A corrupt cache is rebuilt from the logs, not reported as a failure.
161
+ return {}
162
+ }
163
+ }
164
+
165
+ function writeIndex(file, index) {
166
+ mkdirSync(dirname(file), { recursive: true })
167
+ const temp = `${file}.tmp`
168
+ writeFileSync(temp, JSON.stringify(index))
169
+ // Rename keeps the previous index readable if the process dies mid-write.
170
+ renameSync(temp, file)
171
+ }
172
+
173
+ function listSessionFiles(root) {
174
+ let workspaces
175
+ try {
176
+ workspaces = readdirSync(root, { withFileTypes: true })
177
+ } catch (error) {
178
+ if (error?.code === 'ENOENT') return { files: [], complete: true }
179
+ throw error
180
+ }
181
+
182
+ const files = []
183
+ let complete = true
184
+ for (const workspace of workspaces) {
185
+ if (!workspace.isDirectory()) continue
186
+ let sessions
187
+ try {
188
+ sessions = readdirSync(join(root, workspace.name), { withFileTypes: true })
189
+ } catch {
190
+ // One locked workspace must not blank the others, and must not prune them.
191
+ complete = false
192
+ continue
193
+ }
194
+ for (const session of sessions) {
195
+ if (!session.isDirectory()) continue
196
+ const file = join(root, workspace.name, session.name, SESSION_FILE)
197
+ try {
198
+ const stat = statSync(file)
199
+ if (stat.isFile()) {
200
+ files.push({ id: session.name, file, mtimeMs: stat.mtimeMs, size: stat.size })
201
+ }
202
+ } catch {
203
+ // A session folder without a readable log is not a session yet.
204
+ }
205
+ }
206
+ }
207
+ files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
208
+ return { files, complete }
209
+ }
210
+
211
+ /**
212
+ * Classify one chunk of the session tree and persist the index.
213
+ *
214
+ * @param options
215
+ * @param options.cursor - index of the first file in the sorted session list to
216
+ * process; negative and non-integer values read as 0
217
+ * @param options.indexFile - index path; defaults to
218
+ * `~/.dsh/study-sessions-index.json`
219
+ * @param options.sessionsRoot - session tree; defaults to `~/.dsh/sessions`
220
+ * @param options.chunkSize - files to process per call; defaults to 20
221
+ * @param options.decode - decoder override for tests; defaults to
222
+ * `decodeSessionFile`
223
+ * @returns {{ phase: 'pending' | 'ready', cursor: number, done: number,
224
+ * total: number, sessions: Record<string, { studyPlus: boolean }> }} `cursor`
225
+ * is the next call's starting index and equals `total` once `phase` is
226
+ * `ready`. `done` counts files visited so far. A key in `sessions` means Nerd
227
+ * Mode was on at least once for that id.
228
+ * @throws when the index or the session tree cannot be read or written
229
+ */
230
+ export function scan({
231
+ cursor = 0,
232
+ indexFile = DEFAULT_INDEX_FILE,
233
+ sessionsRoot = DEFAULT_SESSIONS_ROOT,
234
+ chunkSize = DEFAULT_CHUNK_SIZE,
235
+ decode = decodeSessionFile,
236
+ } = {}) {
237
+ const { files, complete } = listSessionFiles(sessionsRoot)
238
+ const index = readIndex(indexFile)
239
+ if (complete) {
240
+ const live = new Set(files.map(({ id }) => id))
241
+ for (const id of Object.keys(index)) {
242
+ if (!live.has(id)) delete index[id]
243
+ }
244
+ }
245
+
246
+ const start = Number.isInteger(cursor) && cursor > 0 ? cursor : 0
247
+ const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE
248
+ const end = Math.min(start + size, files.length)
249
+ for (let position = start; position < end; position += 1) {
250
+ const { id, file, mtimeMs, size: bytes } = files[position]
251
+ const previous = index[id]
252
+ if (previous?.mtimeMs === mtimeMs && previous?.size === bytes) continue
253
+
254
+ let flags
255
+ try {
256
+ flags = classify(decode(file))
257
+ } catch {
258
+ // An unreadable session keeps its previous record and retries next scan.
259
+ continue
260
+ }
261
+ index[id] = {
262
+ mtimeMs,
263
+ size: bytes,
264
+ // Compaction can drop the original event. A fact that disappeared from a
265
+ // rewritten log must not resurface as a missing session.
266
+ nerd: flags.nerd || previous?.nerd === true,
267
+ studyPlus: flags.studyPlus,
268
+ }
269
+ }
270
+ writeIndex(indexFile, index)
271
+
272
+ const sessions = {}
273
+ for (const [id, entry] of Object.entries(index)) {
274
+ if (entry?.nerd === true) sessions[id] = { studyPlus: entry.studyPlus === true }
275
+ }
276
+ return {
277
+ phase: end >= files.length ? 'ready' : 'pending',
278
+ cursor: end,
279
+ done: end,
280
+ total: files.length,
281
+ sessions,
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "dsh-nerd-mode",
3
- "version": "0.4.6",
4
- "description": "A study switch and study skills for DeepSeek Harness.",
5
- "license": "MIT",
6
- "type": "module",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/bocchii-the-code/dsh-nerd-mode.git"
10
- },
11
- "homepage": "https://github.com/bocchii-the-code/dsh-nerd-mode#readme",
12
- "bugs": {
13
- "url": "https://github.com/bocchii-the-code/dsh-nerd-mode/issues"
14
- },
15
- "main": "./lib/index.js",
16
- "exports": {
17
- ".": "./lib/index.js",
18
- "./client": "./client.js",
19
- "./package.json": "./package.json"
20
- },
21
- "files": [
22
- "lib",
23
- "client.js",
24
- "assets",
25
- "cordis.patch.yml",
26
- "skills",
27
- "README.md",
28
- "README.zh.md",
29
- "NOTICE.md",
30
- "LICENSE"
31
- ],
32
- "dsh": {
33
- "bundle": {
34
- "patch": "./cordis.patch.yml"
35
- },
36
- "client": {
37
- "platform": "web",
38
- "immediately": true,
39
- "inject": [
40
- "@deepseek-ai/dsh-client-ui-conversation"
41
- ]
42
- }
43
- },
44
- "scripts": {
45
- "test": "node --test",
46
- "lint": "eslint .",
47
- "check": "npm run lint && npm test"
48
- },
49
- "peerDependencies": {
50
- "@deepseek-ai/schemastery": "^3.18.5"
51
- },
52
- "engines": {
53
- "node": "^22.19.0 || >=24.0.0"
54
- },
55
- "keywords": [
56
- "dsh-plugin",
57
- "deepseek-harness",
58
- "study",
59
- "tutor",
60
- "agent-skill"
61
- ],
62
- "publishConfig": {
63
- "access": "public"
64
- },
65
- "devDependencies": {
66
- "@deepseek-ai/schemastery": "^3.18.5",
67
- "eslint": "^10.13.0",
68
- "globals": "^17.13.0"
69
- }
70
- }
1
+ {
2
+ "name": "dsh-nerd-mode",
3
+ "version": "0.5.0",
4
+ "description": "A study switch and study skills for DeepSeek Harness.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bocchii-the-code/dsh-nerd-mode.git"
10
+ },
11
+ "homepage": "https://github.com/bocchii-the-code/dsh-nerd-mode#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/bocchii-the-code/dsh-nerd-mode/issues"
14
+ },
15
+ "main": "./lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./client.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "client.js",
24
+ "assets",
25
+ "cordis.patch.yml",
26
+ "skills",
27
+ "README.md",
28
+ "README.zh.md",
29
+ "NOTICE.md",
30
+ "LICENSE"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ]
42
+ }
43
+ },
44
+ "scripts": {
45
+ "test": "node --test",
46
+ "lint": "eslint .",
47
+ "check": "npm run lint && npm test"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/schemastery": "^3.18.5"
51
+ },
52
+ "engines": {
53
+ "node": "^22.19.0 || >=24.0.0"
54
+ },
55
+ "keywords": [
56
+ "dsh-plugin",
57
+ "deepseek-harness",
58
+ "study",
59
+ "tutor",
60
+ "agent-skill"
61
+ ],
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "devDependencies": {
66
+ "@deepseek-ai/schemastery": "^3.18.5",
67
+ "eslint": "^10.13.0",
68
+ "globals": "^17.13.0"
69
+ }
70
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study
3
- description: "[v0.4.6] Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output."
3
+ description: "[v0.5.0] Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output."
4
4
  argument-hint: "<concept or task>"
5
5
  ---
6
6
  # Study
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study-plus
3
- description: "[v0.4.6] Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router."
3
+ description: "[v0.5.0] Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router."
4
4
  argument-hint: "<course-slug>, or empty to continue the current Course"
5
5
  ---
6
6
  # Study Plus