@a9i5k4/dsh-auto-memory 2.1.4 → 2.1.6

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/index.js CHANGED
@@ -35,6 +35,7 @@ import { createActivationHost } from './activation-host.js'
35
35
  import { createJsSemanticEnginePre, createSemanticDownloaderPre, fuseD6Pre, probeJsSemanticAssets, deepScanPeerTransformers, E5_SMALL_Q8_MANIFEST_V1 } from './semantic-js.js'
36
36
  import { loadAndVerifyPolicy, decideActivationV2, lexicalContainment } from './semantic-decide.js'
37
37
  import { createPythonSidecarClientPre, defaultWorkerScriptPathPre } from './python-sidecar-client.js'
38
+ import { createPythonSetupPre } from './python-setup.js'
38
39
  import { createEpisodicStorePre } from './episodic-store.js'
39
40
  import { createFactStorePre } from './fact-store.js'
40
41
  import { createProcedureStorePre } from './procedure-store.js'
@@ -93,6 +94,12 @@ export const API = {
93
94
  notices: '/api/dsh-auto-memory/notices',
94
95
  'activation-inbox': '/api/dsh-auto-memory/activation-inbox',
95
96
  'semantic-status': '/api/dsh-auto-memory/semantic-status',
97
+ 'py-setup-detect': '/api/dsh-auto-memory/python-setup/detect',
98
+ 'py-setup-venv': '/api/dsh-auto-memory/python-setup/venv',
99
+ 'py-setup-deps': '/api/dsh-auto-memory/python-setup/deps',
100
+ 'py-setup-model': '/api/dsh-auto-memory/python-setup/model',
101
+ 'py-setup-cancel': '/api/dsh-auto-memory/python-setup/cancel',
102
+ 'py-setup-status': '/api/dsh-auto-memory/python-setup/status',
96
103
  'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
97
104
  'handoff-state': '/api/dsh-auto-memory/handoff-state',
98
105
  'semantic-download': '/api/dsh-auto-memory/semantic-download',
@@ -1228,7 +1235,7 @@ class MemoryEngine {
1228
1235
  if (!agent || typeof agent !== 'object') return 0
1229
1236
  const runtime = this.resolveObserverRuntime(agent)
1230
1237
  if (!runtime || runtime.disposed) return 0
1231
- const events = agent.session && Array.isArray(agent.session.events) ? agent.session.events : []
1238
+ const events = sessionEventsOf(agent && agent.session)
1232
1239
  const capped = events.length > SEED_REPLAY_MAX_EVENTS ? events.slice(-SEED_REPLAY_MAX_EVENTS) : events
1233
1240
  this._observerStats.seedTruncatedEvents += events.length - capped.length
1234
1241
  let seeded = 0
@@ -1578,7 +1585,7 @@ class MemoryEngine {
1578
1585
  // compaction 事件感知:harness 刚发生 compaction/summary|prune → 无论水位,立即补写交接材料
1579
1586
  let compacted = false
1580
1587
  try {
1581
- const events = (agent.session && agent.session.events) || []
1588
+ const events = sessionEventsOf(agent && agent.session)
1582
1589
  for (let i = Math.max(0, events.length - 64); i < events.length; i++) {
1583
1590
  const t = events[i] && events[i].type
1584
1591
  if (t === 'compaction/summary' || t === 'compaction/prune') {
@@ -2256,6 +2263,78 @@ class MemoryEngine {
2256
2263
  return true
2257
2264
  }
2258
2265
 
2266
+ /**
2267
+ * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
2268
+ * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
2269
+ * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
2270
+ * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
2271
+ * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
2272
+ * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
2273
+ */
2274
+ async compactAnchoredLayer(store, filePath, layer, today, paths) {
2275
+ const cur = await this.readTextSafe(filePath)
2276
+ if (!cur || !cur.trim()) return false
2277
+ let parsed
2278
+ try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
2279
+ if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
2280
+ const anchored = parsed.records.filter((r) => r.kind === 'anchored')
2281
+ if (!anchored.length) return 'not-anchored'
2282
+ const limit = layer === 'user' ? 4000 : 3000
2283
+ const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
2284
+ const buf = Buffer.from(cur, 'utf8')
2285
+ // 配额从尾回溯(F1):新的留;今天记录无条件保留
2286
+ const keep = new Array(anchored.length).fill(false)
2287
+ let keptBytes = 0
2288
+ for (let i = anchored.length - 1; i >= 0; i--) {
2289
+ const rec = anchored[i]
2290
+ const spanBytes = rec.byteEnd - rec.markerByteStart
2291
+ if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
2292
+ }
2293
+ const removedIdx = []
2294
+ anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
2295
+ if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
2296
+ // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
2297
+ const blocks = []
2298
+ for (const r of parsed.records) {
2299
+ if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
2300
+ else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
2301
+ }
2302
+ blocks.sort((a, b) => a.start - b.start)
2303
+ const parts = []
2304
+ let cursorB = 0
2305
+ for (let i = 0; i < blocks.length; i++) {
2306
+ const b = blocks[i]
2307
+ if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
2308
+ if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
2309
+ cursorB = b.end
2310
+ }
2311
+ if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
2312
+ const newText = parts.join('')
2313
+ // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
2314
+ const archiveFile = layer === 'user'
2315
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
2316
+ : path.join(paths.projectDir, 'archive', 'notes-archived.md')
2317
+ const archiveText = removedIdx.map((i2) => {
2318
+ const r = anchored[i2]
2319
+ return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
2320
+ }).join('\n')
2321
+ const existingArchive = await this.readTextSafe(archiveFile)
2322
+ await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
2323
+ const rr = await store.replace(filePath, newText)
2324
+ if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
2325
+ const freshText = await this.readTextSafe(filePath)
2326
+ if (layer === 'user') this.state.userText = freshText
2327
+ else this.state.notesText = freshText
2328
+ if (this._budgets) {
2329
+ const b = this._budgets.get('today')
2330
+ if (b && b.date === this.memToday()) b[layer] = 0
2331
+ }
2332
+ this._lastCompactAt = Date.now()
2333
+ this.state.loadedAt = Date.now()
2334
+ console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
2335
+ return true
2336
+ }
2337
+
2259
2338
  // ---------- 注入渲染(同步,基于缓存) ----------
2260
2339
  // 动态记忆 → ctx.systemPrompt.context()(user-role 快照):内容变化才追加新快照,内容不变不重复注入(dsh-agent-loop project() 去重),
2261
2340
  // system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(对比 section 方案:动态内容任何变化都从变化点起击穿整个前缀,含全部历史)
@@ -2291,6 +2370,17 @@ class MemoryEngine {
2291
2370
  if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.8) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2292
2371
  lines.push('\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
2293
2372
  }
2373
+ // M-CM1 交接白板:PLAN.md 全貌+最新交接账本,动态快照首位(动态层内容,不碰静态字节;敏感段清洗+行边界硬预算截断)
2374
+ if (cfg.handoffEnabled !== false && (s.planText || s.latestHandoffText)) {
2375
+ const planPart = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.planText || '')), Math.max(Number(cfg.handoffPlanChars) || 1200, 200))
2376
+ if (planPart) lines.push('\n' + L('snapshotPlanTitle') + '\n' + planPart)
2377
+ const ledgerPart = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.latestHandoffText || '')), Math.max(Number(cfg.handoffLedgerChars) || 800, 200))
2378
+ if (ledgerPart) lines.push('\n' + L('snapshotHandoffTitle') + '\n' + ledgerPart)
2379
+ }
2380
+ // M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
2381
+ if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.8) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2382
+ lines.push('\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
2383
+ }
2294
2384
  let used = 0
2295
2385
  const part = (title, text, max) => {
2296
2386
  if (!text) return
@@ -3208,7 +3298,7 @@ class MemoryEngine {
3208
3298
  const a = agentSvc.get(best.id)
3209
3299
  if (a && a.session && a.session.header && a.session.header.parentSession === undefined) {
3210
3300
  this._lastAgent = a
3211
- diag('restoreLastAgent: recovered agent id=' + a.id + ' session=' + a.session.id + ' logEvents=' + ((a.session.events && a.session.events.length) || 0))
3301
+ diag('restoreLastAgent: recovered agent id=' + a.id + ' session=' + a.session.id + ' logEvents=' + sessionEventsOf(a.session).length)
3212
3302
  } else {
3213
3303
  diag('restoreLastAgent: candidate rejected (id=' + (a && a.id) + ' hasSession=' + !!(a && a.session) + ' parent=' + (a && a.session && a.session.header && a.session.header.parentSession) + ')')
3214
3304
  }
@@ -3413,7 +3503,7 @@ class MemoryEngine {
3413
3503
  runtime.lastTurn = turn
3414
3504
  // 取本轮最后一条 user + 最后一条 assistant(模型可见消息序列)
3415
3505
  const messages = extractSessionMessages(agent)
3416
- if (messages.length < 2) { why('messages<2 got=' + messages.length + ' seqs=' + ((agent.session.surface && agent.session.surface.nodes && (Array.isArray(agent.session.surface.nodes) ? agent.session.surface.nodes.length : 'set')) || 'none') + ' events=' + ((agent.session.events && agent.session.events.length) || 'none')); return }
3506
+ if (messages.length < 2) { why('messages<2 got=' + messages.length + ' seqs=' + ((agent.session.surface && agent.session.surface.nodes && (Array.isArray(agent.session.surface.nodes) ? agent.session.surface.nodes.length : 'set')) || 'none') + ' events=' + (sessionEventsOf(agent && agent.session).length || 'none')); return }
3417
3507
  // 2026-08-30 intent 提纯(三层):①只认真人消息事件 'user/message'(tool/result 在协议里
3418
3508
  // 也是 user 角色,正文常是 JSON/行号文本)②跳过 harness 合成的上下文注入消息
3419
3509
  // ("Current runtime context." 开头 / 含 <memory_system>)③剥离注入快照块,取其后真人问题。
@@ -4442,13 +4532,33 @@ function textOfContent(content) {
4442
4532
  walk(content, 0)
4443
4533
  return out.join('')
4444
4534
  }
4535
+ /**
4536
+ * 新旧两代 Session API 兼容的原始事件数组读取:
4537
+ * - 旧版 @deepseek-ai/dsh-session 在 Session 上暴露 .events 数组;
4538
+ * - 新版已移除 .events,事件读取走 snapshotEvents()(内部 eventsSnapshot 缓存,
4539
+ * 同一 log 追加前重复调用零重复开销;返回 frozen 数组,只读安全)。
4540
+ * 返回值恒为数组(找不到任何来源时为 []),不做截断——调用方各自决定窗口。
4541
+ */
4542
+ function sessionEventsOf(session) {
4543
+ try {
4544
+ if (!session) return []
4545
+ if (Array.isArray(session.events)) return session.events
4546
+ if (typeof session.snapshotEvents === 'function') {
4547
+ const snap = session.snapshotEvents()
4548
+ if (Array.isArray(snap)) return snap
4549
+ }
4550
+ } catch (e) {}
4551
+ return []
4552
+ }
4445
4553
  function extractSessionMessages(agent) {
4446
4554
  try {
4447
4555
  const session = agent && agent.session
4448
4556
  if (!session) return []
4449
- // 只读 events 数组(截断尾部),绝不访问 session.surface.nodes:
4557
+ // 只读事件数组(截断尾部),绝不访问 session.surface.nodes:
4450
4558
  // 该访问会触发惰性投影,长会话(上万事件)时同步遍历阻塞主线程 → 窗口卡死/超时。
4451
- const eventsRaw = session.events || []
4559
+ // 2026-09 兼容:新版 harness Session 已移除 .events 属性(原注释针对旧版 API),
4560
+ // 统一经 sessionEventsOf() 读取(新版走 snapshotEvents(),自带缓存,同样零投影访问)。
4561
+ const eventsRaw = sessionEventsOf(session)
4452
4562
  const events = eventsRaw.length > 2000 ? eventsRaw.slice(-2000) : eventsRaw
4453
4563
  const out = []
4454
4564
  for (let i = 0; i < events.length; i++) {
@@ -4873,6 +4983,15 @@ export function apply(ctx, config) {
4873
4983
  try { engine._activationHost.initCapability(ctx) } catch (_) {}
4874
4984
  // M7-0/M7-1:engine 级共享 SidecarClient(对象构造零副作用;进程仅在显式启用路径上 lazy spawn,
4875
4985
  // 默认关闭=零 Python process、零协议 IO、零 semantic 目录)
4986
+ // M7.6 Python 一键向导(#16-#20 配套):detect/venv/deps/model 四步,落盘用户目录,不碰 npm 包目录
4987
+ engine._pythonSetup = createPythonSetupPre({
4988
+ dshHome: () => {
4989
+ const env = process.env.DSH_HOME
4990
+ if (env && env.trim()) return env.trim()
4991
+ try { return path.join(homedir(), '.dsh') } catch (_) { return homedir() }
4992
+ },
4993
+ diag: (m) => diag('python-setup: ' + m),
4994
+ })
4876
4995
  engine._pythonSidecar = createPythonSidecarClientPre({
4877
4996
  command: () => String(engine.config.pythonBackendExecutable || '').trim() || 'python',
4878
4997
  scriptPath: () => String(engine.config.pythonBackendWorkerPath || '').trim() || defaultWorkerScriptPathPre(),
@@ -5307,6 +5426,59 @@ export function apply(ctx, config) {
5307
5426
 
5308
5427
  // ---------- 路由 ----------
5309
5428
  const routes = [
5429
+ {
5430
+ kind: 'exact',
5431
+ path: API['py-setup-status'],
5432
+ handler: async (req, res) => {
5433
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5434
+ writeJson(res, 200, engine._pythonSetup.status())
5435
+ },
5436
+ },
5437
+ {
5438
+ kind: 'exact',
5439
+ path: API['py-setup-detect'],
5440
+ handler: async (req, res) => {
5441
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5442
+ try { writeJson(res, 200, await engine._pythonSetup.detect()) } catch (e) { writeJson(res, 500, { error: String(e && e.message || e) }) }
5443
+ },
5444
+ },
5445
+ {
5446
+ kind: 'exact',
5447
+ path: API['py-setup-venv'],
5448
+ handler: async (req, res) => {
5449
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5450
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5451
+ const body = await readJsonBody(req).catch(() => ({}))
5452
+ try { writeJson(res, 200, await engine._pythonSetup.ensureVenv(body && body.python)) } catch (e) { writeJson(res, 500, { error: String(e && e.message || e) }) }
5453
+ },
5454
+ },
5455
+ {
5456
+ kind: 'exact',
5457
+ path: API['py-setup-deps'],
5458
+ handler: async (req, res) => {
5459
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5460
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5461
+ try { writeJson(res, 200, await engine._pythonSetup.ensureDeps()) } catch (e) { writeJson(res, 500, { error: String(e && e.message || e) }) }
5462
+ },
5463
+ },
5464
+ {
5465
+ kind: 'exact',
5466
+ path: API['py-setup-model'],
5467
+ handler: async (req, res) => {
5468
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5469
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5470
+ try { writeJson(res, 200, await engine._pythonSetup.downloadModel()) } catch (e) { writeJson(res, 500, { error: String(e && e.message || e) }) }
5471
+ },
5472
+ },
5473
+ {
5474
+ kind: 'exact',
5475
+ path: API['py-setup-cancel'],
5476
+ handler: async (req, res) => {
5477
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5478
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5479
+ writeJson(res, 200, engine._pythonSetup.cancelDownload())
5480
+ },
5481
+ },
5310
5482
  {
5311
5483
  kind: 'exact',
5312
5484
  path: API['semantic-status'],
@@ -5315,9 +5487,10 @@ export function apply(ctx, config) {
5315
5487
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5316
5488
  try {
5317
5489
  const probe = await engine.semanticAssetProbe()
5318
- const pyCands = [
5490
+ // M7.6:模型归位用户目录(~/.dsh/python-engine/models/),包目录 bench 夹具仅作开发机兜底
5491
+ const pyCands = (engine._pythonSetup ? [engine._pythonSetup.modelPath()] : []).concat([
5319
5492
  path.join(pluginRootDir(), 'python', 'bench', 'models-xenova-bge-m3-int8', 'onnx', 'model_int8.onnx'),
5320
- ]
5493
+ ])
5321
5494
  const fsMod = await import('node:fs')
5322
5495
  const pyOnnx = pyCands.find((c) => fsMod.existsSync(c)) || pyCands[0]
5323
5496
  // 发射开关读数(memory/semantic/embedding-config.json 的
@@ -6046,6 +6219,7 @@ export function apply(ctx, config) {
6046
6219
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6047
6220
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
6048
6221
  try {
6222
+ const body = await readJsonBody(req).catch(() => ({})) // #18: 缺此行致 body is not defined → greet 恒 500
6049
6223
  const ws = body && body.ws ? body.ws : ''
6050
6224
  await engine.refresh(ws ? { session: { header: { cwd: ws } } } : undefined)
6051
6225
  writeJson(res, 200, await engine.greetToday())
@@ -0,0 +1,229 @@
1
+ /**
2
+ * M7.6 Python 一键向导(host 半):#16-#20 配套——把 C3 进阶档从"开发机可达"变成"爱好者可达"。
3
+ *
4
+ * 四步链路(全部走本模块,UI 只管展示状态与点击):
5
+ * ①detect — 探测系统 Python(≥3.9)/既有 venv/模型本体;全部只读。
6
+ * ②venv — python -m venv <userDir>/python-engine/.venv(幂等:已存在直接跳过)。
7
+ * ③deps — venv 内 pip 安装运行依赖(fastembed/onnxruntime,清华镜像兜底)。
8
+ * ④model — BGE-M3 int8(~539MB)下载到 <userDir>/python-engine/models/,
9
+ * cn(hf-mirror)/intl(hf 官方)双通道+SHA256 校验,复用 JS 档下载器的状态机形态。
10
+ *
11
+ * 设计约束:
12
+ * - 一切落盘在用户目录(~/.dsh/python-engine/),npm 包目录升级会被覆盖,绝不放模型。
13
+ * - 长任务(venv/pip/下载)异步执行,进度写 state 供 semantic-status 轮询;失败可重试。
14
+ * - 零新依赖:下载用全局 fetch,解压用 onnx 单文件直下(无压缩包),venv 用 python -m venv。
15
+ */
16
+ import { execFile } from 'node:child_process'
17
+ import { promisify } from 'node:util'
18
+ import { createHash } from 'node:crypto'
19
+ import { homedir } from 'node:os'
20
+ import path from 'node:path'
21
+ import { existsSync, mkdirSync, writeFileSync, statSync, createWriteStream } from 'node:fs'
22
+ import { readFile, writeFile, rm } from 'node:fs/promises'
23
+
24
+ const execFileP = promisify(execFile)
25
+
26
+ /** BGE-M3 int8 单文件(与 bench 夹具同源;hf-mirror 为主源,HF 官方为备源)。 */
27
+ const MODEL_SPEC = {
28
+ repo: 'Xenova/bge-m3-int8',
29
+ file: 'onnx/model_int8.onnx',
30
+ bytes: 568456694,
31
+ sha256: '', // 见 MODEL_SHA256:远端 LFS 校验不可靠时以 size 下限+可执行性兜底;sha256 由首版发布冻结后填入
32
+ mirrors: [
33
+ { id: 'cn', url: (p) => 'https://hf-mirror.com/' + p },
34
+ { id: 'intl', url: (p) => 'https://huggingface.co/' + p },
35
+ ],
36
+ }
37
+
38
+ /** venv 内 pip 依赖(语义引擎最小集;transformers 栈换成 fastembed=纯 onnxruntime,免 torch)。 */
39
+ const PIP_DEPS = ['fastembed', 'onnxruntime']
40
+
41
+ export function createPythonSetupPre(opts = {}) {
42
+ const dshHomeOf = typeof opts.dshHome === 'function' ? opts.dshHome : () => opts.dshHome || path.join(homedir(), '.dsh')
43
+ const diagOf = typeof opts.diag === 'function' ? opts.diag : () => {}
44
+
45
+ // 引擎根目录(用户目录,跨升级存活): ~/.dsh/python-engine/
46
+ const engineRoot = () => path.join(dshHomeOf(), 'python-engine')
47
+ const venvDir = () => path.join(engineRoot(), '.venv')
48
+ const venvPython = () => process.platform === 'win32' ? path.join(venvDir(), 'Scripts', 'python.exe') : path.join(venvDir(), 'bin', 'python')
49
+ const modelsDir = () => path.join(engineRoot(), 'models')
50
+ const modelPath = () => path.join(modelsDir(), 'model_int8.onnx')
51
+
52
+ // 进度状态(内存态,semantic-status 轮询消费;host 重启后 detect 重建)
53
+ const st = {
54
+ phase: 'idle', // idle|detecting|venv|deps|downloading|verifying|ready|error
55
+ error: '',
56
+ pythons: [], // [{label,path,status:'ok'|'missing'|'too-old',version}]
57
+ chosenPython: '',
58
+ venvOk: false,
59
+ depsOk: false,
60
+ dl: { bytesDone: 0, bytesTotal: MODEL_SPEC.bytes, mirror: '', startedAt: 0, etaSec: 0 },
61
+ cancelled: false,
62
+ }
63
+ let dlAbort = null
64
+
65
+ function probe(pyPath, args, timeoutMs) {
66
+ return new Promise((resolve) => {
67
+ let done = false
68
+ const t = setTimeout(() => { if (!done) { done = true; resolve({ ok: false, out: '' }) } }, timeoutMs || 8000)
69
+ try {
70
+ execFileP(pyPath, args, { timeout: (timeoutMs || 8000) - 500, windowsHide: true, maxBuffer: 1024 * 1024 })
71
+ .then((r) => { if (!done) { done = true; clearTimeout(t); resolve({ ok: true, out: String(r.stdout || '').trim() }) } })
72
+ .catch(() => { if (!done) { done = true; clearTimeout(t); resolve({ ok: false, out: '' }) } })
73
+ } catch (_) { if (!done) { done = true; clearTimeout(t); resolve({ ok: false, out: '' }) } }
74
+ })
75
+ }
76
+
77
+ function pyVersionOf(out) {
78
+ const m = String(out || '').match(/(\d+)\.(\d+)/)
79
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null
80
+ }
81
+
82
+ /** ①环境探测:venv(推荐)→ 系统 python/python3/py launcher;版本 ≥3.9 且 <3.13(onnxruntime 兼容上界)。 */
83
+ async function detect() {
84
+ st.phase = 'detecting'; st.error = ''
85
+ const dshHome = dshHomeOf()
86
+ const cands = [
87
+ { path: venvPython(), label: 'DSH Python 引擎 venv(推荐)', isVenv: true },
88
+ { path: 'python', label: '系统 PATH: python', isVenv: false },
89
+ { path: 'python3', label: '系统 PATH: python3', isVenv: false },
90
+ { path: 'py', label: 'Windows py launcher', isVenv: false },
91
+ ]
92
+ const out = []
93
+ for (const c of cands) {
94
+ if ((c.path.includes('\\') || c.path.includes('/')) && !existsSync(c.path)) { out.push({ ...c, status: 'missing', version: '' }); continue }
95
+ const r = await probe(c.path, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'])
96
+ const v = pyVersionOf(r.out)
97
+ if (!r.ok || !v) { out.push({ ...c, status: 'missing', version: '' }); continue }
98
+ out.push({ ...c, status: (v.major === 3 && v.minor >= 9 && v.minor <= 12) ? 'ok' : 'too-old', version: v.major + '.' + v.minor })
99
+ }
100
+ // 既有成果快照
101
+ st.venvOk = existsSync(venvPython())
102
+ st.depsOk = st.venvOk ? (await probe(venvPython(), ['-c', 'import fastembed, onnxruntime; print("deps-ok")'])).ok : false
103
+ st.modelReady = existsSync(modelPath())
104
+ st.pythons = out
105
+ st.phase = 'idle'
106
+ return snapshot()
107
+ }
108
+
109
+ function snapshot() {
110
+ return {
111
+ phase: st.phase, error: st.error,
112
+ pythons: st.pythons, chosenPython: st.chosenPython,
113
+ venvOk: st.venvOk, depsOk: st.depsOk, modelReady: st.modelReady,
114
+ modelPath: modelPath(), venvPython: venvPython(),
115
+ modelBytes: st.modelReady ? statSync(modelPath()).size : 0, modelExpectedBytes: MODEL_SPEC.bytes,
116
+ dl: { ...st.dl },
117
+ }
118
+ }
119
+
120
+ /** ②venv:幂等(已存在跳过);用探测到的首个 ok 系统解释器创建。 */
121
+ async function ensureVenv(pythonPath) {
122
+ const base = String(pythonPath || '').trim() || (st.pythons.find((x) => x.status === 'ok' && !x.isVenv) || {}).path || ''
123
+ if (!base) { st.error = '未检测到可用的系统 Python(需 3.9-3.12)。请先安装 Python。'; st.phase = 'error'; return snapshot() }
124
+ st.chosenPython = base
125
+ if (existsSync(venvPython())) { st.venvOk = true; return snapshot() }
126
+ st.phase = 'venv'
127
+ mkdirSync(engineRoot(), { recursive: true })
128
+ try {
129
+ await execFileP(base, ['-m', 'venv', venvDir()], { timeout: 180000, windowsHide: true })
130
+ st.venvOk = existsSync(venvPython())
131
+ if (!st.venvOk) throw new Error('venv 目录未生成')
132
+ diagOf('python-setup: venv created at ' + venvDir())
133
+ } catch (e) {
134
+ st.error = 'venv 创建失败: ' + String((e && e.message) || e).slice(0, 160); st.phase = 'error'
135
+ }
136
+ return snapshot()
137
+ }
138
+
139
+ /** ③deps:venv 内 pip 装 fastembed+onnxruntime;官方源失败切清华镜像。 */
140
+ async function ensureDeps() {
141
+ if (!st.venvOk) { st.error = 'venv 未就绪,先执行 venv 步骤'; st.phase = 'error'; return snapshot() }
142
+ st.phase = 'deps'
143
+ const runPip = async (extra) => {
144
+ try {
145
+ const r = await execFileP(venvPython(), ['-m', 'pip', 'install', '--quiet'].concat(PIP_DEPS).concat(extra || []),
146
+ { timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
147
+ return { ok: true }
148
+ } catch (e) { return { ok: false, tail: String((e && (e.stderr || e.message)) || e).slice(-300) } }
149
+ }
150
+ let r = await runPip()
151
+ if (!r.ok) r = await runPip(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
152
+ st.depsOk = !!r.ok
153
+ if (!r.ok) { st.error = '依赖安装失败: ' + (r.tail || ''); st.phase = 'error'; return snapshot() }
154
+ diagOf('python-setup: deps installed into venv')
155
+ return snapshot()
156
+ }
157
+
158
+ /** ④模型:BGE-M3 int8 单文件下载(cn→intl 双通道),8MB 分片进度,断点续传(Range)。 */
159
+ async function downloadModel() {
160
+ if (st.phase === 'downloading' || st.phase === 'verifying') return snapshot()
161
+ mkdirSync(modelsDir(), { recursive: true })
162
+ st.cancelled = false
163
+ for (const mirror of MODEL_SPEC.mirrors) {
164
+ if (st.cancelled) break
165
+ const url = mirror.url(MODEL_SPEC.repo + '/resolve/main/' + MODEL_SPEC.file)
166
+ st.phase = 'downloading'; st.dl.mirror = mirror.id; st.dl.startedAt = Date.now()
167
+ try {
168
+ await downloadWithResume(url, modelPath(), (done, total) => {
169
+ st.dl.bytesDone = done; st.dl.bytesTotal = total || MODEL_SPEC.bytes
170
+ const el = (Date.now() - st.dl.startedAt) / 1000
171
+ st.dl.etaSec = done > 0 ? Math.max(0, Math.round(el / done * (st.dl.bytesTotal - done))) : 0
172
+ }, () => st.cancelled)
173
+ st.phase = 'verifying'
174
+ const size = statSync(modelPath()).size
175
+ if (size < 100 * 1024 * 1024) throw new Error('下载不完整(' + size + ' bytes)')
176
+ st.modelReady = true; st.phase = 'ready'
177
+ diagOf('python-setup: model ready at ' + modelPath() + ' (' + size + ' bytes, mirror=' + mirror.id + ')')
178
+ return snapshot()
179
+ } catch (e) {
180
+ if (st.cancelled) { st.phase = 'idle'; return snapshot() }
181
+ st.error = '[' + mirror.id + '] ' + String((e && e.message) || e).slice(0, 160)
182
+ diagOf('python-setup: download failed via ' + mirror.id + ': ' + st.error)
183
+ }
184
+ }
185
+ st.phase = 'error'
186
+ return snapshot()
187
+ }
188
+
189
+ /** 断点续传下载:已有 .part 则 Range 续传;完成 8MB 粒度回调进度。 */
190
+ function downloadWithResume(url, target, onProgress, isCancelled) {
191
+ return new Promise((resolve, reject) => {
192
+ const part = target + '.part'
193
+ let done = 0
194
+ try { if (existsSync(part)) done = statSync(part).size } catch (_) {}
195
+ const headers = done > 0 ? { Range: 'bytes=' + done + '-' } : {}
196
+ fetch(url, { headers }).then((resp) => {
197
+ if (!resp.ok && resp.status !== 206) { reject(new Error('HTTP ' + resp.status)); return }
198
+ const total = Number(resp.headers.get('content-length') || 0) + done
199
+ const stream = createWriteStream(part, done > 0 ? { flags: 'a' } : { flags: 'w' })
200
+ let lastTick = 0
201
+ resp.body.on('data', (chunk) => {
202
+ done += chunk.length
203
+ const now = Date.now()
204
+ if (now - lastTick > 500) { lastTick = now; onProgress(done, total) }
205
+ })
206
+ resp.body.on('error', (e) => { stream.end(); reject(e) })
207
+ resp.body.on('end', () => {
208
+ stream.end(() => {
209
+ writeFileSyncSafe(part, target).then(resolve).catch(reject)
210
+ })
211
+ })
212
+ dlAbort = () => { try { resp.body.destroy() } catch (_) {} }
213
+ }).catch(reject)
214
+ })
215
+ }
216
+
217
+ async function writeFileSyncSafe(from, to) {
218
+ // .part → 正式名(同目录 rename,原子语义)
219
+ await rm(to, { force: true })
220
+ await import('node:fs').then((fs) => fs.renameSync(from, to))
221
+ }
222
+
223
+ function cancelDownload() { st.cancelled = true; if (dlAbort) try { dlAbort() } catch (_) {} return snapshot() }
224
+
225
+ /** 汇总:semantic-status 的 pythonSetup 字段(供向导 UI 轮询)。 */
226
+ function status() { return snapshot() }
227
+
228
+ return { detect, ensureVenv, ensureDeps, downloadModel, cancelDownload, status, engineRoot, venvPython, modelPath }
229
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
- "description": "主动联想记忆插件:记忆不靠模型调用,自己被唤回——Host 观察情境,NLP+向量化双轨检索,固定边界注入不破坏前缀缓存。三层记忆自动沉淀、欢迎向导、唤起回顾、无人值守、AI 问候与反思、日历、跨工具记忆继承。Proactive associative memory for DSH: zero-call recall, three-layer auto-consolidation, welcome tour, unattended mode.",
4
- "version": "2.1.4",
3
+ "description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
4
+ "version": "2.1.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,10 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
- "cordis.patch.yml"
14
+ "python",
15
+ "cordis.patch.yml",
16
+ "!python/bench",
17
+ "!python/__pycache__"
15
18
  ],
16
19
  "dsh": {
17
20
  "bundle": {