dsh-vibegap 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +12 -2
  2. package/lib/client.js +158 -35
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -4,8 +4,9 @@ VibeGap 的 DeepSeek Harness 原生插件。Agent 持续运行 18 秒后,dsh w
4
4
  右下角会出现拼写单词卡;会话完成或等待确认时,完成当前单词后自动收起或继续。
5
5
  插件可独立运行,不要求安装 VibeGap Python 桌面端。
6
6
  如果本机 VibeGap Core 正在运行,插件会自动改用桌面端的当前词书和进度游标;
7
- Core 在页面打开后才由另一个 Agent 拉起也能自动接入。连接失败或运行中断线时,
8
- 会回退到浏览器内的独立进度,远端游标会同步回本地作为断线续用位置。
7
+ Core 在页面打开后才由另一个 Agent 拉起也能自动接入。为避免两张卡重复出现,
8
+ 共享模式下页内卡不自动弹出,但仍可用 `Ctrl+Alt+V` 手动呼出。运行中断线时
9
+ 保留当前词并自动重连,不会误跳到首次下载界面。
9
10
 
10
11
  ## 安装
11
12
 
@@ -46,6 +47,8 @@ dsh plugin --profile web remove dsh-vibegap
46
47
  本机 Core 可用时,单词和进度改由 `http://127.0.0.1:8765/panel/*` 提供,
47
48
  插件每 5 秒低频探测一次,且仅允许来自 `localhost` 或 `127.0.0.1` 的
48
49
  HTTP(S) 浏览器 Origin。DSH 单独使用时不会因此启动 Python/Core 进程。
50
+ 共享模式目前只发现默认端口 `8765`;若桌面端修改了 `daemon_port`,DSH 会保持
51
+ 独立本地模式。
49
52
 
50
53
  ## 使用
51
54
 
@@ -53,6 +56,7 @@ HTTP(S) 浏览器 Origin。DSH 单独使用时不会因此启动 Python/Core 进
53
56
  - 输入错误会清空当前拼写并计数。
54
57
  - `Tab` 显示或隐藏答案;看过答案后,本词按 fail 完成并前进。
55
58
  - `Esc` 或右上角 `×` 隐藏卡片;同一批运行中的会话不会再次弹出。
59
+ - `Ctrl+Alt+V` 手动呼出或隐藏卡片(dsh 标签页处于前台时有效,不受 18 秒规则限制)。
56
60
  - “发音”可手动播放美式发音,“自动发音”可随时开关。
57
61
 
58
62
  ## 开发
@@ -61,6 +65,12 @@ HTTP(S) 浏览器 Origin。DSH 单独使用时不会因此启动 Python/Core 进
61
65
  或其他构建步骤。修改 bundle 后刷新页面;修改 `package.json` 或
62
66
  `cordis.patch.yml` 后重启 `dsh web`。
63
67
 
68
+ 纯逻辑与 manifest 兼容性测试:
69
+
70
+ ```bash
71
+ npm test
72
+ ```
73
+
64
74
  服务端 `lib/index.js` 会在本机 VibeGap daemon 存在时继续上报 agent 生命周期;
65
75
  连接失败会静默降级,不影响 dsh。
66
76
 
package/lib/client.js CHANGED
@@ -35,12 +35,17 @@ window.__ModuleLoader__.load({
35
35
  { autoPronounce: true },
36
36
  { persist: { name: "vibegap.prefs" } },
37
37
  );
38
+ var posStore = runtime.createSnapshotStore(
39
+ { x: null, y: null },
40
+ { persist: { name: "vibegap.pos" } },
41
+ );
38
42
 
39
43
  var css = [
40
44
  ".vg-card{position:fixed;right:24px;bottom:24px;width:min(390px,calc(100vw - 32px));box-sizing:border-box;padding:16px;border:1px solid var(--dsw-alias-border-l,#d8d8d8);border-radius:14px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#202020);box-shadow:0 12px 34px rgba(0,0,0,.18);font-family:inherit;pointer-events:auto;outline:none;z-index:1}",
41
45
  ".vg-card:focus{border-color:var(--dsw-alias-border-focus,#5794ff);box-shadow:0 12px 34px rgba(0,0,0,.18),0 0 0 2px rgba(87,148,255,.2)}",
42
- ".vg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}",
46
+ ".vg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;cursor:move;user-select:none;touch-action:none}",
43
47
  ".vg-title{font-size:13px;font-weight:650;letter-spacing:.02em}",
48
+ ".vg-prog{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}",
44
49
  ".vg-close{min-width:28px!important;padding:0!important;font-size:18px!important}",
45
50
  ".vg-banner{margin:-2px 0 14px;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,#f2f3f5);font-size:12px;line-height:1.4}",
46
51
  ".vg-word{display:flex;flex-wrap:wrap;justify-content:center;gap:4px;min-height:48px;margin:16px 0 12px}",
@@ -54,6 +59,7 @@ window.__ModuleLoader__.load({
54
59
  ".vg-hint{margin-top:12px;text-align:center;color:var(--dsw-alias-label-tertiary,#888);font-size:11px}",
55
60
  ".vg-empty{padding:18px 4px 8px;text-align:center;color:var(--dsw-alias-label-secondary,#666);font-size:13px;line-height:1.6}",
56
61
  ".vg-error{margin-top:10px;color:var(--dsw-alias-state-danger-primary,#c93c37);font-size:12px}",
62
+ ".vg-reconnect{margin:8px 0 0;text-align:center;color:var(--dsw-alias-state-warning-primary,#9a6700);font-size:12px}",
57
63
  ".vg-shake{animation:vg-shake .28s ease-in-out}",
58
64
  "@keyframes vg-blink{50%{border-color:transparent}}",
59
65
  "@keyframes vg-shake{25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}",
@@ -189,6 +195,13 @@ window.__ModuleLoader__.load({
189
195
  draft.cursor = Math.min(Math.max(Number(remoteProgress.cursor), 0), draft.words.length - 1);
190
196
  });
191
197
  }
198
+ function daemonFailureState(current) {
199
+ if (current.mode === "daemon" || current.mode === "disconnected") {
200
+ return { mode: "disconnected", word: current.word };
201
+ }
202
+ if (current.mode === "probing") return { mode: "local", word: null };
203
+ return current;
204
+ }
192
205
  function useDaemonBackend() {
193
206
  var backend = React.useState({ mode: "probing", word: null });
194
207
  var current = useCell({ mode: "probing", word: null });
@@ -208,14 +221,15 @@ window.__ModuleLoader__.load({
208
221
  if (active) setBackend({ mode: "daemon", word: word });
209
222
  }
210
223
  } catch (_) {
211
- if (active && current.current.mode !== "local") setBackend({ mode: "local", word: null });
224
+ var failed = daemonFailureState(current.current);
225
+ if (active && failed !== current.current) setBackend(failed);
212
226
  }
213
227
  if (active) timer = setTimeout(probe, DAEMON_PROBE_MS);
214
228
  }
215
229
  probe();
216
230
  return function () { active = false; if (timer) clearTimeout(timer); };
217
231
  }, []);
218
- return backend;
232
+ return [backend[0], setBackend];
219
233
  }
220
234
 
221
235
  function advanceProgress(total) {
@@ -253,10 +267,16 @@ window.__ModuleLoader__.load({
253
267
  );
254
268
  }
255
269
 
256
- function useLifecycle(sessionState, visible, setVisible, showBanner, onRestart) {
270
+ function canAutoPopup(backendMode) {
271
+ return backendMode !== "daemon" && backendMode !== "disconnected";
272
+ }
273
+
274
+ function useLifecycle(sessionState, visible, setVisible, showBanner, onRestart, allowAutoPopup) {
257
275
  var ref = useCell({ running: false, rows: {}, suppressed: false, arm: null, pending: null });
258
276
  var runningRef = useCell(false);
259
277
  var visibleRef = useCell(visible);
278
+ var autoPopupRef = useCell(allowAutoPopup);
279
+ autoPopupRef.current = allowAutoPopup;
260
280
  React.useEffect(function () { visibleRef.current = visible; }, [visible]);
261
281
  React.useEffect(function () {
262
282
  var anyRunning = sessionState.ids.some(function (id) {
@@ -268,7 +288,7 @@ window.__ModuleLoader__.load({
268
288
  life.suppressed = false; life.pending = null;
269
289
  onRestart();
270
290
  if (!visibleRef.current) life.arm = setTimeout(function () {
271
- if (!runningRef.current || life.suppressed) return;
291
+ if (!runningRef.current || life.suppressed || !autoPopupRef.current) return;
272
292
  setVisible(true);
273
293
  if (life.pending) { showBanner(life.pending); life.pending = null; }
274
294
  }, POPUP_DELAY_MS);
@@ -288,34 +308,47 @@ window.__ModuleLoader__.load({
288
308
  return { state: ref, running: runningRef };
289
309
  }
290
310
 
311
+ function createTypingHandler(configRef) {
312
+ return function onKey(event) {
313
+ var config = configRef.current;
314
+ if (document.activeElement !== config.card.current) return;
315
+ if (event.key === "Escape") { event.preventDefault(); config.hide(); return; }
316
+ if (event.key === "Tab") {
317
+ event.preventDefault(); config.setPeeked(true); config.setTyped(0);
318
+ config.setRevealed(function (value) { return !value; }); return;
319
+ }
320
+ if (event.key.length !== 1 || !/[a-zA-Z'\- ]/.test(event.key)) return;
321
+ event.preventDefault();
322
+ if (config.revealed) config.setRevealed(false);
323
+ if (event.key.toLowerCase() === config.word[config.typed].toLowerCase()) {
324
+ var next = config.typed + 1; config.setTyped(next);
325
+ if (next === config.word.length) config.complete();
326
+ return;
327
+ }
328
+ config.setTypos(function (value) { return value + 1; });
329
+ config.setTyped(0); config.shake();
330
+ };
331
+ }
332
+
291
333
  function useTyping(config) {
334
+ var configRef = useCell(config);
335
+ configRef.current = config;
292
336
  React.useEffect(function () {
293
337
  if (!config.visible || !config.focused || !config.word || config.busy) return;
294
- function onKey(event) {
295
- if (document.activeElement !== config.card.current) return;
296
- if (event.key === "Escape") { event.preventDefault(); config.hide(); return; }
297
- if (event.key === "Tab") {
298
- event.preventDefault(); config.setPeeked(true); config.setTyped(0);
299
- config.setRevealed(function (value) { return !value; }); return;
300
- }
301
- if (event.key.length !== 1 || !/[a-zA-Z'\- ]/.test(event.key)) return;
302
- event.preventDefault();
303
- if (config.revealed) config.setRevealed(false);
304
- if (event.key.toLowerCase() === config.word[config.typed].toLowerCase()) {
305
- var next = config.typed + 1; config.setTyped(next);
306
- if (next === config.word.length) config.complete();
307
- return;
308
- }
309
- config.setTypos(function (value) { return value + 1; });
310
- config.setTyped(0); config.shake();
311
- }
338
+ var onKey = createTypingHandler(configRef);
312
339
  document.addEventListener("keydown", onKey);
313
340
  return function () { document.removeEventListener("keydown", onKey); };
314
- }, [config]);
341
+ }, [config.visible, config.focused, config.word, config.busy]);
315
342
  }
316
343
 
317
344
  function CardBody(props) {
318
- if (!props.word) return h(EmptyCard, { status: props.download, setStatus: props.setDownload });
345
+ if (!props.word) {
346
+ if (props.connectionLost) {
347
+ return h("div", { className: "vg-empty", role: "status" },
348
+ "桌面端连接已断开,正在自动重连");
349
+ }
350
+ return h(EmptyCard, { status: props.download, setStatus: props.setDownload });
351
+ }
319
352
  var translation = props.word.trans.join(";");
320
353
  return h(React.Fragment, null,
321
354
  h(LetterGrid, {
@@ -325,15 +358,17 @@ window.__ModuleLoader__.load({
325
358
  h("div", { className: "vg-trans", title: translation }, translation.slice(0, TRANSLATION_MAX_CHARS)),
326
359
  h("div", { className: "vg-meta" },
327
360
  props.word.usphone ? "/" + props.word.usphone + "/" : null,
328
- props.typos ? "错误 " + props.typos : "尚无错误",
361
+ props.typos ? " " + props.typos : null,
329
362
  props.outcome ? h("span", { role: "status" }, props.outcome === "fail" ? "已记为需复习" : "拼写正确") : null,
330
363
  ),
364
+ props.connectionLost ? h("div", { className: "vg-reconnect", role: "status" },
365
+ "桌面端连接已断开,当前词会在重连后继续") : null,
331
366
  h("div", { className: "vg-actions" },
332
367
  h(Button, { size: "sm", variant: "outline", onClick: props.pronounce }, "发音"),
333
368
  h(Button, { size: "sm", variant: "ghost", onClick: props.toggleAuto },
334
369
  "自动发音:" + (props.autoPronounce ? "开" : "关")),
335
370
  ),
336
- h("div", { className: "vg-hint" }, "点击卡片后拼写 · Tab 查看答案 · Esc 隐藏"),
371
+ h("div", { className: "vg-hint" }, "点击卡片后拼写 · Tab 查看答案 · Esc 隐藏 · Ctrl+Alt+V 呼出"),
337
372
  );
338
373
  }
339
374
 
@@ -344,6 +379,7 @@ window.__ModuleLoader__.load({
344
379
  peeked: React.useState(false), shaking: React.useState(false), busy: React.useState(false),
345
380
  outcome: React.useState(""), holdWord: React.useState(null),
346
381
  download: React.useState({ busy: false, error: "" }),
382
+ pos: React.useState(null),
347
383
  };
348
384
  }
349
385
 
@@ -362,12 +398,18 @@ window.__ModuleLoader__.load({
362
398
  function activeSelection(local, backend) {
363
399
  var remote = backend[0];
364
400
  if (remote.mode === "daemon" && remote.word) {
365
- return { ordered: [remote.word], word: remote.word, isDaemon: true };
401
+ return { ordered: [remote.word], word: remote.word, isDaemon: true,
402
+ connectionLost: false, remoteUnavailable: false };
366
403
  }
367
- return { ordered: local.ordered, word: local.word, isDaemon: false };
404
+ if (remote.mode === "disconnected") {
405
+ return { ordered: remote.word ? [remote.word] : [], word: remote.word, isDaemon: true,
406
+ connectionLost: true, remoteUnavailable: true };
407
+ }
408
+ return { ordered: local.ordered, word: local.word, isDaemon: false,
409
+ connectionLost: false, remoteUnavailable: false };
368
410
  }
369
411
 
370
- function useLifecycleBridge(sessionState, states, refs) {
412
+ function useLifecycleBridge(sessionState, states, refs, allowAutoPopup) {
371
413
  var showBanner = function (message) {
372
414
  refs.banner.current = message; states.banner[1](message);
373
415
  };
@@ -376,7 +418,9 @@ window.__ModuleLoader__.load({
376
418
  clearTimeout(refs.hideTimer.current); refs.hideTimer.current = null;
377
419
  showBanner(null); resetInput(states);
378
420
  };
379
- var tracker = useLifecycle(sessionState, states.visible[0], states.visible[1], showBanner, onRestart);
421
+ var tracker = useLifecycle(
422
+ sessionState, states.visible[0], states.visible[1], showBanner, onRestart, allowAutoPopup,
423
+ );
380
424
  return { tracker: tracker, showBanner: showBanner };
381
425
  }
382
426
  function resetInput(states) {
@@ -429,7 +473,7 @@ window.__ModuleLoader__.load({
429
473
  syncLocalProgress(result);
430
474
  var word = await panelRequest("/next-word");
431
475
  model.backend[1]({ mode: "daemon", word: word });
432
- } catch (_) { model.backend[1]({ mode: "local", word: null }); }
476
+ } catch (_) { model.backend[1](daemonFailureState(model.backend[0])); }
433
477
  }
434
478
 
435
479
  function shakeCard(states) {
@@ -437,6 +481,31 @@ window.__ModuleLoader__.load({
437
481
  setTimeout(function () { states.shaking[1](false); }, SHAKE_MS);
438
482
  }
439
483
 
484
+ function startDrag(model, event) {
485
+ if (event.target.closest("button,a,input,textarea,select")) return;
486
+ var card = model.refs.card.current;
487
+ if (!card || event.button !== 0) return;
488
+ event.preventDefault();
489
+ var rect = card.getBoundingClientRect();
490
+ var offsetX = event.clientX - rect.left;
491
+ var offsetY = event.clientY - rect.top;
492
+ var last = null;
493
+ function move(ev) {
494
+ last = {
495
+ x: Math.min(Math.max(ev.clientX - offsetX, 0), Math.max(window.innerWidth - rect.width, 0)),
496
+ y: Math.min(Math.max(ev.clientY - offsetY, 0), Math.max(window.innerHeight - 48, 0)),
497
+ };
498
+ model.states.pos[1](last);
499
+ }
500
+ function up() {
501
+ document.removeEventListener("pointermove", move);
502
+ document.removeEventListener("pointerup", up);
503
+ if (last) try { posStore.set(last); } catch (_) {}
504
+ }
505
+ document.addEventListener("pointermove", move);
506
+ document.addEventListener("pointerup", up);
507
+ }
508
+
440
509
  function useCardEffects(model) {
441
510
  var word = displayedWord(model);
442
511
  React.useEffect(function () { resetInput(model.states); }, [word && word.name]);
@@ -446,6 +515,29 @@ window.__ModuleLoader__.load({
446
515
  React.useEffect(function () {
447
516
  return function () { if (model.refs.hideTimer.current) clearTimeout(model.refs.hideTimer.current); };
448
517
  }, []);
518
+ React.useEffect(function () {
519
+ // 页面内手动唤醒:Ctrl+Alt+V 唤出/隐藏(dsh 标签页聚焦时有效)
520
+ function onSummon(event) {
521
+ if (!event.ctrlKey || !event.altKey || event.key.toLowerCase() !== "v") return;
522
+ event.preventDefault();
523
+ if (model.states.visible[0]) { hideCard(model, true); return; }
524
+ model.lifecycle.tracker.state.current.suppressed = false;
525
+ model.states.visible[1](true);
526
+ }
527
+ document.addEventListener("keydown", onSummon);
528
+ return function () { document.removeEventListener("keydown", onSummon); };
529
+ }, [model.states.visible[0]]);
530
+ React.useEffect(function () {
531
+ // 恢复上次拖到的位置;越出当前视口则放弃,回默认右下角
532
+ try {
533
+ var saved = posStore.getSnapshot();
534
+ if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y) &&
535
+ saved.x >= 0 && saved.y >= 0 &&
536
+ saved.x < window.innerWidth - 60 && saved.y < window.innerHeight - 48) {
537
+ model.states.pos[1]({ x: saved.x, y: saved.y });
538
+ }
539
+ } catch (_) {}
540
+ }, []);
449
541
  }
450
542
 
451
543
  function useCardTyping(model) {
@@ -454,7 +546,8 @@ window.__ModuleLoader__.load({
454
546
  useTyping({
455
547
  visible: states.visible[0], focused: states.focused[0],
456
548
  word: word && word.name,
457
- busy: states.busy[0], typed: states.typed[0], revealed: states.revealed[0],
549
+ busy: states.busy[0] || model.selection.remoteUnavailable,
550
+ typed: states.typed[0], revealed: states.revealed[0],
458
551
  card: model.refs.card, setTyped: states.typed[1], setTypos: states.typos[1],
459
552
  setRevealed: states.revealed[1], setPeeked: states.peeked[1],
460
553
  hide: function () { hideCard(model, true); },
@@ -468,13 +561,21 @@ window.__ModuleLoader__.load({
468
561
  return h(React.Fragment, null, h("style", { id: STYLE_ID }, css),
469
562
  h("section", {
470
563
  className: "vg-card", tabIndex: 0, ref: model.refs.card, "aria-label": "VibeGap 单词卡",
564
+ style: states.pos[0]
565
+ ? { left: states.pos[0].x + "px", top: states.pos[0].y + "px", right: "auto", bottom: "auto" }
566
+ : undefined,
471
567
  onFocus: function (event) { if (event.target === event.currentTarget) states.focused[1](true); },
472
568
  onBlur: function () { states.focused[1](false); },
473
569
  onMouseDown: function (event) {
474
570
  if (!event.target.closest("button,a,input,textarea,select")) model.refs.card.current.focus();
475
571
  },
476
572
  },
477
- h("div", { className: "vg-head" }, h("div", { className: "vg-title" }, "VibeGap"),
573
+ h("div", {
574
+ className: "vg-head", title: "按住拖动",
575
+ onPointerDown: function (event) { startDrag(model, event); },
576
+ },
577
+ h("div", { className: "vg-title" }, "VibeGap"),
578
+ model.progressText ? h("span", { className: "vg-prog" }, model.progressText) : null,
478
579
  h(Button, { className: "vg-close", size: "sm", variant: "ghost", title: "隐藏",
479
580
  onClick: function () { hideCard(model, true); } }, "×")),
480
581
  states.banner[0] ? h("div", { className: "vg-banner", role: "status" }, states.banner[0]) : null,
@@ -489,6 +590,7 @@ window.__ModuleLoader__.load({
489
590
  word: word, typed: states.typed[0], typos: states.typos[0], revealed: states.revealed[0],
490
591
  focused: states.focused[0], shaking: states.shaking[0], download: states.download[0],
491
592
  outcome: states.outcome[0], setDownload: states.download[1], autoPronounce: model.autoPronounce,
593
+ connectionLost: model.selection.connectionLost,
492
594
  pronounce: function () { if (word) safePronounce(word.name); },
493
595
  toggleAuto: function () { prefsStore.set({ autoPronounce: !model.autoPronounce }); },
494
596
  };
@@ -502,12 +604,24 @@ window.__ModuleLoader__.load({
502
604
  var prefs = useSnapshot(prefsStore);
503
605
  var states = useCardStates();
504
606
  var refs = { card: useCell(null), hideTimer: useCell(null), banner: useCell(null) };
505
- var lifecycle = useLifecycleBridge(sessionState, states, refs);
506
607
  var backend = useDaemonBackend();
608
+ var lifecycle = useLifecycleBridge(
609
+ sessionState, states, refs, canAutoPopup(backend[0].mode),
610
+ );
507
611
  var selection = activeSelection(useWordSelection(progress), backend);
612
+ var word = selection.word;
613
+ var progressText = "";
614
+ if (selection.isDaemon && word && Number.isFinite(Number(word.total))) {
615
+ progressText = word.position + "/" + word.total +
616
+ (selection.connectionLost ? " · 桌面端重连中" : " · 共享桌面进度");
617
+ } else if (selection.ordered.length) {
618
+ var cursorShown = Math.min(Math.max(Number(progress.cursor) || 0, 0), selection.ordered.length - 1);
619
+ progressText = cursorShown + "/" + selection.ordered.length + " · 本地进度";
620
+ }
508
621
  var model = {
509
622
  states: states, refs: refs, lifecycle: lifecycle, selection: selection, backend: backend,
510
623
  autoPronounce: !prefs || prefs.autoPronounce !== false,
624
+ progressText: progressText,
511
625
  };
512
626
  useCardEffects(model);
513
627
  useCardTyping(model);
@@ -516,6 +630,15 @@ window.__ModuleLoader__.load({
516
630
 
517
631
  exports.apply = apply;
518
632
  exports.inject = inject;
633
+ exports.__test = {
634
+ activeSelection: activeSelection,
635
+ canAutoPopup: canAutoPopup,
636
+ createTypingHandler: createTypingHandler,
637
+ daemonFailureState: daemonFailureState,
638
+ normalizeWords: normalizeWords,
639
+ transitionNotice: transitionNotice,
640
+ useTyping: useTyping,
641
+ };
519
642
  return module.exports;
520
643
  },
521
644
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vibegap",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Self-contained vocabulary flashcards for DeepSeek Harness web sessions",
5
5
  "main": "lib/index.js",
6
6
  "exports": {
@@ -10,6 +10,9 @@
10
10
  },
11
11
  "keywords": ["dsh-plugin", "vibegap", "vocabulary", "productivity"],
12
12
  "files": ["lib", "cordis.patch.yml", "README.md"],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js"
15
+ },
13
16
  "repository": {
14
17
  "type": "git",
15
18
  "url": "git+https://github.com/ktao732084-arch/vibegap.git",