dsh-theone 0.3.16 → 0.3.17

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
@@ -104,6 +104,7 @@ The database location and entry identifier switch TheOne to different data, so t
104
104
  - DSH keeps the original conversations and tool results; TheOne keeps only its catalog, summaries, links and routing records in its own SQLite database (`$DSH_HOME/theone/contexts.db`, `~/.dsh/theone/` by default).
105
105
  - Text sent to the router or written into briefings has API keys, passwords and similar secrets removed.
106
106
  - A topic marked **Do not share** never appears in other topics' briefings, recent-chat excerpts or lookups.
107
+ - Notices from the author are read from `https://yulid.org/theone/notice.json` with a plain request that sends none of your data; turn them off in Settings.
107
108
  - When main chat grows long, DSH compacts it through TheOne: frequently used topics keep longer summaries and their latest turns, rarely used ones keep a short status. This makes no model call.
108
109
  - When a new version is out, an **Update** button appears on the right of the TheOne entry in the sidebar: one click installs it through DSH's plugin manager and reloads TheOne in place, without restarting DSH (a DSH without plugin hot reload applies it at the next restart). Your topics stay in the database.
109
110
  </details>
package/README.zh.md CHANGED
@@ -104,6 +104,7 @@
104
104
  - 原始对话和工具结果由 DSH 保存;TheOne 只在自己的 SQLite 数据库(`$DSH_HOME/theone/contexts.db`,默认 `~/.dsh/theone/`)里保存目录、摘要、关联和路由记录。
105
105
  - 发给路由和写进参考简报的内容会去掉 API Key、密码等敏感信息。
106
106
  - 标记为「不共享」的话题,不会出现在其他话题的简报、最近对话和查阅结果里。
107
+ - 作者发布的公告从 `https://yulid.org/theone/notice.json` 读取,只是一次普通的下载,不发送你的任何数据;可以在设置里关闭。
107
108
  - 主聊天变长时,DSH 通过 TheOne 压缩它:常用话题保留较长摘要和最近几轮,不常用的只保留简短状态,不额外调用模型。
108
109
  - 有新版本时,左侧 TheOne 入口右边会出现 **更新** 按钮,点一下由 DSH 的插件管理器安装,并就地重新加载 TheOne,不用重启 DSH(不支持插件热加载的 DSH 会在下次启动时生效);话题都保存在数据库里,不受影响。
109
110
  </details>
@@ -5,6 +5,13 @@ export declare const zh: {
5
5
  readonly 'gateway.opening': "正在打开 TheOne 主聊天…";
6
6
  readonly 'gateway.error': "主聊天暂时无法打开,请检查 DSH 连接和 TheOne 插件状态。";
7
7
  readonly retry: "重试";
8
+ readonly 'settings.other': "其他";
9
+ readonly 'settings.notices': "显示公告";
10
+ readonly 'settings.help.notices': "显示 TheOne 作者发布的公告(新版本、重要提醒等)。只从 yulid.org 读取一个公告文件,不发送任何你的数据。";
11
+ readonly 'notice.label': "公告";
12
+ readonly 'notice.more': "查看详情";
13
+ readonly 'notice.ok': "知道了";
14
+ readonly 'notice.close': "关闭这条公告";
8
15
  readonly 'bg.label': "后台";
9
16
  readonly 'bg.follow': "默认";
10
17
  readonly 'bg.title': "后台模型:分配话题和干活都用它";
@@ -5,6 +5,13 @@ export const zh = {
5
5
  'gateway.opening': '正在打开 TheOne 主聊天…',
6
6
  'gateway.error': '主聊天暂时无法打开,请检查 DSH 连接和 TheOne 插件状态。',
7
7
  'retry': '重试',
8
+ 'settings.other': "其他",
9
+ 'settings.notices': "显示公告",
10
+ 'settings.help.notices': "显示 TheOne 作者发布的公告(新版本、重要提醒等)。只从 yulid.org 读取一个公告文件,不发送任何你的数据。",
11
+ 'notice.label': "公告",
12
+ 'notice.more': "查看详情",
13
+ 'notice.ok': "知道了",
14
+ 'notice.close': "关闭这条公告",
8
15
  'bg.label': '后台',
9
16
  'bg.follow': '默认',
10
17
  'bg.title': '后台模型:分配话题和干活都用它',
@@ -207,6 +214,13 @@ export const en = {
207
214
  'gateway.opening': 'Opening TheOne main chat…',
208
215
  'gateway.error': 'Main chat could not open. Check your DSH connection and TheOne plugin status.',
209
216
  'retry': 'Retry',
217
+ 'settings.other': "Other",
218
+ 'settings.notices': "Show notices",
219
+ 'settings.help.notices': "Show notices from TheOne's author (new versions, important reminders). TheOne only reads one notice file from yulid.org and sends none of your data.",
220
+ 'notice.label': "Notice",
221
+ 'notice.more': "Read more",
222
+ 'notice.ok': "Got it",
223
+ 'notice.close': "Close this notice",
210
224
  'bg.label': 'Background',
211
225
  'bg.follow': 'Default',
212
226
  'bg.title': 'Background model: routes and does the work',
package/dist/client.js CHANGED
@@ -38,7 +38,8 @@ var EDITABLE_SETTINGS_KEYS = [
38
38
  "maxResponseChars",
39
39
  "linkScope",
40
40
  "routeNotice",
41
- "contextsPath"
41
+ "contextsPath",
42
+ "notices"
42
43
  ];
43
44
 
44
45
  // src/client-navigation.ts
@@ -88,6 +89,13 @@ var zh = {
88
89
  "gateway.opening": "\u6B63\u5728\u6253\u5F00 TheOne \u4E3B\u804A\u5929\u2026",
89
90
  "gateway.error": "\u4E3B\u804A\u5929\u6682\u65F6\u65E0\u6CD5\u6253\u5F00\uFF0C\u8BF7\u68C0\u67E5 DSH \u8FDE\u63A5\u548C TheOne \u63D2\u4EF6\u72B6\u6001\u3002",
90
91
  "retry": "\u91CD\u8BD5",
92
+ "settings.other": "\u5176\u4ED6",
93
+ "settings.notices": "\u663E\u793A\u516C\u544A",
94
+ "settings.help.notices": "\u663E\u793A TheOne \u4F5C\u8005\u53D1\u5E03\u7684\u516C\u544A\uFF08\u65B0\u7248\u672C\u3001\u91CD\u8981\u63D0\u9192\u7B49\uFF09\u3002\u53EA\u4ECE yulid.org \u8BFB\u53D6\u4E00\u4E2A\u516C\u544A\u6587\u4EF6\uFF0C\u4E0D\u53D1\u9001\u4EFB\u4F55\u4F60\u7684\u6570\u636E\u3002",
95
+ "notice.label": "\u516C\u544A",
96
+ "notice.more": "\u67E5\u770B\u8BE6\u60C5",
97
+ "notice.ok": "\u77E5\u9053\u4E86",
98
+ "notice.close": "\u5173\u95ED\u8FD9\u6761\u516C\u544A",
91
99
  "bg.label": "\u540E\u53F0",
92
100
  "bg.follow": "\u9ED8\u8BA4",
93
101
  "bg.title": "\u540E\u53F0\u6A21\u578B\uFF1A\u5206\u914D\u8BDD\u9898\u548C\u5E72\u6D3B\u90FD\u7528\u5B83",
@@ -290,6 +298,13 @@ var en = {
290
298
  "gateway.opening": "Opening TheOne main chat\u2026",
291
299
  "gateway.error": "Main chat could not open. Check your DSH connection and TheOne plugin status.",
292
300
  "retry": "Retry",
301
+ "settings.other": "Other",
302
+ "settings.notices": "Show notices",
303
+ "settings.help.notices": "Show notices from TheOne's author (new versions, important reminders). TheOne only reads one notice file from yulid.org and sends none of your data.",
304
+ "notice.label": "Notice",
305
+ "notice.more": "Read more",
306
+ "notice.ok": "Got it",
307
+ "notice.close": "Close this notice",
293
308
  "bg.label": "Background",
294
309
  "bg.follow": "Default",
295
310
  "bg.title": "Background model: routes and does the work",
@@ -1039,6 +1054,118 @@ function apply(ctx) {
1039
1054
  (0, import_react.createElement)("span", { className: "theone-bg-chevron", "aria-hidden": true }, saving ? "\u2026" : "\u2303")
1040
1055
  );
1041
1056
  }
1057
+ let notices = [];
1058
+ const noticeListeners = /* @__PURE__ */ new Set();
1059
+ const setNotices = (value) => {
1060
+ notices = value;
1061
+ for (const listener of noticeListeners) listener();
1062
+ };
1063
+ const subscribeNotices = (listener) => {
1064
+ noticeListeners.add(listener);
1065
+ return () => {
1066
+ noticeListeners.delete(listener);
1067
+ };
1068
+ };
1069
+ const localized = (value) => localeSnapshot().active.startsWith("zh") ? value.zh ?? value.en ?? "" : value.en ?? value.zh ?? "";
1070
+ const shownDialogs = /* @__PURE__ */ new Set();
1071
+ const dismissNotice = (id) => {
1072
+ setNotices(notices.filter((notice) => notice.id !== id));
1073
+ void fetch("/api/theone/notices", {
1074
+ method: "POST",
1075
+ signal: lifetime.signal,
1076
+ cache: "no-store",
1077
+ headers: { "content-type": "application/json" },
1078
+ body: JSON.stringify({ dismiss: id })
1079
+ }).catch(() => {
1080
+ });
1081
+ };
1082
+ function openNoticeDialog(notice) {
1083
+ shownDialogs.add(notice.id);
1084
+ const backdrop = document.createElement("div");
1085
+ backdrop.className = "theone-dialog-backdrop";
1086
+ backdrop.setAttribute("translate", "no");
1087
+ const card = document.createElement("div");
1088
+ card.className = "theone-dialog";
1089
+ card.setAttribute("role", "dialog");
1090
+ card.setAttribute("aria-modal", "true");
1091
+ const title = document.createElement("h2");
1092
+ title.id = "theone-notice-title";
1093
+ title.textContent = localized(notice.title);
1094
+ card.setAttribute("aria-labelledby", title.id);
1095
+ const body = document.createElement("p");
1096
+ body.className = "theone-dialog-body";
1097
+ body.textContent = localized(notice.body);
1098
+ const footer = document.createElement("div");
1099
+ footer.className = "theone-dialog-actions";
1100
+ const close = () => {
1101
+ backdrop.remove();
1102
+ document.removeEventListener("keydown", keydown, true);
1103
+ dismissNotice(notice.id);
1104
+ };
1105
+ const keydown = (event) => {
1106
+ if (event.key === "Escape") {
1107
+ event.preventDefault();
1108
+ close();
1109
+ }
1110
+ };
1111
+ if (notice.link) {
1112
+ const more = document.createElement("button");
1113
+ more.type = "button";
1114
+ more.textContent = t("notice.more");
1115
+ more.addEventListener("click", () => {
1116
+ window.open(notice.link, "_blank", "noopener");
1117
+ close();
1118
+ });
1119
+ footer.append(more);
1120
+ }
1121
+ const ok = document.createElement("button");
1122
+ ok.type = "button";
1123
+ ok.className = "theone-dialog-primary";
1124
+ ok.textContent = t("notice.ok");
1125
+ ok.addEventListener("click", close);
1126
+ footer.append(ok);
1127
+ card.append(title, body, footer);
1128
+ backdrop.append(card);
1129
+ document.addEventListener("keydown", keydown, true);
1130
+ document.body.append(backdrop);
1131
+ ok.focus();
1132
+ }
1133
+ const readNotices = async () => {
1134
+ try {
1135
+ const response = await fetch("/api/theone/notices", { signal: lifetime.signal, cache: "no-store" });
1136
+ if (!response.ok) return;
1137
+ setNotices((await response.json()).notices);
1138
+ const important = notices.find((notice) => notice.level === "important" && !shownDialogs.has(notice.id));
1139
+ if (important && !document.querySelector(".theone-dialog-backdrop")) openNoticeDialog(important);
1140
+ } catch {
1141
+ }
1142
+ };
1143
+ ctx.effect(() => {
1144
+ void readNotices();
1145
+ const timer = setInterval(() => {
1146
+ void readNotices();
1147
+ }, 3 * 36e5);
1148
+ return () => clearInterval(timer);
1149
+ });
1150
+ function NoticeStrip({ directory }) {
1151
+ const t2 = useText();
1152
+ const state = (0, import_react.useSyncExternalStore)((listener) => directory.subscribe(listener), () => directory.getSnapshot());
1153
+ const list = (0, import_react.useSyncExternalStore)(subscribeNotices, () => notices);
1154
+ const notice = list.find((item) => item.level === "info");
1155
+ if ((state.pending ?? state.current)?.provider !== "theone" || !notice) return null;
1156
+ const title = localized(notice.title), body = localized(notice.body);
1157
+ return (0, import_react.createElement)(
1158
+ "div",
1159
+ { className: "theone-notice", role: "status", translate: "no" },
1160
+ (0, import_react.createElement)("span", { className: "theone-notice-tag" }, t2("notice.label")),
1161
+ (0, import_react.createElement)("span", { className: "theone-notice-text", title: `${title}
1162
+ ${body}` }, (0, import_react.createElement)("strong", null, title), " ", body),
1163
+ notice.link ? (0, import_react.createElement)("button", { type: "button", className: "theone-notice-link", onClick: () => {
1164
+ window.open(notice.link, "_blank", "noopener");
1165
+ } }, t2("notice.more")) : null,
1166
+ (0, import_react.createElement)("button", { type: "button", className: "theone-notice-close", "aria-label": t2("notice.close"), title: t2("notice.close"), onClick: () => dismissNotice(notice.id) }, "\xD7")
1167
+ );
1168
+ }
1042
1169
  ctx.inject(["slots", "modelDirectories"], (scope) => {
1043
1170
  const slots = scope.slots;
1044
1171
  slots.inject("conversation.input.right", () => slots.register({
@@ -1047,6 +1174,12 @@ function apply(ctx) {
1047
1174
  order: 100,
1048
1175
  inject: (sessionId) => ({ directory: scope.modelDirectories.directoryFor(sessionId).store })
1049
1176
  }, BackgroundModel));
1177
+ slots.inject("conversation.composer.dock", () => slots.register({
1178
+ name: "conversation.composer.dock",
1179
+ id: "theone.notice",
1180
+ order: 100,
1181
+ inject: (sessionId) => ({ directory: scope.modelDirectories.directoryFor(sessionId).store })
1182
+ }, NoticeStrip));
1050
1183
  });
1051
1184
  function GatewayPanel() {
1052
1185
  const t2 = useText();
@@ -1107,7 +1240,8 @@ function apply(ctx) {
1107
1240
  ["history", ["historyCatalog", "catalogIntervalMs"]],
1108
1241
  ["linkage", ["linkScope", "routeNotice"]],
1109
1242
  ["limits", ["maxDescriptorChars", "maxResponseChars"]],
1110
- ["storage", ["contextsPath", "databasePath", "gatewayKey"]]
1243
+ ["storage", ["contextsPath", "databasePath", "gatewayKey"]],
1244
+ ["other", ["notices"]]
1111
1245
  ];
1112
1246
  const display = (key) => {
1113
1247
  const value = snapshot.values[key];
@@ -1164,7 +1298,7 @@ function apply(ctx) {
1164
1298
  { ...props, value, required: field === "workerModel" && !!draft.workerProvider, onChange: (event) => selectValue(event.target.value) },
1165
1299
  ...choices.map((choice) => (0, import_react.createElement)("option", { key: choice.value, value: choice.value }, choice.label))
1166
1300
  );
1167
- if (field === "historyCatalog") return select(String(draft.historyCatalog), [{ value: "true", label: t2("settings.on") }, { value: "false", label: t2("settings.off") }], (v) => change(field, v === "true"));
1301
+ if (field === "historyCatalog" || field === "notices") return select(String(draft[field]), [{ value: "true", label: t2("settings.on") }, { value: "false", label: t2("settings.off") }], (v) => change(field, v === "true"));
1168
1302
  if (field === "linkScope" || field === "routeNotice") {
1169
1303
  const values = field === "linkScope" ? ["auto", "workspace", "off"] : ["switch", "hidden", "all"];
1170
1304
  return select(draft[field], values.map((value) => ({ value, label: t2(`settings.${field === "linkScope" ? "scope" : "notice"}.${value}`) })), (v) => change(field, v));
@@ -1817,6 +1951,15 @@ button:has(.theone-nav[data-wide=true])>span:has(.theone-nav){flex:1;min-width:0
1817
1951
  .theone-dialog-actions .theone-dialog-primary{border-color:transparent;background:#3b6fb0;color:#fff}
1818
1952
  .theone-dialog-actions .theone-dialog-primary:hover{background:#4a7fc0}
1819
1953
  .theone-dialog-actions button:focus-visible{outline:2px solid #4a7fc0;outline-offset:2px}
1954
+ .theone-dialog .theone-dialog-body{white-space:pre-wrap;color:var(--dsw-alias-label-primary,#e8e8ea)}
1955
+ .theone-notice{display:flex;align-items:center;gap:8px;margin:6px 4px 0;padding:6px 8px 6px 10px;border:1px solid var(--dsw-alias-border-l2,#ffffff1f);border-radius:10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#a0a0a6);min-width:0}
1956
+ .theone-notice-tag{flex:none;padding:0 6px;border-radius:6px;background:#3b6fb033;color:#7fa9dd}
1957
+ .theone-notice-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1958
+ .theone-notice-text strong{color:var(--dsw-alias-label-primary,#e8e8ea);font-weight:500}
1959
+ .theone-notice button{flex:none;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:2px 6px;border-radius:6px}
1960
+ .theone-notice button:hover{background:var(--dsw-alias-interactive-bg-hover,#ffffff12)}
1961
+ .theone-notice-link{color:#7fa9dd!important}
1962
+ .theone-notice-close{font-size:15px;line-height:1}
1820
1963
  .theone-update-spin{width:10px;height:10px;border-radius:50%;border:1.5px solid currentColor;border-right-color:transparent;animation:theone-spin 800ms linear infinite}
1821
1964
  @keyframes theone-spin{to{transform:rotate(360deg)}}
1822
1965
  @media(prefers-reduced-motion:reduce){.theone-update-spin{animation:none}}
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ import type { LinkageSnapshot } from './catalog-types.ts';
10
10
  import { type SettingsSnapshot } from './settings-types.ts';
11
11
  import { type LinkScope } from './linkage.ts';
12
12
  import { Updater } from './update.ts';
13
+ import { NoticeBoard } from './notices.ts';
13
14
  export interface Config {
14
15
  databasePath?: string;
15
16
  contextsPath?: string;
@@ -28,6 +29,10 @@ export interface Config {
28
29
  routerApiKeyEnv?: string;
29
30
  linkScope?: LinkScope;
30
31
  routeNotice?: 'hidden' | 'switch' | 'all';
32
+ /** Show notices the maintainer publishes (read from a static file; nothing is sent). */
33
+ notices?: boolean;
34
+ /** Where notices are read from; for testing. */
35
+ noticeUrl?: string;
31
36
  }
32
37
  declare module '@deepseek-ai/cordis' {
33
38
  interface Context {
@@ -79,6 +84,7 @@ export default class TheOne extends Service {
79
84
  private adapter?;
80
85
  /** Update checks and one-click install through DSH's plugin manager. */
81
86
  readonly updater: Updater;
87
+ readonly noticeBoard: NoticeBoard;
82
88
  /** Sessions whose current step answers through TheOne; only these refuse to run tools themselves. */
83
89
  private readonly throughTheOne;
84
90
  constructor(ctx: Context, config: Config);
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { validateSettings } from "./settings.js";
21
21
  import { RESTART_CODE, WorkerRun } from "./run.js";
22
22
  import { buildBriefing, LINK_SIGNAL, relatedTopics } from "./linkage.js";
23
23
  import { PACKAGE_NAME, Updater } from "./update.js";
24
+ import { NOTICE_URL, NoticeBoard } from "./notices.js";
24
25
  function redactDescriptor(text) {
25
26
  return text.replace(/\bsk-[a-zA-Z0-9_-]{16,}|\bBearer\s+\S+/gi, '[REDACTED]')
26
27
  .replace(/((?:api[_ -]?key|password|密码|密钥)\s*[:=:]\s*)\S+/gi, '$1[REDACTED]');
@@ -155,6 +156,8 @@ export default class TheOne extends Service {
155
156
  routerBaseUrl: z.string(), routerModel: z.string(), routerApiKeyEnv: z.string(),
156
157
  linkScope: z.union([z.const('off'), z.const('workspace'), z.const('auto')]).default('auto'),
157
158
  routeNotice: z.union([z.const('hidden'), z.const('switch'), z.const('all')]).default('switch'),
159
+ notices: z.boolean().default(true),
160
+ noticeUrl: z.string(),
158
161
  });
159
162
  store;
160
163
  catalog;
@@ -173,6 +176,7 @@ export default class TheOne extends Service {
173
176
  adapter;
174
177
  /** Update checks and one-click install through DSH's plugin manager. */
175
178
  updater;
179
+ noticeBoard;
176
180
  /** Sessions whose current step answers through TheOne; only these refuse to run tools themselves. */
177
181
  throughTheOne = new WeakMap();
178
182
  constructor(ctx, config) {
@@ -218,6 +222,7 @@ export default class TheOne extends Service {
218
222
  return undefined;
219
223
  }
220
224
  }, fetch, Date.now, () => { const dir = profileDir(); return dir ? join(dir, 'pnpm-workspace.yaml') : undefined; });
225
+ this.noticeBoard = new NoticeBoard(this.updater.current, config.noticeUrl || NOTICE_URL);
221
226
  ctx.effect(() => async () => {
222
227
  try {
223
228
  await Promise.all([...this.workers.values()].map(handle => handle.dispose()));
@@ -646,6 +651,19 @@ export default class TheOne extends Service {
646
651
  : await this.updater.status(new URL(request.url).searchParams.has('force'));
647
652
  return Response.json(status, { headers: { 'cache-control': 'no-store' } });
648
653
  } }));
654
+ // Notices from TheOne's maintainer; the user can close each one, or turn them all off in Settings.
655
+ child.effect(() => connection.fetch.register({ path: '/api/theone/notices', methods: ['GET', 'POST'], requestBody: 'buffered', fetch: async (request) => {
656
+ if (request.method === 'POST') {
657
+ const body = await request.json().catch(() => ({}));
658
+ if (typeof body?.dismiss !== 'string' || !/^[\w.-]{1,64}$/.test(body.dismiss))
659
+ return Response.json({ error: 'INVALID_INPUT' }, { status: 400 });
660
+ this.store.dismissNotice(body.dismiss);
661
+ }
662
+ if (this.config.notices === false)
663
+ return Response.json({ notices: [] }, { headers: { 'cache-control': 'no-store' } });
664
+ const dismissed = this.store.dismissedNotices();
665
+ return Response.json({ notices: (await this.noticeBoard.notices()).filter(notice => !dismissed.has(notice.id)) }, { headers: { 'cache-control': 'no-store' } });
666
+ } }));
649
667
  child.effect(() => connection.fetch.register({ path: '/api/theone/sessions', methods: ['GET'], requestBody: 'buffered', fetch: async () => Response.json({ sessions: await this.attachableSessions() }, { headers: { 'cache-control': 'no-store' } }) }));
650
668
  // The user's corrections to topic linking always take precedence over what was learned.
651
669
  child.effect(() => connection.fetch.register({ path: '/api/theone/links', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
@@ -805,7 +823,7 @@ export default class TheOne extends Service {
805
823
  workerProvider: c.workerProvider ? redactDescriptor(c.workerProvider) : null, workerModel: c.workerModel ? redactDescriptor(c.workerModel) : null,
806
824
  maxDescriptorChars: c.maxDescriptorChars, maxResponseChars: c.maxResponseChars,
807
825
  routerMode: c.routerMode ?? 'llm',
808
- linkScope: c.linkScope ?? 'auto', routeNotice: c.routeNotice ?? 'switch',
826
+ linkScope: c.linkScope ?? 'auto', routeNotice: c.routeNotice ?? 'switch', notices: c.notices ?? true,
809
827
  };
810
828
  const activeEditable = Object.fromEntries(EDITABLE_SETTINGS_KEYS.map(key => [key, key === 'contextsPath' ? c.contextsPath ?? null : values[key]]));
811
829
  let savedValues = activeEditable;
@@ -0,0 +1,39 @@
1
+ /** Where TheOne's maintainer publishes notices: a static JSON file, read with a plain GET. */
2
+ export declare const NOTICE_URL = "https://yulid.org/theone/notice.json";
3
+ /** Text in each interface language; the reader's language is chosen in the client. */
4
+ export interface LocalizedText {
5
+ zh?: string;
6
+ en?: string;
7
+ }
8
+ /** One notice as the client shows it. */
9
+ export interface Notice {
10
+ id: string;
11
+ /** `important` opens a dialog once; `info` shows a strip under the main chat input. */
12
+ level: 'info' | 'important';
13
+ title: LocalizedText;
14
+ body: LocalizedText;
15
+ /** An https page to read more; opened only when the reader clicks it. */
16
+ link?: string;
17
+ }
18
+ /**
19
+ * Whether `version` falls within a range such as "<0.3.16", ">=0.3.10", "0.3.12", "*", or several
20
+ * of these separated by spaces (all must hold). An unreadable range matches nothing.
21
+ */
22
+ export declare function versionMatches(range: unknown, version: string): boolean;
23
+ /**
24
+ * The notices in a published file that apply to this version now, at most five. Anything that does
25
+ * not fit the format is skipped rather than shown; text is plain, links must be https.
26
+ */
27
+ export declare function applicableNotices(file: unknown, version: string, now?: number): Notice[];
28
+ /** Reads the notice file at most every three hours; a failure simply means no notices. */
29
+ export declare class NoticeBoard {
30
+ private readonly version;
31
+ private readonly url;
32
+ private readonly fetcher;
33
+ private readonly now;
34
+ private cache?;
35
+ private pending?;
36
+ constructor(version: string, url?: string, fetcher?: typeof fetch, now?: () => number);
37
+ notices(): Promise<Notice[]>;
38
+ private read;
39
+ }
@@ -0,0 +1,105 @@
1
+ import { compareVersions } from "./update.js";
2
+ /** Where TheOne's maintainer publishes notices: a static JSON file, read with a plain GET. */
3
+ export const NOTICE_URL = 'https://yulid.org/theone/notice.json';
4
+ const HOUR = 3600000;
5
+ const text = (value, max) => {
6
+ if (typeof value === 'string')
7
+ return value.trim() ? { zh: value.trim().slice(0, max), en: value.trim().slice(0, max) } : undefined;
8
+ if (!value || typeof value !== 'object')
9
+ return undefined;
10
+ const row = value;
11
+ const pick = (key) => typeof row[key] === 'string' && row[key].trim() ? { [key]: row[key].trim().slice(0, max) } : {};
12
+ const result = { ...pick('zh'), ...pick('en') };
13
+ return Object.keys(result).length ? result : undefined;
14
+ };
15
+ /**
16
+ * Whether `version` falls within a range such as "<0.3.16", ">=0.3.10", "0.3.12", "*", or several
17
+ * of these separated by spaces (all must hold). An unreadable range matches nothing.
18
+ */
19
+ export function versionMatches(range, version) {
20
+ if (range === undefined || range === null || range === '*' || range === '')
21
+ return true;
22
+ if (typeof range !== 'string')
23
+ return false;
24
+ return range.trim().split(/\s+/).every(part => {
25
+ const match = /^(<=|>=|<|>|=)?v?(\d+\.\d+\.\d+)$/.exec(part);
26
+ if (!match)
27
+ return false;
28
+ const order = compareVersions(version, match[2]);
29
+ return { '<': order < 0, '<=': order <= 0, '>': order > 0, '>=': order >= 0, '=': order === 0 }[match[1] ?? '='];
30
+ });
31
+ }
32
+ /**
33
+ * The notices in a published file that apply to this version now, at most five. Anything that does
34
+ * not fit the format is skipped rather than shown; text is plain, links must be https.
35
+ */
36
+ export function applicableNotices(file, version, now = Date.now()) {
37
+ const list = file && typeof file === 'object' && Array.isArray(file.notices) ? file.notices : [];
38
+ const result = [];
39
+ for (const item of list.slice(0, 20)) {
40
+ if (!item || typeof item !== 'object')
41
+ continue;
42
+ const row = item;
43
+ if (typeof row.id !== 'string' || !/^[\w.-]{1,64}$/.test(row.id) || result.some(notice => notice.id === row.id))
44
+ continue;
45
+ const title = text(row.title, 80), body = text(row.body, 600);
46
+ if (!title || !body || !versionMatches(row.versions, version))
47
+ continue;
48
+ const time = (value) => typeof value === 'string' ? Date.parse(value) : NaN;
49
+ if (Number.isFinite(time(row.from)) && now < time(row.from))
50
+ continue;
51
+ if (Number.isFinite(time(row.until)) && now > time(row.until))
52
+ continue;
53
+ let link;
54
+ if (typeof row.link === 'string') {
55
+ try {
56
+ const url = new URL(row.link);
57
+ if (url.protocol === 'https:' && row.link.length <= 300)
58
+ link = url.href;
59
+ }
60
+ catch { /* Not a link. */ }
61
+ }
62
+ result.push({ id: row.id, level: row.level === 'important' ? 'important' : 'info', title, body, ...(link ? { link } : {}) });
63
+ if (result.length === 5)
64
+ break;
65
+ }
66
+ return result;
67
+ }
68
+ /** Reads the notice file at most every three hours; a failure simply means no notices. */
69
+ export class NoticeBoard {
70
+ version;
71
+ url;
72
+ fetcher;
73
+ now;
74
+ cache;
75
+ pending;
76
+ constructor(version, url = NOTICE_URL, fetcher = fetch, now = Date.now) {
77
+ this.version = version;
78
+ this.url = url;
79
+ this.fetcher = fetcher;
80
+ this.now = now;
81
+ }
82
+ async notices() {
83
+ if (!this.cache || this.now() - this.cache.at > 3 * HOUR) {
84
+ this.pending ??= this.read().finally(() => { this.pending = undefined; });
85
+ await this.pending;
86
+ }
87
+ return applicableNotices(this.cache?.file, this.version, this.now());
88
+ }
89
+ async read() {
90
+ try {
91
+ // A plain GET: nothing about the reader or their conversations is sent.
92
+ const response = await this.fetcher(this.url, { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } });
93
+ if (!response.ok)
94
+ throw new Error(`HTTP ${response.status}`);
95
+ const body = await response.text();
96
+ if (body.length > 64000)
97
+ throw new Error('Notice file too large');
98
+ this.cache = { at: this.now(), file: JSON.parse(body) };
99
+ }
100
+ catch {
101
+ // Try again in half an hour, keeping what was read before.
102
+ this.cache = { at: this.now() - 2.5 * HOUR, file: this.cache?.file };
103
+ }
104
+ }
105
+ }
@@ -13,6 +13,7 @@ export interface SettingsSnapshot {
13
13
  routerMode: 'rules' | 'llm';
14
14
  linkScope: 'off' | 'workspace' | 'auto';
15
15
  routeNotice: 'hidden' | 'switch' | 'all';
16
+ notices: boolean;
16
17
  };
17
18
  model: {
18
19
  provider: string;
@@ -32,7 +33,7 @@ export interface SettingsSnapshot {
32
33
  /** Saved settings that apply only after DSH restarts; everything else applies when saved. */
33
34
  restartRequired: boolean;
34
35
  }
35
- export declare const EDITABLE_SETTINGS_KEYS: readonly ["workerProvider", "workerModel", "routerMode", "historyCatalog", "catalogIntervalMs", "maxDescriptorChars", "maxResponseChars", "linkScope", "routeNotice", "contextsPath"];
36
+ export declare const EDITABLE_SETTINGS_KEYS: readonly ["workerProvider", "workerModel", "routerMode", "historyCatalog", "catalogIntervalMs", "maxDescriptorChars", "maxResponseChars", "linkScope", "routeNotice", "contextsPath", "notices"];
36
37
  /** The background catalog is started once; these take effect after DSH restarts. */
37
38
  export declare const RESTART_SETTINGS_KEYS: readonly ["historyCatalog", "catalogIntervalMs"];
38
39
  /** Settings of the removed direct router; forms saved by older versions may still carry them. */
@@ -41,5 +42,6 @@ export declare const RETIRED_SETTINGS_KEYS: readonly ["routerTransport", "router
41
42
  export declare const SETTINGS_DEFAULTS: {
42
43
  readonly linkScope: "auto";
43
44
  readonly routeNotice: "switch";
45
+ readonly notices: true;
44
46
  };
45
47
  export type EditableSettings = Pick<SettingsSnapshot['values'], typeof EDITABLE_SETTINGS_KEYS[number]>;
@@ -1,8 +1,8 @@
1
1
  export const EDITABLE_SETTINGS_KEYS = ['workerProvider', 'workerModel', 'routerMode', 'historyCatalog',
2
- 'catalogIntervalMs', 'maxDescriptorChars', 'maxResponseChars', 'linkScope', 'routeNotice', 'contextsPath'];
2
+ 'catalogIntervalMs', 'maxDescriptorChars', 'maxResponseChars', 'linkScope', 'routeNotice', 'contextsPath', 'notices'];
3
3
  /** The background catalog is started once; these take effect after DSH restarts. */
4
4
  export const RESTART_SETTINGS_KEYS = ['historyCatalog', 'catalogIntervalMs'];
5
5
  /** Settings of the removed direct router; forms saved by older versions may still carry them. */
6
6
  export const RETIRED_SETTINGS_KEYS = ['routerTransport', 'routerBaseUrl', 'routerModel', 'routerApiKeyEnv'];
7
7
  /** Added after the first release; settings saved before them take their defaults. */
8
- export const SETTINGS_DEFAULTS = { linkScope: 'auto', routeNotice: 'switch' };
8
+ export const SETTINGS_DEFAULTS = { linkScope: 'auto', routeNotice: 'switch', notices: true };
package/dist/settings.js CHANGED
@@ -33,8 +33,10 @@ export function validateSettings(value, fallback = {}) {
33
33
  if ((workerProvider === null) !== (workerModel === null) || workerProvider === 'theone')
34
34
  return fail();
35
35
  const contextsPath = row.contextsPath === null ? null : text('contextsPath', 4096);
36
+ if (typeof row.notices !== 'boolean')
37
+ return fail();
36
38
  return { workerProvider, workerModel, routerMode: row.routerMode, historyCatalog: row.historyCatalog,
37
39
  catalogIntervalMs: number('catalogIntervalMs', 10000, 86400000), maxDescriptorChars: number('maxDescriptorChars', 128, 1000000),
38
40
  maxResponseChars: number('maxResponseChars', 128, 10000000),
39
- linkScope: row.linkScope, routeNotice: row.routeNotice, contextsPath };
41
+ linkScope: row.linkScope, routeNotice: row.routeNotice, contextsPath, notices: row.notices };
40
42
  }
package/dist/store.d.ts CHANGED
@@ -157,5 +157,8 @@ export declare class ContextStore {
157
157
  /** Remove a topic and everything TheOne kept about it. DSH keeps the conversations themselves. */
158
158
  deleteTopic(contextId: string): void;
159
159
  private purge;
160
+ /** Notices the user closed; they are not shown again on any browser. */
161
+ dismissNotice(id: string, now?: number): void;
162
+ dismissedNotices(): Set<string>;
160
163
  close(): void;
161
164
  }
package/dist/store.js CHANGED
@@ -93,6 +93,7 @@ export class ContextStore {
93
93
  CREATE TABLE IF NOT EXISTS learned_terms (
94
94
  context_id TEXT NOT NULL, term TEXT NOT NULL, weight REAL NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(context_id, term)
95
95
  );
96
+ CREATE TABLE IF NOT EXISTS dismissed_notices (id TEXT PRIMARY KEY, dismissed_at INTEGER NOT NULL);
96
97
  CREATE TABLE IF NOT EXISTS route_details (
97
98
  message_id TEXT PRIMARY KEY, excerpt TEXT NOT NULL, receipt TEXT, corrected_to TEXT, corrected_at INTEGER
98
99
  );
@@ -684,5 +685,12 @@ export class ContextStore {
684
685
  this.db.prepare('UPDATE route_details SET corrected_to = NULL, corrected_at = NULL WHERE corrected_to = ?').run(contextId);
685
686
  this.db.prepare('DELETE FROM contexts WHERE id = ?').run(contextId);
686
687
  }
688
+ /** Notices the user closed; they are not shown again on any browser. */
689
+ dismissNotice(id, now = Date.now()) {
690
+ this.db.prepare('INSERT OR IGNORE INTO dismissed_notices VALUES (?, ?)').run(id, now);
691
+ }
692
+ dismissedNotices() {
693
+ return new Set(this.db.prepare('SELECT id FROM dismissed_notices').all().map(row => String(row.id)));
694
+ }
687
695
  close() { this.db.close(); }
688
696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theone",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Automatic context routing for DeepSeek Harness",