@furongjun1999/dsh-memory 0.3.1 → 0.4.1

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/lib/bridge.d.ts CHANGED
@@ -49,6 +49,10 @@ export declare class LingshuBridge {
49
49
  private retryTimer;
50
50
  private bootQueue;
51
51
  private readyState;
52
+ /** issue #7:当前子进程启动时间(计算 uptime,区分秒退与长存后外部关闭) */
53
+ private procStartedAt;
54
+ /** issue #7:运行期反复退出的滑动窗口(时间戳列表)——防无限重启刷屏 */
55
+ private unexpectedExits;
52
56
  constructor(options: BridgeOptions);
53
57
  /**
54
58
  * 启动灵枢进程并完成握手(initialize → notifications/initialized)。
package/lib/bridge.js CHANGED
@@ -48,6 +48,10 @@ export class LingshuBridge {
48
48
  retryTimer = null;
49
49
  bootQueue = [];
50
50
  readyState = 'pending';
51
+ /** issue #7:当前子进程启动时间(计算 uptime,区分秒退与长存后外部关闭) */
52
+ procStartedAt = 0;
53
+ /** issue #7:运行期反复退出的滑动窗口(时间戳列表)——防无限重启刷屏 */
54
+ unexpectedExits = [];
51
55
  constructor(options) {
52
56
  this.options = options;
53
57
  }
@@ -112,6 +116,7 @@ export class LingshuBridge {
112
116
  spawnAndHandshake() {
113
117
  if (this.disposed)
114
118
  return;
119
+ this.procStartedAt = Date.now();
115
120
  const { python, args, env, cwd } = this.options;
116
121
  const childEnv = { ...process.env, ...env };
117
122
  // 确保 DB 目录存在(灵枢 server 也会防御性创建,这里提前为可读错误)
@@ -165,12 +170,37 @@ export class LingshuBridge {
165
170
  });
166
171
  proc.on('exit', (code, signal) => {
167
172
  // 探针:写独立文件记录退出(绕过 DSH 日志系统,便于定位)
168
- probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started}`);
169
- console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}`);
173
+ const uptimeS = this.procStartedAt
174
+ ? Math.round((Date.now() - this.procStartedAt) / 1000)
175
+ : -1;
176
+ probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started} uptime=${uptimeS}s`);
177
+ console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}(存活 ${uptimeS}s)`);
170
178
  this.rl?.close();
171
179
  this.rl = null;
172
180
  this.rejectAll(new Error(`灵枢进程已退出(code=${code} signal=${signal ?? 'none'})`));
173
181
  if (!this.disposed) {
182
+ // issue #7:长存后 code=0 退出 = stdin 被外部关闭(非崩溃)。
183
+ // 记录滑动窗口(10 分钟内 ≥3 次)→ 进入冷却,防「外部反复关闭 +
184
+ // 无限重启」刷屏;冷却结束自动恢复,不进入放弃终态。
185
+ this.unexpectedExits = this.unexpectedExits.filter((t) => Date.now() - t < 600_000);
186
+ this.unexpectedExits.push(Date.now());
187
+ if (uptimeS >= 0 && uptimeS < 5) {
188
+ console.error('[lingshu-bridge] 进程启动后 5 秒内即退出——请检查 python 可执行文件与 aeis 安装。');
189
+ }
190
+ else if (this.unexpectedExits.length >= 3) {
191
+ console.error(`[lingshu-bridge] 10 分钟内已意外退出 ${this.unexpectedExits.length} 次,` +
192
+ '冷却 5 分钟后自动恢复。若持续出现,请检查是否有多个插件/脚本'
193
+ + '同时管理灵枢进程(重复 spawn 会互相关闭对方子进程的 stdin)。');
194
+ probe(`cooldown: ${this.unexpectedExits.length} unexpected exits in 10min`);
195
+ this.retryTimer = setTimeout(() => {
196
+ this.retryTimer = null;
197
+ this.unexpectedExits = [];
198
+ if (!this.disposed)
199
+ this.spawnAndHandshake();
200
+ }, 300_000);
201
+ this.retryTimer.unref();
202
+ return;
203
+ }
174
204
  // 有挂起请求的失败是异常的;仅启动失败的等待者得到 false
175
205
  this.readyState = 'failed';
176
206
  this.flushBootQueue(false);
package/lib/index.js CHANGED
@@ -75,6 +75,10 @@ export const Config = z.object({
75
75
  toolCallTimeoutMs: z.number().default(60_000),
76
76
  maxRetryDelayMs: z.number().default(30_000),
77
77
  failOnStartupError: z.boolean().default(false),
78
+ /** 角色扮演入口按钮(issue #8):注入 dsh 首页右上角浮动入口;
79
+ * 关闭后不注入(/roleplay 页面仍可直接访问)。按钮本身支持拖动,
80
+ * 位置记忆在浏览器 localStorage。 */
81
+ roleplayEntryButton: z.boolean().default(true),
78
82
  /** 互维维护(v1.1):心跳 10min / 任务验证双通道。 */
79
83
  mutual: z
80
84
  .object({
@@ -839,13 +839,36 @@ export async function installRoleplayWeb(ctx, bridge, config, disposers) {
839
839
  };
840
840
  disposers.push(webServer.register({ kind: 'prefix', path: '/roleplay', handler }));
841
841
  // 在 GUI 首页注入浮动入口按钮(APP 内置页面内一键进入角色扮演,右上角)
842
- disposers.push(webServer.tapIndex((html) => {
843
- if (html.includes('href="/roleplay"'))
844
- return html;
845
- const btn = '<a href="/roleplay" style="position:fixed;top:12px;right:14px;z-index:999999;background:#7c8cff;color:#0b0d14;border-radius:999px;padding:9px 15px;font:600 13px/1 system-ui,sans-serif;text-decoration:none;box-shadow:0 4px 14px rgba(0,0,0,.45)">🎭 角色扮演</a>';
846
- return html.replace('</body>', `${btn}\n</body>`);
847
- }));
848
- ctx.logger.info(`dsh-memory: 角色扮演网页已挂载 /roleplay(data_dir=${roleDataDir})`);
842
+ // issue #8:按钮可隐藏(roleplayEntryButton=false 不注入)+ 可拖动
843
+ // (pointer 拖拽,位置记忆在 localStorage;移动 >6px 视为拖动,否则放行点击跳转)
844
+ if (config.roleplayEntryButton) {
845
+ disposers.push(webServer.tapIndex((html) => {
846
+ if (html.includes('href="/roleplay"'))
847
+ return html;
848
+ const btn = '<a id="dshm-rp" href="/roleplay" style="position:fixed;top:12px;right:14px;z-index:999999;background:#7c8cff;color:#0b0d14;border-radius:999px;padding:9px 15px;font:600 13px/1 system-ui,sans-serif;text-decoration:none;box-shadow:0 4px 14px rgba(0,0,0,.45);touch-action:none;user-select:none;cursor:grab">🎭 角色扮演</a>' +
849
+ '<script>(function(){var b=document.getElementById("dshm-rp");if(!b)return;' +
850
+ 'try{var p=JSON.parse(localStorage.getItem("dshm_rlp")||"null");' +
851
+ 'if(p&&p.l!=null){b.style.right="auto";b.style.left=p.l+"px";b.style.top=p.t+"px";}}catch(e){}' +
852
+ 'var sx=0,sy=0,sl=0,st=0,drag=false;' +
853
+ 'b.addEventListener("pointerdown",function(e){sx=e.clientX;sy=e.clientY;' +
854
+ 'var r=b.getBoundingClientRect();sl=r.left;st=r.top;drag=false;' +
855
+ 'try{b.setPointerCapture(e.pointerId);}catch(err){}});' +
856
+ 'b.addEventListener("pointermove",function(e){' +
857
+ 'if(e.buttons===0||!drag&&!sx&&!sy)return;' +
858
+ 'var dx=e.clientX-sx,dy=e.clientY-sy;' +
859
+ 'if(!drag&&Math.sqrt(dx*dx+dy*dy)>6){drag=true;b.style.right="auto";b.style.cursor="grabbing";}' +
860
+ 'if(drag){var L=Math.max(4,Math.min(window.innerWidth-b.offsetWidth-4,sl+dx)),' +
861
+ 'T=Math.max(4,Math.min(window.innerHeight-b.offsetHeight-4,st+dy));' +
862
+ 'b.style.left=L+"px";b.style.top=T+"px";e.preventDefault();}});' +
863
+ 'b.addEventListener("pointerup",function(e){' +
864
+ 'if(drag){e.preventDefault();' +
865
+ 'try{var r=b.getBoundingClientRect();' +
866
+ 'localStorage.setItem("dshm_rlp",JSON.stringify({l:r.left,t:r.top}));}catch(err){}}' +
867
+ 'drag=false;sx=0;sy=0;});})();</script>';
868
+ return html.replace('</body>', `${btn}\n</body>`);
869
+ }));
870
+ }
871
+ ctx.logger.info(`dsh-memory: 角色扮演网页已挂载 /roleplay(data_dir=${roleDataDir},入口按钮=${config.roleplayEntryButton ? '显示(可拖动)' : '隐藏'})`);
849
872
  }
850
873
  catch (err) {
851
874
  ctx.logger.warn(`dsh-memory: 角色扮演网页挂载失败(不影响其他功能): ${String(err && err.message || err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@furongjun1999/dsh-memory",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "灵枢(Lingshu·líng shū)DeepSeek Harness 插件:完整大脑——长期记忆/知识飞轮/自我认知/递归反思接入 DSH,对话自动沉淀进记忆库",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/bridge.ts CHANGED
@@ -86,6 +86,10 @@ export class LingshuBridge {
86
86
  private retryTimer: NodeJS.Timeout | null = null
87
87
  private bootQueue: Array<(ok: boolean) => void> = []
88
88
  private readyState: 'pending' | 'ok' | 'failed' = 'pending'
89
+ /** issue #7:当前子进程启动时间(计算 uptime,区分秒退与长存后外部关闭) */
90
+ private procStartedAt = 0
91
+ /** issue #7:运行期反复退出的滑动窗口(时间戳列表)——防无限重启刷屏 */
92
+ private unexpectedExits: number[] = []
89
93
 
90
94
  constructor(options: BridgeOptions) {
91
95
  this.options = options
@@ -154,6 +158,7 @@ export class LingshuBridge {
154
158
 
155
159
  private spawnAndHandshake(): void {
156
160
  if (this.disposed) return
161
+ this.procStartedAt = Date.now()
157
162
  const { python, args, env, cwd } = this.options
158
163
  const childEnv = { ...process.env, ...env }
159
164
  // 确保 DB 目录存在(灵枢 server 也会防御性创建,这里提前为可读错误)
@@ -207,12 +212,39 @@ export class LingshuBridge {
207
212
 
208
213
  proc.on('exit', (code, signal) => {
209
214
  // 探针:写独立文件记录退出(绕过 DSH 日志系统,便于定位)
210
- probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started}`)
211
- console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}`)
215
+ const uptimeS = this.procStartedAt
216
+ ? Math.round((Date.now() - this.procStartedAt) / 1000)
217
+ : -1
218
+ probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started} uptime=${uptimeS}s`)
219
+ console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}(存活 ${uptimeS}s)`)
212
220
  this.rl?.close()
213
221
  this.rl = null
214
222
  this.rejectAll(new Error(`灵枢进程已退出(code=${code} signal=${signal ?? 'none'})`))
215
223
  if (!this.disposed) {
224
+ // issue #7:长存后 code=0 退出 = stdin 被外部关闭(非崩溃)。
225
+ // 记录滑动窗口(10 分钟内 ≥3 次)→ 进入冷却,防「外部反复关闭 +
226
+ // 无限重启」刷屏;冷却结束自动恢复,不进入放弃终态。
227
+ this.unexpectedExits = this.unexpectedExits.filter(
228
+ (t) => Date.now() - t < 600_000,
229
+ )
230
+ this.unexpectedExits.push(Date.now())
231
+ if (uptimeS >= 0 && uptimeS < 5) {
232
+ console.error(
233
+ '[lingshu-bridge] 进程启动后 5 秒内即退出——请检查 python 可执行文件与 aeis 安装。')
234
+ } else if (this.unexpectedExits.length >= 3) {
235
+ console.error(
236
+ `[lingshu-bridge] 10 分钟内已意外退出 ${this.unexpectedExits.length} 次,` +
237
+ '冷却 5 分钟后自动恢复。若持续出现,请检查是否有多个插件/脚本'
238
+ + '同时管理灵枢进程(重复 spawn 会互相关闭对方子进程的 stdin)。')
239
+ probe(`cooldown: ${this.unexpectedExits.length} unexpected exits in 10min`)
240
+ this.retryTimer = setTimeout(() => {
241
+ this.retryTimer = null
242
+ this.unexpectedExits = []
243
+ if (!this.disposed) this.spawnAndHandshake()
244
+ }, 300_000)
245
+ this.retryTimer.unref()
246
+ return
247
+ }
216
248
  // 有挂起请求的失败是异常的;仅启动失败的等待者得到 false
217
249
  this.readyState = 'failed'
218
250
  this.flushBootQueue(false)
package/src/index.ts CHANGED
@@ -114,6 +114,10 @@ export const Config: z<Config> = z.object({
114
114
  toolCallTimeoutMs: z.number().default(60_000),
115
115
  maxRetryDelayMs: z.number().default(30_000),
116
116
  failOnStartupError: z.boolean().default(false),
117
+ /** 角色扮演入口按钮(issue #8):注入 dsh 首页右上角浮动入口;
118
+ * 关闭后不注入(/roleplay 页面仍可直接访问)。按钮本身支持拖动,
119
+ * 位置记忆在浏览器 localStorage。 */
120
+ roleplayEntryButton: z.boolean().default(true),
117
121
  /** 互维维护(v1.1):心跳 10min / 任务验证双通道。 */
118
122
  mutual: z
119
123
  .object({
@@ -839,13 +839,36 @@ export async function installRoleplayWeb(ctx, bridge, config, disposers) {
839
839
  };
840
840
  disposers.push(webServer.register({ kind: 'prefix', path: '/roleplay', handler }));
841
841
  // 在 GUI 首页注入浮动入口按钮(APP 内置页面内一键进入角色扮演,右上角)
842
- disposers.push(webServer.tapIndex((html) => {
843
- if (html.includes('href="/roleplay"'))
844
- return html;
845
- const btn = '<a href="/roleplay" style="position:fixed;top:12px;right:14px;z-index:999999;background:#7c8cff;color:#0b0d14;border-radius:999px;padding:9px 15px;font:600 13px/1 system-ui,sans-serif;text-decoration:none;box-shadow:0 4px 14px rgba(0,0,0,.45)">🎭 角色扮演</a>';
846
- return html.replace('</body>', `${btn}\n</body>`);
847
- }));
848
- ctx.logger.info(`dsh-memory: 角色扮演网页已挂载 /roleplay(data_dir=${roleDataDir})`);
842
+ // issue #8:按钮可隐藏(roleplayEntryButton=false 不注入)+ 可拖动
843
+ // (pointer 拖拽,位置记忆在 localStorage;移动 >6px 视为拖动,否则放行点击跳转)
844
+ if (config.roleplayEntryButton) {
845
+ disposers.push(webServer.tapIndex((html) => {
846
+ if (html.includes('href="/roleplay"'))
847
+ return html;
848
+ const btn = '<a id="dshm-rp" href="/roleplay" style="position:fixed;top:12px;right:14px;z-index:999999;background:#7c8cff;color:#0b0d14;border-radius:999px;padding:9px 15px;font:600 13px/1 system-ui,sans-serif;text-decoration:none;box-shadow:0 4px 14px rgba(0,0,0,.45);touch-action:none;user-select:none;cursor:grab">🎭 角色扮演</a>' +
849
+ '<script>(function(){var b=document.getElementById("dshm-rp");if(!b)return;' +
850
+ 'try{var p=JSON.parse(localStorage.getItem("dshm_rlp")||"null");' +
851
+ 'if(p&&p.l!=null){b.style.right="auto";b.style.left=p.l+"px";b.style.top=p.t+"px";}}catch(e){}' +
852
+ 'var sx=0,sy=0,sl=0,st=0,drag=false;' +
853
+ 'b.addEventListener("pointerdown",function(e){sx=e.clientX;sy=e.clientY;' +
854
+ 'var r=b.getBoundingClientRect();sl=r.left;st=r.top;drag=false;' +
855
+ 'try{b.setPointerCapture(e.pointerId);}catch(err){}});' +
856
+ 'b.addEventListener("pointermove",function(e){' +
857
+ 'if(e.buttons===0||!drag&&!sx&&!sy)return;' +
858
+ 'var dx=e.clientX-sx,dy=e.clientY-sy;' +
859
+ 'if(!drag&&Math.sqrt(dx*dx+dy*dy)>6){drag=true;b.style.right="auto";b.style.cursor="grabbing";}' +
860
+ 'if(drag){var L=Math.max(4,Math.min(window.innerWidth-b.offsetWidth-4,sl+dx)),' +
861
+ 'T=Math.max(4,Math.min(window.innerHeight-b.offsetHeight-4,st+dy));' +
862
+ 'b.style.left=L+"px";b.style.top=T+"px";e.preventDefault();}});' +
863
+ 'b.addEventListener("pointerup",function(e){' +
864
+ 'if(drag){e.preventDefault();' +
865
+ 'try{var r=b.getBoundingClientRect();' +
866
+ 'localStorage.setItem("dshm_rlp",JSON.stringify({l:r.left,t:r.top}));}catch(err){}}' +
867
+ 'drag=false;sx=0;sy=0;});})();</script>';
868
+ return html.replace('</body>', `${btn}\n</body>`);
869
+ }));
870
+ }
871
+ ctx.logger.info(`dsh-memory: 角色扮演网页已挂载 /roleplay(data_dir=${roleDataDir},入口按钮=${config.roleplayEntryButton ? '显示(可拖动)' : '隐藏'})`);
849
872
  }
850
873
  catch (err) {
851
874
  ctx.logger.warn(`dsh-memory: 角色扮演网页挂载失败(不影响其他功能): ${String(err && err.message || err)}`);