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 +10 -5
- package/README.zh.md +10 -5
- package/client.js +485 -66
- package/lib/index.js +63 -0
- package/lib/study-sessions.js +283 -0
- package/package.json +70 -70
- package/skills/study/SKILL.md +1 -1
- package/skills/study-plus/SKILL.md +1 -1
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
|
|
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
|
-
│ │
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
│ │
|
|
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
|
-
| `
|
|
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
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
1633
|
+
className: 'lectures-row',
|
|
1634
|
+
'aria-label': label,
|
|
1646
1635
|
onClick: () => onOpen(entry),
|
|
1647
|
-
|
|
1648
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
1668
|
-
|
|
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',
|
|
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,
|
|
1675
|
-
React.createElement('
|
|
1676
|
-
|
|
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,
|
|
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', {
|
|
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',
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1732
|
-
}, React.createElement('style', null, LECTURES_CSS),
|
|
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
|
-
"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
|
+
}
|
package/skills/study/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: study
|
|
3
|
-
description: "[v0.
|
|
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.
|
|
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
|