@kanadego/dsh-heartbeat 1.6.4 → 1.7.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/dist/index.js CHANGED
@@ -21,12 +21,13 @@ import {
21
21
  runDeterministicAging,
22
22
  scanPending,
23
23
  sendNewMessageHint,
24
+ updateUserPolicy,
24
25
  userPresetRoot
25
- } from "./chunk-TFMQKETS.js";
26
+ } from "./chunk-ON4MSU6E.js";
26
27
  import {
27
28
  getRuntime,
28
29
  setRuntime
29
- } from "./chunk-SJUNS2BE.js";
30
+ } from "./chunk-3QJJRXIR.js";
30
31
  import {
31
32
  activeSeeds,
32
33
  addSeed,
@@ -851,14 +852,57 @@ defineMethod("transform", [
851
852
  "preserve"
852
853
  ], ({ inner }, isInner) => inner.toString(isInner));
853
854
 
855
+ // src/config/ui-config.ts
856
+ import fs from "fs";
857
+ import path from "path";
858
+ var USER_UI_FILE = "ui.json";
859
+ var RANGES = {
860
+ intervalMin: { min: 1, max: 1440 },
861
+ maxDailySend: { min: 1, max: 50 },
862
+ timeInjectMin: { min: 0, max: 1440 }
863
+ };
864
+ function sanitize(raw) {
865
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
866
+ const r = raw;
867
+ const out = {};
868
+ for (const key of ["intervalMin", "maxDailySend", "timeInjectMin"]) {
869
+ const v = r[key];
870
+ if (typeof v === "number" && Number.isFinite(v) && v >= RANGES[key].min && v <= RANGES[key].max) {
871
+ out[key] = Math.floor(v);
872
+ }
873
+ }
874
+ if (typeof r.statusbar === "boolean") out.statusbar = r.statusbar;
875
+ if (typeof r.idleMode === "boolean") out.idleMode = r.idleMode;
876
+ if (typeof r.tokenSaver === "boolean") out.tokenSaver = r.tokenSaver;
877
+ return out;
878
+ }
879
+ function uiConfigPath(settingsDir) {
880
+ return path.join(settingsDir, USER_UI_FILE);
881
+ }
882
+ function loadUiConfig(guard, settingsDir) {
883
+ try {
884
+ const raw = JSON.parse(fs.readFileSync(guard.assert(uiConfigPath(settingsDir)), "utf8"));
885
+ return sanitize(raw);
886
+ } catch {
887
+ return {};
888
+ }
889
+ }
890
+ function saveUiConfig(guard, settingsDir, patch) {
891
+ const merged = sanitize({ ...loadUiConfig(guard, settingsDir), ...patch });
892
+ const file = guard.assert(uiConfigPath(settingsDir));
893
+ fs.mkdirSync(path.dirname(file), { recursive: true });
894
+ fs.writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", "utf8");
895
+ return merged;
896
+ }
897
+
854
898
  // src/core/orchestrator.ts
855
899
  import { randomUUID as randomUUID2 } from "crypto";
856
- import fs8 from "fs";
857
- import path8 from "path";
900
+ import fs9 from "fs";
901
+ import path9 from "path";
858
902
 
859
903
  // src/env/envpulse.ts
860
- import fs2 from "fs";
861
- import path2 from "path";
904
+ import fs3 from "fs";
905
+ import path3 from "path";
862
906
  import { spawnSync } from "child_process";
863
907
 
864
908
  // src/env/timeflow.ts
@@ -896,8 +940,8 @@ function timeContext(now = /* @__PURE__ */ new Date()) {
896
940
  }
897
941
 
898
942
  // src/gate/busy-rules.ts
899
- import fs from "fs";
900
- import path from "path";
943
+ import fs2 from "fs";
944
+ import path2 from "path";
901
945
  var own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
902
946
  var FALLBACK_RULES = {
903
947
  busy: {},
@@ -906,7 +950,7 @@ var FALLBACK_RULES = {
906
950
  };
907
951
  function loadBusyRules(configDir) {
908
952
  try {
909
- const raw = JSON.parse(fs.readFileSync(path.join(configDir, "busy-rules.json"), "utf8"));
953
+ const raw = JSON.parse(fs2.readFileSync(path2.join(configDir, "busy-rules.json"), "utf8"));
910
954
  if (!raw.busy || !raw.idle) return FALLBACK_RULES;
911
955
  return raw;
912
956
  } catch {
@@ -946,21 +990,36 @@ function presenceOf(idleSec, windowClass) {
946
990
  return windowClass === "busy" ? "active" : "present";
947
991
  }
948
992
  function probeIdle(guard, paths) {
949
- const tmp = path2.join(paths.tmpDir, `idle-${Date.now()}.txt`);
993
+ const tmp = path3.join(paths.tmpDir, `idle-${Date.now()}.txt`);
950
994
  try {
951
995
  const out = guard.assert(tmp);
952
996
  const r = spawnSync(
953
997
  "powershell.exe",
954
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path2.join(paths.assetsDir, "idle.ps1"), "-out", out],
998
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path3.join(paths.assetsDir, "idle.ps1"), "-out", out],
955
999
  { timeout: 2e4, encoding: "utf8" }
956
1000
  );
957
- if (r.status !== 0 || !fs2.existsSync(out)) return -1;
958
- const v = Number.parseInt(fs2.readFileSync(out, "utf8").trim(), 10);
1001
+ if (r.status !== 0 || !fs3.existsSync(out)) return -1;
1002
+ const v = Number.parseInt(fs3.readFileSync(out, "utf8").trim(), 10);
959
1003
  return Number.isNaN(v) ? -1 : v;
960
1004
  } catch {
961
1005
  return -1;
962
1006
  } finally {
963
- fs2.rmSync(tmp, { force: true });
1007
+ fs3.rmSync(tmp, { force: true });
1008
+ }
1009
+ }
1010
+ function probeIdleSeconds(guard, paths) {
1011
+ return probeIdle(guard, paths);
1012
+ }
1013
+ function probeWorkstationLocked() {
1014
+ try {
1015
+ const r = spawnSync(
1016
+ "powershell.exe",
1017
+ ["-NoProfile", "-Command", 'if (Get-Process -Name LogonUI -ErrorAction SilentlyContinue) { "locked" } else { "unlocked" }'],
1018
+ { timeout: 1e4, encoding: "utf8" }
1019
+ );
1020
+ return r.status === 0 && String(r.stdout || "").includes("locked");
1021
+ } catch {
1022
+ return false;
964
1023
  }
965
1024
  }
966
1025
  function collectPulse(guard, paths, rules, fgProcess = null, now = /* @__PURE__ */ new Date()) {
@@ -978,7 +1037,7 @@ function collectPulse(guard, paths, rules, fgProcess = null, now = /* @__PURE__
978
1037
  festival: t.festival
979
1038
  };
980
1039
  try {
981
- fs2.writeFileSync(path2.join(paths.dataDir, "envpulse.json"), JSON.stringify(snapshot, null, 1), "utf8");
1040
+ fs3.writeFileSync(path3.join(paths.dataDir, "envpulse.json"), JSON.stringify(snapshot, null, 1), "utf8");
982
1041
  } catch {
983
1042
  }
984
1043
  writePulseStream(paths, snapshot);
@@ -986,7 +1045,7 @@ function collectPulse(guard, paths, rules, fgProcess = null, now = /* @__PURE__
986
1045
  }
987
1046
  function writePulseStream(paths, snapshot) {
988
1047
  try {
989
- appendAuditLine(path2.join(paths.logsDir, "envpulse.jsonl"), {
1048
+ appendAuditLine(path3.join(paths.logsDir, "envpulse.jsonl"), {
990
1049
  event: "pulse",
991
1050
  takenAt: snapshot.takenAt,
992
1051
  idleSeconds: snapshot.idleSeconds,
@@ -1002,7 +1061,7 @@ function writePulseStream(paths, snapshot) {
1002
1061
  }
1003
1062
  function readPulse(guard, paths) {
1004
1063
  try {
1005
- const raw = fs2.readFileSync(path2.join(paths.dataDir, "envpulse.json"), "utf8");
1064
+ const raw = fs3.readFileSync(path3.join(paths.dataDir, "envpulse.json"), "utf8");
1006
1065
  return JSON.parse(raw);
1007
1066
  } catch {
1008
1067
  return null;
@@ -1010,33 +1069,33 @@ function readPulse(guard, paths) {
1010
1069
  }
1011
1070
 
1012
1071
  // src/screen/screenpulse.ts
1013
- import fs3 from "fs";
1014
- import path3 from "path";
1072
+ import fs4 from "fs";
1073
+ import path4 from "path";
1015
1074
  import { spawnSync as spawnSync2 } from "child_process";
1016
1075
  var SCREEN_JSON = "screen.json";
1017
1076
  var SCREEN_JPG = "screen.jpg";
1018
1077
  function screenJsonPath(paths) {
1019
- return path3.join(paths.dataDir, SCREEN_JSON);
1078
+ return path4.join(paths.dataDir, SCREEN_JSON);
1020
1079
  }
1021
1080
  function screenJpgPath(paths) {
1022
- return path3.join(paths.dataDir, SCREEN_JPG);
1081
+ return path4.join(paths.dataDir, SCREEN_JPG);
1023
1082
  }
1024
1083
  function collectScreen(guard, paths, now = Date.now()) {
1025
- const rawJson = path3.join(paths.tmpDir, "screen.raw.json");
1026
- const rawJpg = path3.join(paths.tmpDir, "screen.raw.jpg");
1084
+ const rawJson = path4.join(paths.tmpDir, "screen.raw.json");
1085
+ const rawJpg = path4.join(paths.tmpDir, "screen.raw.jpg");
1027
1086
  let encJson = "";
1028
1087
  try {
1029
1088
  const r = spawnSync2(
1030
1089
  "powershell.exe",
1031
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path3.join(paths.assetsDir, "screenpulse.ps1"), "-outdir", paths.tmpDir],
1090
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path4.join(paths.assetsDir, "screenpulse.ps1"), "-outdir", paths.tmpDir],
1032
1091
  { timeout: 45e3, encoding: "utf8" }
1033
1092
  );
1034
1093
  if (r.status !== 0) {
1035
1094
  return { ok: false, capturedAt: null, hasShot: false, visibleCount: 0, error: `collector exit ${r.status}` };
1036
1095
  }
1037
- const raw = fs3.readFileSync(rawJson, "utf8");
1096
+ const raw = fs4.readFileSync(rawJson, "utf8");
1038
1097
  const meta = JSON.parse(raw);
1039
- if (meta.shot_path && fs3.existsSync(meta.shot_path)) {
1098
+ if (meta.shot_path && fs4.existsSync(meta.shot_path)) {
1040
1099
  try {
1041
1100
  encryptFile(guard, meta.shot_path, screenJpgPath(paths));
1042
1101
  } catch {
@@ -1044,51 +1103,51 @@ function collectScreen(guard, paths, now = Date.now()) {
1044
1103
  }
1045
1104
  const { shot_path: _drop, ...clean } = meta;
1046
1105
  void _drop;
1047
- encJson = path3.join(paths.tmpDir, `screen.enc.${now}.json`);
1048
- fs3.writeFileSync(encJson, JSON.stringify(clean, null, 1), "utf8");
1106
+ encJson = path4.join(paths.tmpDir, `screen.enc.${now}.json`);
1107
+ fs4.writeFileSync(encJson, JSON.stringify(clean, null, 1), "utf8");
1049
1108
  encryptFile(guard, encJson, screenJsonPath(paths));
1050
1109
  return {
1051
1110
  ok: true,
1052
1111
  capturedAt: meta.captured_at ?? null,
1053
- hasShot: fs3.existsSync(screenJpgPath(paths)),
1112
+ hasShot: fs4.existsSync(screenJpgPath(paths)),
1054
1113
  visibleCount: Array.isArray(meta.windows) ? meta.windows.length : 0
1055
1114
  };
1056
1115
  } catch (e) {
1057
1116
  return { ok: false, capturedAt: null, hasShot: false, visibleCount: 0, error: String(e) };
1058
1117
  } finally {
1059
1118
  for (const leftover of [rawJson, rawJpg, encJson]) {
1060
- fs3.rmSync(leftover, { force: true });
1119
+ fs4.rmSync(leftover, { force: true });
1061
1120
  }
1062
1121
  }
1063
1122
  }
1064
1123
  function readScreenJson(guard, paths) {
1065
1124
  const f = screenJsonPath(paths);
1066
- if (!fs3.existsSync(guard.assert(f))) return null;
1067
- const tmp = path3.join(paths.tmpDir, `screen.read.${Date.now()}.json`);
1125
+ if (!fs4.existsSync(guard.assert(f))) return null;
1126
+ const tmp = path4.join(paths.tmpDir, `screen.read.${Date.now()}.json`);
1068
1127
  try {
1069
1128
  decryptFile(guard, f, guard.assert(tmp));
1070
- return JSON.parse(fs3.readFileSync(tmp, "utf8"));
1129
+ return JSON.parse(fs4.readFileSync(tmp, "utf8"));
1071
1130
  } catch {
1072
1131
  return null;
1073
1132
  } finally {
1074
- fs3.rmSync(tmp, { force: true });
1133
+ fs4.rmSync(tmp, { force: true });
1075
1134
  }
1076
1135
  }
1077
1136
  function unlockShot(guard, paths) {
1078
1137
  const f = screenJpgPath(paths);
1079
- if (!fs3.existsSync(guard.assert(f))) return null;
1080
- const tmp = path3.join(paths.tmpDir, `screen.view.${Date.now()}.jpg`);
1138
+ if (!fs4.existsSync(guard.assert(f))) return null;
1139
+ const tmp = path4.join(paths.tmpDir, `screen.view.${Date.now()}.jpg`);
1081
1140
  decryptFile(guard, f, guard.assert(tmp));
1082
1141
  return tmp;
1083
1142
  }
1084
1143
  function burnUnlocked(guard, tmpPath) {
1085
- fs3.rmSync(guard.assert(tmpPath), { force: true });
1144
+ fs4.rmSync(guard.assert(tmpPath), { force: true });
1086
1145
  }
1087
1146
 
1088
1147
  // src/screen/vision.ts
1089
1148
  import { createRequire } from "module";
1090
- import fs4 from "fs";
1091
- import path4 from "path";
1149
+ import fs5 from "fs";
1150
+ import path5 from "path";
1092
1151
  import { spawn } from "child_process";
1093
1152
  var VISION_TIMEOUT_MS = 12e4;
1094
1153
  var VISION_MAX_SUMMARY = 300;
@@ -1096,8 +1155,8 @@ function resolveModlensMain(fromUrl) {
1096
1155
  try {
1097
1156
  const req = createRequire(fromUrl);
1098
1157
  const pkgJson = req.resolve("@liustack/modlens/package.json");
1099
- const main = path4.join(path4.dirname(pkgJson), "dist", "main.js");
1100
- return fs4.existsSync(main) ? main : null;
1158
+ const main = path5.join(path5.dirname(pkgJson), "dist", "main.js");
1159
+ return fs5.existsSync(main) ? main : null;
1101
1160
  } catch {
1102
1161
  return null;
1103
1162
  }
@@ -1135,7 +1194,7 @@ function extractSummary(raw) {
1135
1194
  var DEFAULT_VISION_PROVIDER = "openai";
1136
1195
  function readVisionSettings(guard, paths) {
1137
1196
  try {
1138
- const raw = fs4.readFileSync(guard.assert(path4.join(paths.settingsDir, "vision.json")), "utf8");
1197
+ const raw = fs5.readFileSync(guard.assert(path5.join(paths.settingsDir, "vision.json")), "utf8");
1139
1198
  const obj = JSON.parse(raw);
1140
1199
  return {
1141
1200
  ...typeof obj.provider === "string" && obj.provider.trim() ? { provider: obj.provider.trim() } : {},
@@ -1150,7 +1209,7 @@ async function describeScreenShot(guard, paths, opts) {
1150
1209
  if (!mainJs) return { ok: false, summary: null, error: "modlens not installed" };
1151
1210
  const shot = unlockShot(guard, paths);
1152
1211
  if (!shot) return { ok: false, summary: null, error: "no screenshot this beat" };
1153
- const outJson = path4.join(paths.tmpDir, `screen.vision.${Date.now()}.json`);
1212
+ const outJson = path5.join(paths.tmpDir, `screen.vision.${Date.now()}.json`);
1154
1213
  const spawnCli = opts.spawnCli ?? defaultSpawn;
1155
1214
  const timeoutMs = opts.timeoutMs ?? VISION_TIMEOUT_MS;
1156
1215
  const settings = opts.settings ?? readVisionSettings(guard, paths);
@@ -1175,7 +1234,7 @@ async function describeScreenShot(guard, paths, opts) {
1175
1234
  if (code !== 0) {
1176
1235
  return { ok: false, summary: null, error: `modlens exit ${code}${stderr ? `: ${stderr.slice(0, 140)}` : ""}` };
1177
1236
  }
1178
- const raw = fs4.existsSync(outJson) ? fs4.readFileSync(outJson, "utf8") : "";
1237
+ const raw = fs5.existsSync(outJson) ? fs5.readFileSync(outJson, "utf8") : "";
1179
1238
  const summary = extractSummary(raw);
1180
1239
  if (!summary) return { ok: false, summary: null, error: "modlens produced no summary" };
1181
1240
  return { ok: true, summary };
@@ -1183,17 +1242,17 @@ async function describeScreenShot(guard, paths, opts) {
1183
1242
  return { ok: false, summary: null, error: String(e).slice(0, 160) };
1184
1243
  } finally {
1185
1244
  burnUnlocked(guard, shot);
1186
- fs4.rmSync(outJson, { force: true });
1245
+ fs5.rmSync(outJson, { force: true });
1187
1246
  }
1188
1247
  }
1189
1248
 
1190
1249
  // src/gate/gate.ts
1191
- import path5 from "path";
1250
+ import path6 from "path";
1192
1251
  import { spawnSync as spawnSync3 } from "child_process";
1193
- import fs5 from "fs";
1252
+ import fs6 from "fs";
1194
1253
  var STABLE_WINDOW_MS = 15e3;
1195
1254
  function sentFilePath(paths) {
1196
- return path5.join(paths.dataDir, "sent.json");
1255
+ return path6.join(paths.dataDir, "sent.json");
1197
1256
  }
1198
1257
  function localDay(now) {
1199
1258
  const d = new Date(now);
@@ -1242,20 +1301,20 @@ function evaluateGate(input) {
1242
1301
  };
1243
1302
  }
1244
1303
  function probeFrontWindowLive(guard, paths) {
1245
- const tmp = path5.join(paths.tmpDir, `frontwin.${Date.now()}.json`);
1304
+ const tmp = path6.join(paths.tmpDir, `frontwin.${Date.now()}.json`);
1246
1305
  try {
1247
1306
  const out = guard.assert(tmp);
1248
1307
  const r = spawnSync3(
1249
1308
  "powershell.exe",
1250
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path5.join(paths.assetsDir, "frontwin.ps1"), "-out", out],
1309
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path6.join(paths.assetsDir, "frontwin.ps1"), "-out", out],
1251
1310
  { timeout: 1e4, encoding: "utf8" }
1252
1311
  );
1253
- if (r.status !== 0 || !fs5.existsSync(out)) return null;
1254
- return JSON.parse(fs5.readFileSync(out, "utf8"));
1312
+ if (r.status !== 0 || !fs6.existsSync(out)) return null;
1313
+ return JSON.parse(fs6.readFileSync(out, "utf8"));
1255
1314
  } catch {
1256
1315
  return null;
1257
1316
  } finally {
1258
- fs5.rmSync(tmp, { force: true });
1317
+ fs6.rmSync(tmp, { force: true });
1259
1318
  }
1260
1319
  }
1261
1320
  function probeFrontWindowSnapshot(guard, paths) {
@@ -1405,8 +1464,8 @@ function buildRuminationPrompt(input) {
1405
1464
  }
1406
1465
 
1407
1466
  // src/statusbar/store.ts
1408
- import fs6 from "fs";
1409
- import path6 from "path";
1467
+ import fs7 from "fs";
1468
+ import path7 from "path";
1410
1469
  function deriveScene(input) {
1411
1470
  if (input.quietHours) return "quiet-hours";
1412
1471
  if (input.spokeThisBeat) return "just-spoke";
@@ -1421,14 +1480,14 @@ function clampNote(note) {
1421
1480
  return t.length <= 30 ? t : t.slice(0, 30);
1422
1481
  }
1423
1482
  function statusFilePath(dataDir) {
1424
- return path6.join(dataDir, "settings", "status.json");
1483
+ return path7.join(dataDir, "settings", "status.json");
1425
1484
  }
1426
1485
  function writeStatus(guard, paths, state) {
1427
1486
  atomicWriteJsonSync(guard.assert(statusFilePath(paths.dataDir)), state);
1428
1487
  }
1429
1488
  function readStatus(guard, paths) {
1430
1489
  try {
1431
- const raw = fs6.readFileSync(guard.assert(statusFilePath(paths.dataDir)), "utf8");
1490
+ const raw = fs7.readFileSync(guard.assert(statusFilePath(paths.dataDir)), "utf8");
1432
1491
  const parsed = JSON.parse(raw);
1433
1492
  if (typeof parsed?.at !== "string" || typeof parsed?.scene !== "string") return null;
1434
1493
  return parsed;
@@ -1444,7 +1503,7 @@ var StatusReader = class {
1444
1503
  const file = statusFilePath(paths.dataDir);
1445
1504
  let st;
1446
1505
  try {
1447
- st = fs6.statSync(guard.assert(file));
1506
+ st = fs7.statSync(guard.assert(file));
1448
1507
  } catch {
1449
1508
  this.mtimeMs = -1;
1450
1509
  this.cached = null;
@@ -1684,16 +1743,16 @@ function buildDigest(guard, paths, policy, input = {}) {
1684
1743
  }
1685
1744
 
1686
1745
  // src/rhythm/rhythm.ts
1687
- import fs7 from "fs";
1688
- import path7 from "path";
1746
+ import fs8 from "fs";
1747
+ import path8 from "path";
1689
1748
  var DAY_MS = 864e5;
1690
1749
  var TAU_DAYS = 10;
1691
1750
  function rhythmFilePath(paths) {
1692
- return path7.join(paths.dataDir, "profile_rhythm.json");
1751
+ return path8.join(paths.dataDir, "profile_rhythm.json");
1693
1752
  }
1694
1753
  function loadRhythm(paths) {
1695
1754
  try {
1696
- return JSON.parse(fs7.readFileSync(rhythmFilePath(paths), "utf8"));
1755
+ return JSON.parse(fs8.readFileSync(rhythmFilePath(paths), "utf8"));
1697
1756
  } catch {
1698
1757
  return { histogram: {}, days: [], lastDecayAt: (/* @__PURE__ */ new Date()).toISOString() };
1699
1758
  }
@@ -1764,7 +1823,7 @@ function noteBeat(verdict, detail) {
1764
1823
  lastBeat = { at: (/* @__PURE__ */ new Date()).toISOString(), verdict, ...detail };
1765
1824
  }
1766
1825
  function stateFile(paths) {
1767
- return path8.join(paths.dataDir, "gate.json");
1826
+ return path9.join(paths.dataDir, "gate.json");
1768
1827
  }
1769
1828
  function readBeatState(guard, paths) {
1770
1829
  try {
@@ -1947,7 +2006,9 @@ async function hostUserMessage(text, label) {
1947
2006
  const { createUserMessage } = await import("@deepseek-ai/dsh-llm");
1948
2007
  return createUserMessage({
1949
2008
  content: [{ type: "text", text }],
1950
- source: { kind: "plugin", plugin: "heartbeat", form: "snapshot", sections: [{ name: "heartbeat", text: label }] }
2009
+ // 0.1.7 V4: kind:'plugin' is a retired generic wrapper, refused on write
2010
+ // ("producer-owned source kind") — the producer names its own kind.
2011
+ source: { kind: "heartbeat", plugin: "heartbeat", form: "snapshot", sections: [{ name: "heartbeat", text: label }] }
1951
2012
  });
1952
2013
  }
1953
2014
  async function agentTurn(deps, agent, prompt, label, idleWaitMs = IDLE_WAIT_TIMEOUT_MS) {
@@ -2038,10 +2099,10 @@ async function observeBoundSessions(bc) {
2038
2099
  const data = loadBindings2(guard, paths.settingsDir);
2039
2100
  const targets = observeTargets(data);
2040
2101
  if (targets.length === 0) return;
2041
- const cursorFile = path8.join(paths.dataDir, "cursors.json");
2102
+ const cursorFile = path9.join(paths.dataDir, "cursors.json");
2042
2103
  let cursors = {};
2043
2104
  try {
2044
- cursors = JSON.parse(fs8.readFileSync(cursorFile, "utf8"));
2105
+ cursors = JSON.parse(fs9.readFileSync(cursorFile, "utf8"));
2045
2106
  } catch {
2046
2107
  }
2047
2108
  const inboxFile = inboxFilePath2(paths.dataDir);
@@ -2057,7 +2118,7 @@ async function observeBoundSessions(bc) {
2057
2118
  const e = events[i];
2058
2119
  if (e.type !== "user/message") continue;
2059
2120
  const d = e.data;
2060
- if (d?.source?.kind === "plugin") continue;
2121
+ if (d?.source && (d.source.kind === "heartbeat" || d.source.kind === "plugin" || d.source.plugin === "heartbeat")) continue;
2061
2122
  const text = (d?.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").trim();
2062
2123
  if (!text) continue;
2063
2124
  inboxAppend(guard, inboxFile, {
@@ -2391,6 +2452,13 @@ function scheduleDeferredRetry(deps) {
2391
2452
  void beat(deps);
2392
2453
  }, delayMs);
2393
2454
  }
2455
+ var TOKEN_SAVER_IDLE_SECONDS = 1800;
2456
+ function tokenSaverActive(deps) {
2457
+ if (!getRuntime().flags.tokenSaver()) return false;
2458
+ if (probeWorkstationLocked()) return true;
2459
+ const idle = probeIdleSeconds(deps.guard, deps.paths);
2460
+ return idle >= TOKEN_SAVER_IDLE_SECONDS;
2461
+ }
2394
2462
  async function beat(deps) {
2395
2463
  if (beating) return;
2396
2464
  beating = true;
@@ -2404,18 +2472,23 @@ async function beat(deps) {
2404
2472
  if (agent) {
2405
2473
  deferredRetries = 0;
2406
2474
  const bc = { deps, agent, now };
2407
- await maintenancePhase(bc);
2408
- appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "maintenance" });
2409
- await collectPhase(bc);
2410
- appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "collect" });
2411
- wandered = await wanderPhase(bc);
2412
- appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "wander" });
2413
- const refilled = await refillWanderPhase(bc);
2414
- if (refilled) {
2415
- appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "refill_wander" });
2475
+ if (tokenSaverActive(deps)) {
2476
+ appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "silent", reason: "token-saver" });
2477
+ noteBeat("silent", { reason: "token-saver\uFF08\u4F60\u4E0D\u5728\uFF0C\u5FC3\u8DF3\u6302\u8D77\uFF09" });
2478
+ } else {
2479
+ await maintenancePhase(bc);
2480
+ appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "maintenance" });
2481
+ await collectPhase(bc);
2482
+ appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "collect" });
2483
+ wandered = await wanderPhase(bc);
2484
+ appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "wander" });
2485
+ const refilled = await refillWanderPhase(bc);
2486
+ if (refilled) {
2487
+ appendAuditLine(paths.logsDir + "/heartbeat.jsonl", { event: "phase_done", phase: "refill_wander" });
2488
+ }
2489
+ wandered = wandered || refilled;
2490
+ await expressionPhases(bc);
2416
2491
  }
2417
- wandered = wandered || refilled;
2418
- await expressionPhases(bc);
2419
2492
  } else {
2420
2493
  const bc = { deps, agent: null, now };
2421
2494
  await maintenancePhase(bc);
@@ -2477,26 +2550,26 @@ function startOrchestrator(deps) {
2477
2550
 
2478
2551
  // src/rpc.ts
2479
2552
  import { spawn as spawn2 } from "child_process";
2480
- import fs11 from "fs";
2553
+ import fs12 from "fs";
2481
2554
  import os from "os";
2482
- import path11 from "path";
2555
+ import path12 from "path";
2483
2556
 
2484
2557
  // src/browse/interests-edit.ts
2485
- import fs9 from "fs";
2486
- import path9 from "path";
2558
+ import fs10 from "fs";
2559
+ import path10 from "path";
2487
2560
  var MAX_INTERESTS = 32;
2488
2561
  var MAX_INTEREST_LEN = 60;
2489
2562
  var MAX_WINDOWS = 6;
2490
2563
  var HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
2491
2564
  function userInterestsPath(paths) {
2492
- return path9.join(paths.settingsDir, "interests.json");
2565
+ return path10.join(paths.settingsDir, "interests.json");
2493
2566
  }
2494
2567
  function factoryInterestsPath(paths) {
2495
- return path9.join(paths.configDir, "interests.json");
2568
+ return path10.join(paths.configDir, "interests.json");
2496
2569
  }
2497
2570
  function readDoc(file) {
2498
2571
  try {
2499
- const raw = JSON.parse(fs9.readFileSync(file, "utf8"));
2572
+ const raw = JSON.parse(fs10.readFileSync(file, "utf8"));
2500
2573
  if (!Array.isArray(raw.interests)) return null;
2501
2574
  return { ...raw, interests: raw.interests.map(String) };
2502
2575
  } catch {
@@ -2514,8 +2587,8 @@ function ensureUserLayer(guard, paths) {
2514
2587
  return doc;
2515
2588
  }
2516
2589
  function saveDoc(guard, file, doc) {
2517
- fs9.mkdirSync(path9.dirname(file), { recursive: true });
2518
- fs9.writeFileSync(guard.assert(file), JSON.stringify(doc, null, 2), "utf8");
2590
+ fs10.mkdirSync(path10.dirname(file), { recursive: true });
2591
+ fs10.writeFileSync(guard.assert(file), JSON.stringify(doc, null, 2), "utf8");
2519
2592
  }
2520
2593
  function normalizeInterest(text) {
2521
2594
  return text.trim().replace(/\s+/g, " ");
@@ -2581,8 +2654,8 @@ function setWanderWindows(guard, paths, rawWindows) {
2581
2654
  }
2582
2655
 
2583
2656
  // src/statusbar/time-inject.ts
2584
- import fs10 from "fs";
2585
- import path10 from "path";
2657
+ import fs11 from "fs";
2658
+ import path11 from "path";
2586
2659
  function shouldInjectTime(input) {
2587
2660
  if (input.step !== 1) return false;
2588
2661
  if (!input.originIsInboxSplice) return false;
@@ -2644,11 +2717,11 @@ function lastMessageTime(session) {
2644
2717
  return void 0;
2645
2718
  }
2646
2719
  function timeInjectStatePath(dataDir) {
2647
- return path10.join(dataDir, "time-inject-state.json");
2720
+ return path11.join(dataDir, "time-inject-state.json");
2648
2721
  }
2649
2722
  function loadTimeInjectState(guard, dataDir) {
2650
2723
  try {
2651
- const parsed = JSON.parse(fs10.readFileSync(guard.assert(timeInjectStatePath(dataDir)), "utf8"));
2724
+ const parsed = JSON.parse(fs11.readFileSync(guard.assert(timeInjectStatePath(dataDir)), "utf8"));
2652
2725
  return parsed && typeof parsed === "object" ? parsed : {};
2653
2726
  } catch {
2654
2727
  return {};
@@ -2687,7 +2760,8 @@ ${statusLine}`;
2687
2760
  }
2688
2761
  const message = createUserMessage({
2689
2762
  content: [{ type: "text", text }],
2690
- source: { kind: "plugin", plugin: "heartbeat", form: "snapshot", sections: [{ name: "heartbeat-time", text }] }
2763
+ // 0.1.7 V4: producer-owned kind (retired generic 'plugin' is refused).
2764
+ source: { kind: "heartbeat", plugin: "heartbeat", form: "snapshot", sections: [{ name: "heartbeat-time", text }] }
2691
2765
  });
2692
2766
  state[sessionId] = now;
2693
2767
  saveTimeInjectState(guard, dataDir, state);
@@ -2712,7 +2786,7 @@ var cachedVersion = null;
2712
2786
  function pluginVersion(paths) {
2713
2787
  if (cachedVersion) return cachedVersion;
2714
2788
  try {
2715
- const pkg = JSON.parse(fs11.readFileSync(path11.join(paths.packageRoot, "package.json"), "utf8"));
2789
+ const pkg = JSON.parse(fs12.readFileSync(path12.join(paths.packageRoot, "package.json"), "utf8"));
2716
2790
  cachedVersion = typeof pkg.version === "string" ? pkg.version : "unknown";
2717
2791
  } catch {
2718
2792
  cachedVersion = "unknown";
@@ -2721,7 +2795,7 @@ function pluginVersion(paths) {
2721
2795
  }
2722
2796
  function homeSessionId(paths, guard) {
2723
2797
  try {
2724
- const raw = loadEncryptedText(guard, path11.join(paths.dataDir, "gate.json"));
2798
+ const raw = loadEncryptedText(guard, path12.join(paths.dataDir, "gate.json"));
2725
2799
  return JSON.parse(raw ?? "{}").sessionId ?? null;
2726
2800
  } catch {
2727
2801
  return null;
@@ -2743,13 +2817,13 @@ function loadSessionTitles() {
2743
2817
  if (row && typeof row.val === "string" && row.val && !titles[id]) titles[id] = row.val;
2744
2818
  };
2745
2819
  try {
2746
- const dir = path11.join(os.homedir(), ".dsh", "storages", "session_projcache", "sessions");
2747
- for (const file of fs11.readdirSync(dir)) {
2820
+ const dir = path12.join(os.homedir(), ".dsh", "storages", "session_projcache", "sessions");
2821
+ for (const file of fs12.readdirSync(dir)) {
2748
2822
  if (!file.endsWith(".json")) continue;
2749
2823
  const id = file.slice(0, -".json".length);
2750
2824
  if (!id.startsWith("session-")) continue;
2751
2825
  try {
2752
- const record = JSON.parse(fs11.readFileSync(path11.join(dir, file), "utf8"));
2826
+ const record = JSON.parse(fs12.readFileSync(path12.join(dir, file), "utf8"));
2753
2827
  take(id, record.record);
2754
2828
  } catch {
2755
2829
  }
@@ -2757,7 +2831,7 @@ function loadSessionTitles() {
2757
2831
  } catch {
2758
2832
  }
2759
2833
  try {
2760
- const raw = JSON.parse(fs11.readFileSync(path11.join(os.homedir(), ".dsh", "storages", "session_projcache.json"), "utf8"));
2834
+ const raw = JSON.parse(fs12.readFileSync(path12.join(os.homedir(), ".dsh", "storages", "session_projcache.json"), "utf8"));
2761
2835
  const walk = (node) => {
2762
2836
  if (!node || typeof node !== "object") return;
2763
2837
  for (const [key, value] of Object.entries(node)) {
@@ -2802,7 +2876,7 @@ function installHeartbeatRpc(ctx, deps) {
2802
2876
  }
2803
2877
  let flags = { statusbarEnabled: true, timeInjectMin: 25, idleMode: false };
2804
2878
  try {
2805
- const { getRuntime: getRuntime2 } = await import("./runtime-YHJT6TXF.js");
2879
+ const { getRuntime: getRuntime2 } = await import("./runtime-W2OWJIHY.js");
2806
2880
  const f = getRuntime2().flags;
2807
2881
  flags = { statusbarEnabled: f.statusbarEnabled(), timeInjectMin: f.timeInjectMin(), idleMode: f.idleMode() };
2808
2882
  } catch {
@@ -2826,14 +2900,14 @@ function installHeartbeatRpc(ctx, deps) {
2826
2900
  });
2827
2901
  }
2828
2902
  case "sessions.list": {
2829
- const root = path11.join(os.homedir(), ".dsh", "sessions");
2903
+ const root = path12.join(os.homedir(), ".dsh", "sessions");
2830
2904
  const bindings = loadBindings(guard, paths.settingsDir).bindings;
2831
2905
  const home = homeSessionId(paths, guard);
2832
2906
  const titles = loadSessionTitles();
2833
2907
  const out = [];
2834
- if (fs11.existsSync(root)) {
2835
- for (const slug of fs11.readdirSync(root)) {
2836
- for (const id of fs11.readdirSync(path11.join(root, slug))) {
2908
+ if (fs12.existsSync(root)) {
2909
+ for (const slug of fs12.readdirSync(root)) {
2910
+ for (const id of fs12.readdirSync(path12.join(root, slug))) {
2837
2911
  const binding = bindings.find((b) => b.sessionId === id);
2838
2912
  out.push({
2839
2913
  id,
@@ -2868,7 +2942,7 @@ function installHeartbeatRpc(ctx, deps) {
2868
2942
  let homeReset = false;
2869
2943
  if (id === homeSessionId(paths, guard)) {
2870
2944
  try {
2871
- fs11.rmSync(guard.assert(path11.join(paths.dataDir, "gate.json")), { force: true });
2945
+ fs12.rmSync(guard.assert(path12.join(paths.dataDir, "gate.json")), { force: true });
2872
2946
  homeReset = true;
2873
2947
  } catch {
2874
2948
  }
@@ -2912,6 +2986,25 @@ function installHeartbeatRpc(ctx, deps) {
2912
2986
  auditInterests(paths, "set-windows", r, JSON.stringify(p.windows ?? null));
2913
2987
  return r.ok ? ok(r.doc) : err("bad-request", r.reason ?? "setWindows failed");
2914
2988
  }
2989
+ case "config.get":
2990
+ return ok(deps.ui.get());
2991
+ case "config.set": {
2992
+ const patch = p;
2993
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
2994
+ return err("bad-request", "config.set needs a patch object");
2995
+ }
2996
+ const keys = Object.keys(patch).filter(
2997
+ (k) => ["intervalMin", "maxDailySend", "timeInjectMin", "statusbar", "idleMode", "tokenSaver", "psyEnabled"].includes(k)
2998
+ );
2999
+ if (keys.length === 0) return err("bad-request", "config.set has no recognized field");
3000
+ try {
3001
+ deps.ui.set(patch);
3002
+ } catch (e) {
3003
+ return err("bad-request", String(e).slice(0, 120));
3004
+ }
3005
+ appendAuditLine(guard.assert(paths.logsDir + "/heartbeat.jsonl"), { event: "ui_config_set", keys });
3006
+ return ok(deps.ui.get());
3007
+ }
2915
3008
  case "profile.digest": {
2916
3009
  const d = buildDigest(guard, paths, policy, {});
2917
3010
  return ok({
@@ -2931,13 +3024,13 @@ function installHeartbeatRpc(ctx, deps) {
2931
3024
  lines.push(`- [${e.topic}/${e.subTopic}] ${e.content} (conf ${e.confidence.toFixed(2)}, ${e.temporal})`);
2932
3025
  }
2933
3026
  }
2934
- const out = path11.join(paths.exportsDir, `profile-export-${Date.now()}.md`);
3027
+ const out = path12.join(paths.exportsDir, `profile-export-${Date.now()}.md`);
2935
3028
  writeText(guard, out, lines.join("\n") + "\n");
2936
3029
  return ok({ path: out });
2937
3030
  }
2938
3031
  case "ledger.open": {
2939
3032
  const f = ledgerFilePath(paths.dataDir);
2940
- if (!fs11.existsSync(guard.assert(f))) fs11.writeFileSync(f, "# \u8D26\u672C\n", "utf8");
3033
+ if (!fs12.existsSync(guard.assert(f))) fs12.writeFileSync(f, "# \u8D26\u672C\n", "utf8");
2941
3034
  spawn2("cmd", ["/c", "start", "", f], { detached: true, stdio: "ignore" }).unref();
2942
3035
  return ok({ path: f });
2943
3036
  }
@@ -3100,12 +3193,21 @@ var Config = Schema.object({
3100
3193
  /** Statusbar master switch (D19). Off = no section/pre-step status; time injection unaffected. */
3101
3194
  statusbar: Schema.boolean().default(true),
3102
3195
  /** v1.6.3 闲着模式:素材池为空时用画像话题兜底主动搭话(默认关)。 */
3103
- idleMode: Schema.boolean().default(false)
3196
+ idleMode: Schema.boolean().default(false),
3197
+ /** v1.7.0 节省 token 模式:用户离开(闲置 ≥30 分钟)或锁屏时整跳暂停(默认关)。 */
3198
+ tokenSaver: Schema.boolean().default(false)
3104
3199
  });
3105
3200
  function apply(ctx, config = {}) {
3106
3201
  const paths = initWorkspace(config.dataDir ? { dataDir: config.dataDir } : {});
3107
3202
  const guard = createPathGuard(paths.dataDir);
3108
3203
  let policy = loadPolicy(guard, paths.configDir, paths.settingsDir);
3204
+ const ui = loadUiConfig(guard, paths.settingsDir);
3205
+ if (ui.intervalMin && ui.intervalMin >= 1) {
3206
+ policy = { ...policy, heartbeat: { ...policy.heartbeat, intervalMin: ui.intervalMin } };
3207
+ }
3208
+ if (ui.maxDailySend && ui.maxDailySend >= 1) {
3209
+ policy = { ...policy, gate: { ...policy.gate, maxDailySend: ui.maxDailySend } };
3210
+ }
3109
3211
  if (config.intervalMin && config.intervalMin >= 1) {
3110
3212
  policy = { ...policy, heartbeat: { ...policy.heartbeat, intervalMin: config.intervalMin } };
3111
3213
  }
@@ -3120,7 +3222,8 @@ function apply(ctx, config = {}) {
3120
3222
  flags: {
3121
3223
  statusbarEnabled: () => statusbarEnabledRef,
3122
3224
  timeInjectMin: () => timeInjectMinRef,
3123
- idleMode: () => idleModeRef
3225
+ idleMode: () => idleModeRef,
3226
+ tokenSaver: () => tokenSaverRef
3124
3227
  }
3125
3228
  });
3126
3229
  ctx.inject(["agentPresets"], (presetCtx) => {
@@ -3148,9 +3251,10 @@ function apply(ctx, config = {}) {
3148
3251
  }
3149
3252
  });
3150
3253
  let sectionSource = null;
3151
- let timeInjectMinRef = config.timeInjectMin ?? 25;
3152
- let statusbarEnabledRef = config.statusbar !== false;
3153
- let idleModeRef = config.idleMode === true;
3254
+ let timeInjectMinRef = ui.timeInjectMin ?? (config.timeInjectMin ?? 25);
3255
+ let statusbarEnabledRef = ui.statusbar ?? config.statusbar !== false;
3256
+ let idleModeRef = ui.idleMode ?? config.idleMode === true;
3257
+ let tokenSaverRef = ui.tokenSaver ?? config.tokenSaver === true;
3154
3258
  const applySettingsOverrides = () => {
3155
3259
  try {
3156
3260
  const v = sectionSource?.();
@@ -3171,7 +3275,6 @@ function apply(ctx, config = {}) {
3171
3275
  void (async () => {
3172
3276
  try {
3173
3277
  const settingsService = ctx.get("settings");
3174
- const legacy = await import("@deepseek-ai/dsh-settings");
3175
3278
  if (settingsService && typeof settingsService.installSection === "function") {
3176
3279
  settingsService.installSection(ctx, "heartbeat", Config, config, {
3177
3280
  setSource: (current) => {
@@ -3181,7 +3284,18 @@ function apply(ctx, config = {}) {
3181
3284
  applySettingsOverrides();
3182
3285
  }
3183
3286
  });
3184
- } else if (typeof legacy.installSettingsSection === "function" && typeof legacy.settingsNamespace === "function") {
3287
+ applySettingsOverrides();
3288
+ ctx.logger.info("heartbeat: settings section registered (0.1.5 settings service)");
3289
+ return;
3290
+ }
3291
+ if (settingsService && typeof settingsService.describe === "function") {
3292
+ sectionSource = () => config;
3293
+ applySettingsOverrides();
3294
+ ctx.logger.info("heartbeat: config served by the 0.1.7 profile form (edits reload the plugin)");
3295
+ return;
3296
+ }
3297
+ const legacy = await import("@deepseek-ai/dsh-settings");
3298
+ if (typeof legacy.installSettingsSection === "function" && typeof legacy.settingsNamespace === "function") {
3185
3299
  legacy.installSettingsSection(ctx, legacy.settingsNamespace("heartbeat"), Config, config, {
3186
3300
  setSource: (current) => {
3187
3301
  sectionSource = current;
@@ -3190,11 +3304,11 @@ function apply(ctx, config = {}) {
3190
3304
  applySettingsOverrides();
3191
3305
  }
3192
3306
  });
3193
- } else {
3194
- throw new Error("no installSection method on the settings service and no legacy export");
3307
+ applySettingsOverrides();
3308
+ ctx.logger.info("heartbeat: settings section registered (legacy 0.1.1 settings)");
3309
+ return;
3195
3310
  }
3196
- applySettingsOverrides();
3197
- ctx.logger.info("heartbeat: settings section registered");
3311
+ ctx.logger.warn("heartbeat: no settings integration found; policy file values only");
3198
3312
  } catch (e) {
3199
3313
  ctx.logger.warn("heartbeat: settings section unavailable (%s)", String(e).slice(0, 120));
3200
3314
  }
@@ -3203,7 +3317,32 @@ function apply(ctx, config = {}) {
3203
3317
  guard.assert(paths.logsDir + "/heartbeat.jsonl"),
3204
3318
  { event: "plugin_init", dataDir: paths.dataDir }
3205
3319
  );
3206
- installHeartbeatRpc(ctx, { paths, guard, policy });
3320
+ const uiGet = () => ({
3321
+ intervalMin: getRuntime().policy.heartbeat.intervalMin,
3322
+ maxDailySend: getRuntime().policy.gate.maxDailySend,
3323
+ timeInjectMin: timeInjectMinRef,
3324
+ statusbar: statusbarEnabledRef,
3325
+ idleMode: idleModeRef,
3326
+ tokenSaver: tokenSaverRef,
3327
+ psyEnabled: getRuntime().policy.profile.psyEnabled
3328
+ });
3329
+ const uiSet = (patch) => {
3330
+ if (typeof patch.psyEnabled === "boolean") {
3331
+ updateUserPolicy(guard, paths.settingsDir, { profile: { psyEnabled: patch.psyEnabled } });
3332
+ getRuntime().policy.profile.psyEnabled = patch.psyEnabled;
3333
+ }
3334
+ const merged = saveUiConfig(guard, paths.settingsDir, patch);
3335
+ if (merged.intervalMin && merged.intervalMin >= 1) applyHeartbeatInterval(deps, merged.intervalMin);
3336
+ if (merged.maxDailySend && merged.maxDailySend >= 1) getRuntime().policy.gate.maxDailySend = merged.maxDailySend;
3337
+ if (typeof merged.timeInjectMin === "number") timeInjectMinRef = merged.timeInjectMin;
3338
+ if (typeof merged.statusbar === "boolean") statusbarEnabledRef = merged.statusbar;
3339
+ if (typeof merged.idleMode === "boolean") {
3340
+ idleModeRef = merged.idleMode;
3341
+ getRuntime().policy.heartbeat.idleMode = merged.idleMode;
3342
+ }
3343
+ if (typeof merged.tokenSaver === "boolean") tokenSaverRef = merged.tokenSaver;
3344
+ };
3345
+ installHeartbeatRpc(ctx, { paths, guard, policy, ui: { get: uiGet, set: uiSet } });
3207
3346
  startOrchestrator(deps);
3208
3347
  const statusReader = new StatusReader();
3209
3348
  registerStatusbarSection(ctx, guard, paths, {