@a9i5k4/dsh-auto-memory 3.1.0 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/CONTRIBUTORS.html +2 -2
- package/docs/HANDBOOK.md +13 -9
- package/lib/client.js +786 -134
- package/lib/config-io.js +59 -6
- package/lib/context-host.js +10 -1
- package/lib/evidence-store.js +27 -1
- package/lib/index.js +937 -51
- package/lib/jsonl-tail-cursor.js +75 -0
- package/lib/m7-index-sync-host.js +4 -6
- package/lib/memory-hub.js +25 -2
- package/lib/migrate-pack.js +431 -0
- package/lib/python-setup.js +109 -27
- package/lib/recall-stats.js +242 -0
- package/lib/rules-layer.js +106 -15
- package/lib/semantic-js.js +25 -7
- package/lib/shadow-host.js +41 -2
- package/lib/subagent-gc.js +5 -1
- package/lib/wb-sidecar.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 环形 JSONL 的增量游标(issue #103)。
|
|
3
|
+
*
|
|
4
|
+
* **为什么存在**:`judgement-shadow.jsonl` 这类文件由 Python 侧按**保尾丢弃**维护
|
|
5
|
+
* (`python/worker_semantic_v1.py` SHADOW_LOG_MAX=256,每次重写为 `lines[-256:]`)。
|
|
6
|
+
* 用「行数」当游标在文件写满后必然失效:`lines.length` 恒等于上次的 `count` ⇒ 增量区间
|
|
7
|
+
* 恒为空 ⇒ 消费端永久停摆,且因为"没有新行"是合法状态而不会留下任何痕迹。
|
|
8
|
+
*
|
|
9
|
+
* 本模块把游标改成**行内容指纹**:环内每一行只被交付一次,与文件是否被截断无关。
|
|
10
|
+
* 纯函数、零依赖、不碰文件系统 —— 便于用假环直接单测。
|
|
11
|
+
*
|
|
12
|
+
* 语义约定(与调用点 `hubFeedTick` 一致):
|
|
13
|
+
* - 首次 `take()` 只登记现状、不交付任何行(`seeded: false`)。追喂历史行会让重启后
|
|
14
|
+
* 把环里旧判据再吃一遍(`procedures.observe` 会重复计数),故刻意不喂。
|
|
15
|
+
* - 之后每次 `take()` 只交付指纹未出现过的行,并按插入序把登记表封顶在 `maxSeen`。
|
|
16
|
+
* - 逐字节相同的重复行视为同一事实(`fresh` 里不出现第二次)。
|
|
17
|
+
*
|
|
18
|
+
* ★归属说明(2026-09-22):本模块内容取自被 3.1.0 强推冲掉的孤儿快照
|
|
19
|
+
* `475abfe:lib/jsonl-tail-cursor.js`(原为 PR #119 的产物);因 pre 线只认 `-pre` 命名,
|
|
20
|
+
* 此处改名为 `jsonl-tail-cursor.js` **并已登记进 `tools/release.mjs` 的 libModuleRenames**。
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
|
|
24
|
+
const defaultFp = (line) => createHash('sha256').update(String(line)).digest('hex').slice(0, 16)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{maxSeen?: number, fp?: (line: string) => string}} [opts]
|
|
28
|
+
* maxSeen 应 ≥ 目标环形文件的上限(默认 1024 = 256 的 4 倍),否则会出现
|
|
29
|
+
* 「指纹被淘汰 ⇒ 环内旧行被重复交付」。
|
|
30
|
+
*/
|
|
31
|
+
export function createJsonlTailCursorPre(opts = {}) {
|
|
32
|
+
const maxSeen = Number(opts.maxSeen) > 0 ? Number(opts.maxSeen) : 1024
|
|
33
|
+
const fpOf = typeof opts.fp === 'function' ? opts.fp : defaultFp
|
|
34
|
+
const seen = new Set()
|
|
35
|
+
const order = [] // 插入序旧→新;Set 本身不保证可淘汰顺序,故另记
|
|
36
|
+
let seeded = false
|
|
37
|
+
|
|
38
|
+
const remember = (fp) => {
|
|
39
|
+
if (seen.has(fp)) return
|
|
40
|
+
seen.add(fp)
|
|
41
|
+
order.push(fp)
|
|
42
|
+
while (order.length > maxSeen) seen.delete(order.shift())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} lines 当前文件内容(调用方负责切行与去空行)
|
|
47
|
+
* @returns {{seeded: boolean, fresh: string[], seenSize: number}}
|
|
48
|
+
* `seeded: false` 表示本次为冷启动登记,`fresh` 必为空。
|
|
49
|
+
*/
|
|
50
|
+
function take(lines) {
|
|
51
|
+
const arr = Array.isArray(lines) ? lines : []
|
|
52
|
+
if (!seeded) {
|
|
53
|
+
seeded = true
|
|
54
|
+
for (const l of arr) remember(fpOf(l))
|
|
55
|
+
return { seeded: false, fresh: [], seenSize: seen.size }
|
|
56
|
+
}
|
|
57
|
+
const fresh = []
|
|
58
|
+
for (const l of arr) {
|
|
59
|
+
const fp = fpOf(l)
|
|
60
|
+
if (seen.has(fp)) continue
|
|
61
|
+
remember(fp)
|
|
62
|
+
fresh.push(l)
|
|
63
|
+
}
|
|
64
|
+
return { seeded: true, fresh, seenSize: seen.size }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 复位(测试与「换文件」场景用):下次 take() 重新按冷启动登记。 */
|
|
68
|
+
function reset() {
|
|
69
|
+
seeded = false
|
|
70
|
+
seen.clear()
|
|
71
|
+
order.length = 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { take, reset, stats: () => ({ seeded, seenSize: seen.size, maxSeen }) }
|
|
75
|
+
}
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* - 禁止在每个 Segment 重复全量 sync(成功后缓存 ready identity);
|
|
17
17
|
* - dispose 清理 in-flight/ready cache/abort controller,不删除 derived cache。
|
|
18
18
|
*
|
|
19
|
-
* 可观察性(最小投影,不泄内容):
|
|
20
|
-
*
|
|
19
|
+
* 可观察性(最小投影,不泄内容):ready(按 (wsRef,scope) 的 miv/epoch/generation)、inFlightCount、
|
|
20
|
+
* generation、recentDrops{reason,contextVersion}、epoch、engineSwitch、stats。
|
|
21
|
+
* ★2026-09-22:删掉了一条恒空的死投影字段 —— 它取自某个全文件无 .add 的 Set,恒为 []。
|
|
22
|
+
* 真正的「已捕获路径键」属 context-host.js(那边的集合是活的)。
|
|
21
23
|
* UTF-8 无 BOM。
|
|
22
24
|
*/
|
|
23
25
|
import { buildIndexSyncPlansPre, sendIndexSyncPlanPre } from './index-sync.js'
|
|
@@ -25,7 +27,6 @@ import { workspaceRefOf } from './evidence-store.js'
|
|
|
25
27
|
import { createEngineSwitchPre } from './engine-switch.js'
|
|
26
28
|
|
|
27
29
|
export const M7_INDEX_SYNC_HOST_POLICY_VERSION = 'm7_index_sync_host_v1'
|
|
28
|
-
const MAX_PATH_KEYS = 8
|
|
29
30
|
const MAX_DROPS = 16
|
|
30
31
|
|
|
31
32
|
export function createIndexSyncHostPre(opts = {}) {
|
|
@@ -33,7 +34,6 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
33
34
|
if (!engine) throw new Error('index-sync-host: engine required')
|
|
34
35
|
const readyCache = new Map() // key=(wsRef,scope) -> { miv, epoch, at, generation }
|
|
35
36
|
const inFlight = new Map() // key=(wsRef,scope) -> { controller, promise, miv }
|
|
36
|
-
const enabledKeys = new Set() // 显式 disable 的 key(同一 miv 内不再重试;miv 变化自动解除)
|
|
37
37
|
const volatileDrops = [] // ≤16 条最小投影(无文本)
|
|
38
38
|
const stats = { syncsStarted: 0, syncsOk: 0, syncsFailed: 0, skippedCached: 0,
|
|
39
39
|
epochReset: 0, mivReplaced: 0, aborted: 0, drops: 0, readyHits: 0, generationResets: 0 }
|
|
@@ -197,7 +197,6 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
197
197
|
engineSwitch: switchMachine.getEngineSwitchStatusPre(),
|
|
198
198
|
ready: [...readyCache.entries()].map(([k, v]) => ({ key: k, miv: v.miv, epoch: v.epoch ? v.epoch.slice(0, 12) : null, generation: v.generation })),
|
|
199
199
|
inFlightCount: inFlight.size,
|
|
200
|
-
capturedPathKeys: [...enabledKeys].slice(0, MAX_PATH_KEYS),
|
|
201
200
|
stats: { ...stats },
|
|
202
201
|
recentDrops: volatileDrops.slice(-4),
|
|
203
202
|
epoch: c && typeof c.currentEpoch === 'function' ? (c.currentEpoch() || '').slice(0, 12) : null,
|
|
@@ -235,7 +234,6 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
235
234
|
for (const [, infl] of inFlight) { try { infl.controller.abort() } catch (_) {} }
|
|
236
235
|
inFlight.clear()
|
|
237
236
|
readyCache.clear()
|
|
238
|
-
enabledKeys.clear()
|
|
239
237
|
volatileDrops.length = 0
|
|
240
238
|
stats.drops = 0
|
|
241
239
|
}
|
package/lib/memory-hub.js
CHANGED
|
@@ -149,9 +149,32 @@ export function createMemoryHubPre(opts = {}) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
function ingestJudgementRows(rows) {
|
|
152
|
+
// issue #110 的剩余缺口:批内合并落盘此前**只挂在宿主的喂数定时循环上**(index.js 里
|
|
153
|
+
// 手动 beginBatch/endBatch)。于是走 `ingestJudgementRows` 的其它批量入口——HTTP
|
|
154
|
+
// `/memory-hub` 的 `action=feed`(面板/外部重放器喂一整批判据)——仍是 N 行 = N 次整份快照写盘,
|
|
155
|
+
// 正是写放大。把批语义收到"拥有这批行"的这一层,任何批量入口都自动只落一次。
|
|
156
|
+
// 外层若已自己开批(喂数循环),beginBatch 的 depth 计数保证内层 end 不会提前落盘。
|
|
157
|
+
const batch = opts.batch
|
|
158
|
+
const canBatch = batch && typeof batch.beginBatch === 'function' && typeof batch.endBatch === 'function'
|
|
159
|
+
if (!canBatch) {
|
|
160
|
+
const out = []
|
|
161
|
+
for (const r of rows) out.push(ingestJudgement(r))
|
|
162
|
+
return { results: out }
|
|
163
|
+
}
|
|
164
|
+
batch.beginBatch()
|
|
152
165
|
const out = []
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
let batchResult = null
|
|
167
|
+
try {
|
|
168
|
+
for (const r of rows) out.push(ingestJudgement(r))
|
|
169
|
+
} finally {
|
|
170
|
+
// 批末必落:中途抛错也不能把已接受的改动留在内存里等下次。
|
|
171
|
+
// ★不在 finally 里 return —— 那会吞掉正在传播的异常(静默失败的同一种形状)。
|
|
172
|
+
batchResult = batch.endBatch() || null
|
|
173
|
+
}
|
|
174
|
+
if (batchResult && batchResult.ok === false) {
|
|
175
|
+
log('memory-hub batch persist failed: ' + JSON.stringify((batchResult.errors || []).slice(-1)))
|
|
176
|
+
}
|
|
177
|
+
return { results: out, batch: batchResult }
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
// ---- 2) episodic 巩固钩子(会话结束/空闲期调) ----
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 迁移包引擎(migrate pack)—— 把一个工作区的记忆打包/搬包/导入。
|
|
3
|
+
*
|
|
4
|
+
* ★设计要点(依据 docs/internal/MIGRATION-ARCH-20260922.md 的实测取证):
|
|
5
|
+
* 1. **零依赖**:只用 node 内置。不用 zip(`node:zlib` 只有 gzip/deflate、没有 zip 容器;
|
|
6
|
+
* 引第三方库违反本仓零依赖铁律)⇒ 单 JSON 文件,可选再 gzip 一层。
|
|
7
|
+
* 2. **纯逻辑与 IO 分离**:本模块**不碰真实磁盘**(只做纯函数),IO 由宿主负责。
|
|
8
|
+
* 这样导出/重写/计划三步都能在守卫里用真数据直接单测,不必起宿主。
|
|
9
|
+
* 3. **路径重写是必需的**:实测 slug 规则为
|
|
10
|
+
* `'--' + path.replace(/[\\/:*?"<>|]/g, '-') + '--'`
|
|
11
|
+
* —— 它**不可逆**(原路径里的 `-` 与替换产物 `-` 无法区分)⇒ 新路径的 slug 只能由
|
|
12
|
+
* 新路径**重新计算**,且文件内出现的旧路径必须逐处重写,否则 B 机全是死链。
|
|
13
|
+
* 4. **checksum 必校验**:半损坏的包会污染现场且极难排查 ⇒ inspect 阶段即拒绝。
|
|
14
|
+
*
|
|
15
|
+
* 包格式 `dam-pack-v1`(单个 JSON):
|
|
16
|
+
* {
|
|
17
|
+
* format, createdAt, source:{ws,slug,host}, runtime:{pluginVersion},
|
|
18
|
+
* summaryRecord, // workspaces-summary.json 里属于该工作区的那一条
|
|
19
|
+
* files: { '<相对 slug 目录的路径>': '<原样文本>' },
|
|
20
|
+
* stats: { fileCount, bytes },
|
|
21
|
+
* checksum: { algo:'sha256', value }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
|
|
27
|
+
export const MIGRATE_PACK_FORMAT_PRE = 'dam-pack-v1'
|
|
28
|
+
export const MIGRATE_PACK_CHECKSUM_ALGO_PRE = 'sha256'
|
|
29
|
+
/** 单包上限:文件数与总字节(防手滑把整个 memory 目录塞进来)。 */
|
|
30
|
+
export const MIGRATE_PACK_MAX_FILES_PRE = 20000
|
|
31
|
+
export const MIGRATE_PACK_MAX_BYTES_PRE = 256 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* workspaceKey(slug)—— **与宿主 `lib/index.js` 的同名逻辑逐字一致**。
|
|
35
|
+
* ★不要改成「更聪明」的写法:两处一旦不一致,导入会把数据放进错误的目录。
|
|
36
|
+
*/
|
|
37
|
+
export function workspaceSlugPre(ws) {
|
|
38
|
+
if (typeof ws !== 'string' || !ws) return ''
|
|
39
|
+
return '--' + String(ws).replace(/[\\/:*?"<>|]/g, '-') + '--'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 一个真实路径在文件里可能出现的 **4 种形态**。
|
|
44
|
+
* 实测:日志/笔记正文写的是 `D:\a\b`;JSON 里是转义形态 `D:\\a\\b`;
|
|
45
|
+
* file URL 出现在语义索引与部分摘要里。
|
|
46
|
+
*/
|
|
47
|
+
export function pathVariantsPre(p) {
|
|
48
|
+
const s = String(p || '')
|
|
49
|
+
if (!s) return []
|
|
50
|
+
const posix = s.replace(/\\/g, '/')
|
|
51
|
+
const out = [s, posix, 'file:///' + posix]
|
|
52
|
+
// JSON 转义形态:反斜杠翻倍(Windows 路径在 JSON 文本里长这样)
|
|
53
|
+
if (s.includes('\\')) out.push(s.replace(/\\/g, '\\\\'))
|
|
54
|
+
// 去重并按长度降序(先替换长的,避免短形态抢先命中留下残渣)
|
|
55
|
+
return Array.from(new Set(out)).filter(Boolean).sort((a, b) => b.length - a.length)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 文本级路径重写(纯函数)。**这是导入时唯一会改动用户正文的地方**,故:
|
|
60
|
+
* - 逐形态替换,长形态优先
|
|
61
|
+
* - 返回 `hits`(改了多少处)供预览逐文件展示,用户能看见改在哪
|
|
62
|
+
* - 不做正则/模糊匹配(避免误伤),只做**字面量**替换
|
|
63
|
+
*
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @param {{fromPath:string, toPath:string, fromSlug?:string, toSlug?:string}} opt
|
|
66
|
+
* @returns {{text:string, hits:number}}
|
|
67
|
+
*/
|
|
68
|
+
export function rewritePathsInTextPre(text, opt = {}) {
|
|
69
|
+
let s = String(text == null ? '' : text)
|
|
70
|
+
let hits = 0
|
|
71
|
+
const fromPath = String(opt.fromPath || '')
|
|
72
|
+
const toPath = String(opt.toPath || '')
|
|
73
|
+
if (fromPath && toPath && fromPath !== toPath) {
|
|
74
|
+
const toVariants = pathVariantsPre(toPath)
|
|
75
|
+
const fromVariants = pathVariantsPre(fromPath)
|
|
76
|
+
// 建立「同形态 → 同形态」映射:Windows 原形对 Windows 原形、posix 对 posix …
|
|
77
|
+
// 简化为按索引对齐(pathVariantsPre 的排序对两者一致,因为只是斜杠方向不同)
|
|
78
|
+
for (let i = 0; i < fromVariants.length; i++) {
|
|
79
|
+
const fv = fromVariants[i]
|
|
80
|
+
const tv = toVariants[i] !== undefined ? toVariants[i] : toPath
|
|
81
|
+
if (!fv || fv === tv) continue
|
|
82
|
+
const before = s
|
|
83
|
+
s = s.split(fv).join(tv)
|
|
84
|
+
if (s !== before) hits += countOccurrencesPre(before, fv)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const fromSlug = String(opt.fromSlug || '')
|
|
88
|
+
const toSlug = String(opt.toSlug || '')
|
|
89
|
+
if (fromSlug && toSlug && fromSlug !== toSlug) {
|
|
90
|
+
const before = s
|
|
91
|
+
s = s.split(fromSlug).join(toSlug)
|
|
92
|
+
if (s !== before) hits += countOccurrencesPre(before, fromSlug)
|
|
93
|
+
}
|
|
94
|
+
return { text: s, hits }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 字面量子串出现次数(不用正则,避免特殊字符)。 */
|
|
98
|
+
export function countOccurrencesPre(hay, needle) {
|
|
99
|
+
if (!needle) return 0
|
|
100
|
+
let n = 0, i = 0
|
|
101
|
+
for (;;) {
|
|
102
|
+
const p = String(hay).indexOf(needle, i)
|
|
103
|
+
if (p < 0) return n
|
|
104
|
+
n++
|
|
105
|
+
i = p + needle.length
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ───────────────────────── 校验和 ─────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 包内容摘要(纯函数)。**规范化**:按路径排序后逐条喂入,保证同一内容在任何机器上
|
|
113
|
+
* 得到同一个值(否则跨机校验永远失败)。
|
|
114
|
+
* 用 `\u0000` 作字段分隔(路径/内容里不会出现的字符),避免拼接歧义(如 a|b 与 a、b)。
|
|
115
|
+
*/
|
|
116
|
+
export function packChecksumPre(files) {
|
|
117
|
+
const names = Object.keys(files || {}).sort()
|
|
118
|
+
const h = createHash(MIGRATE_PACK_CHECKSUM_ALGO_PRE)
|
|
119
|
+
for (const n of names) {
|
|
120
|
+
h.update(n)
|
|
121
|
+
h.update('\u0000')
|
|
122
|
+
h.update(String(files[n]))
|
|
123
|
+
h.update('\u0000')
|
|
124
|
+
}
|
|
125
|
+
return h.digest('hex')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ───────────────────────── 打包(纯函数)─────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 构造一个迁移包对象(**不写盘**)。
|
|
132
|
+
* @param {{ws:string, files:Object<string,string>, summaryRecord?:object,
|
|
133
|
+
* pluginVersion?:string, now?:number, sourceHost?:string}} opt
|
|
134
|
+
* @returns {{ok:boolean, error?:string, pack?:object, warnings:string[]}}
|
|
135
|
+
*/
|
|
136
|
+
export function buildPackPre(opt = {}) {
|
|
137
|
+
const warnings = []
|
|
138
|
+
const ws = String(opt.ws || '')
|
|
139
|
+
const files = opt.files && typeof opt.files === 'object' ? opt.files : {}
|
|
140
|
+
if (!ws) return { ok: false, error: 'missing-source-ws', warnings }
|
|
141
|
+
const names = Object.keys(files)
|
|
142
|
+
if (!names.length) return { ok: false, error: 'no-files', warnings }
|
|
143
|
+
if (names.length > MIGRATE_PACK_MAX_FILES_PRE) return { ok: false, error: 'too-many-files:' + names.length, warnings }
|
|
144
|
+
let bytes = 0
|
|
145
|
+
for (const n of names) {
|
|
146
|
+
if (n.includes('..') || n.startsWith('/') || n.startsWith('\\')) {
|
|
147
|
+
return { ok: false, error: 'unsafe-relative-path:' + n, warnings }
|
|
148
|
+
}
|
|
149
|
+
bytes += Buffer.byteLength(String(files[n]), 'utf8')
|
|
150
|
+
}
|
|
151
|
+
if (bytes > MIGRATE_PACK_MAX_BYTES_PRE) return { ok: false, error: 'too-large:' + bytes, warnings }
|
|
152
|
+
// 体积提示(不阻断):超过 16 MB 时提醒用户包会很大
|
|
153
|
+
if (bytes > 16 * 1024 * 1024) warnings.push('pack-large:' + Math.round(bytes / 1048576) + 'MB')
|
|
154
|
+
// ★v3.1.3:用户级文件(如 `CALENDAR.md`)—— 与 `files` 的语义区别:
|
|
155
|
+
// files 相对「工作区 slug 目录」,userFiles 相对「用户级 memory 目录」,且**跨工作区共享**。
|
|
156
|
+
// 老包无此字段 ⇒ 视为 {},行为逐字节不变(向后兼容)。
|
|
157
|
+
const userFiles = (opt.userFiles && typeof opt.userFiles === 'object') ? opt.userFiles : {}
|
|
158
|
+
let userBytes = 0
|
|
159
|
+
for (const n of Object.keys(userFiles)) {
|
|
160
|
+
if (n.includes('..') || n.startsWith('/') || n.startsWith('\\')) {
|
|
161
|
+
return { ok: false, error: 'unsafe-user-relative-path:' + n, warnings }
|
|
162
|
+
}
|
|
163
|
+
userBytes += Buffer.byteLength(String(userFiles[n]), 'utf8')
|
|
164
|
+
}
|
|
165
|
+
const pack = {
|
|
166
|
+
format: MIGRATE_PACK_FORMAT_PRE,
|
|
167
|
+
createdAt: typeof opt.now === 'number' ? opt.now : Date.now(),
|
|
168
|
+
source: { ws, slug: workspaceSlugPre(ws), host: String(opt.sourceHost || '') },
|
|
169
|
+
runtime: { pluginVersion: String(opt.pluginVersion || '') },
|
|
170
|
+
summaryRecord: opt.summaryRecord && typeof opt.summaryRecord === 'object' ? opt.summaryRecord : null,
|
|
171
|
+
files,
|
|
172
|
+
userFiles,
|
|
173
|
+
stats: { fileCount: names.length, bytes, userFileCount: Object.keys(userFiles).length, userBytes },
|
|
174
|
+
checksum: { algo: MIGRATE_PACK_CHECKSUM_ALGO_PRE, value: packChecksumPre(files) },
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, pack, warnings }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 校验一个已解析的包对象(纯函数,不抛)。
|
|
181
|
+
* @returns {{ok:boolean, errors:string[], warnings:string[]}}
|
|
182
|
+
*/
|
|
183
|
+
export function validatePackPre(pack) {
|
|
184
|
+
const errors = []
|
|
185
|
+
const warnings = []
|
|
186
|
+
if (!pack || typeof pack !== 'object') return { ok: false, errors: ['not-an-object'], warnings }
|
|
187
|
+
if (pack.format !== MIGRATE_PACK_FORMAT_PRE) errors.push('unknown-format:' + String(pack.format))
|
|
188
|
+
if (!pack.source || typeof pack.source.ws !== 'string' || !pack.source.ws) errors.push('missing-source-ws')
|
|
189
|
+
if (!pack.files || typeof pack.files !== 'object') errors.push('missing-files')
|
|
190
|
+
else {
|
|
191
|
+
const n = Object.keys(pack.files).length
|
|
192
|
+
if (!n) errors.push('empty-files')
|
|
193
|
+
if (n > MIGRATE_PACK_MAX_FILES_PRE) errors.push('too-many-files:' + n)
|
|
194
|
+
for (const k of Object.keys(pack.files)) {
|
|
195
|
+
if (k.includes('..') || k.startsWith('/') || k.startsWith('\\')) { errors.push('unsafe-relative-path:' + k); break }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ★v3.1.3:userFiles 与 files 受**同等**安全检查(路径穿越);老包无该字段直接通过。
|
|
199
|
+
if (!errors.length && pack.userFiles && typeof pack.userFiles === 'object') {
|
|
200
|
+
for (const k of Object.keys(pack.userFiles)) {
|
|
201
|
+
if (k.includes('..') || k.startsWith('/') || k.startsWith('\\')) { errors.push('unsafe-user-relative-path:' + k); break }
|
|
202
|
+
if (typeof pack.userFiles[k] !== 'string') { errors.push('non-text-user-file:' + k); break }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!errors.length) {
|
|
206
|
+
const want = pack.checksum && pack.checksum.value
|
|
207
|
+
const got = packChecksumPre(pack.files)
|
|
208
|
+
if (!want) errors.push('missing-checksum')
|
|
209
|
+
else if (String(want) !== got) errors.push('checksum-mismatch')
|
|
210
|
+
}
|
|
211
|
+
if (!errors.length && pack.stats) {
|
|
212
|
+
const real = Object.keys(pack.files).length
|
|
213
|
+
if (pack.stats.fileCount !== undefined && pack.stats.fileCount !== real) warnings.push('stats-filecount-drift')
|
|
214
|
+
}
|
|
215
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ───────────────────────── 目标重写(纯函数)─────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 把包内容重写到目标工作区(纯函数,不写盘)。
|
|
222
|
+
*
|
|
223
|
+
* 路径相同时**不做任何替换**(避免误改用户正文)——这是架构里的「情形 A」;
|
|
224
|
+
* 路径不同时才逐文件重写并回报 hits(供预览逐条展示)。
|
|
225
|
+
*
|
|
226
|
+
* @param {object} pack
|
|
227
|
+
* @param {{targetWs:string, rewriteBody?:boolean}} opt
|
|
228
|
+
* `rewriteBody !== false` ⇒ 连正文一起改(用户拍板:需要重写,避免 B 机死链)
|
|
229
|
+
* @returns {{files:Object, plan:{pathChanged:boolean, rewriteFiles:Array, totalHits:number}}}
|
|
230
|
+
*/
|
|
231
|
+
export function rewritePackForTargetPre(pack, opt = {}) {
|
|
232
|
+
const files = pack && pack.files ? pack.files : {}
|
|
233
|
+
const fromPath = String((pack && pack.source && pack.source.ws) || '')
|
|
234
|
+
const toPath = String(opt.targetWs || '')
|
|
235
|
+
const fromSlug = workspaceSlugPre(fromPath)
|
|
236
|
+
const toSlug = workspaceSlugPre(toPath)
|
|
237
|
+
const pathChanged = !!toPath && (fromPath !== toPath || fromSlug !== toSlug)
|
|
238
|
+
const rewriteBody = opt.rewriteBody !== false
|
|
239
|
+
const out = {}
|
|
240
|
+
const rewriteFiles = []
|
|
241
|
+
let totalHits = 0
|
|
242
|
+
for (const name of Object.keys(files)) {
|
|
243
|
+
const raw = String(files[name])
|
|
244
|
+
if (!pathChanged || !rewriteBody) { out[name] = raw; continue }
|
|
245
|
+
const r = rewritePathsInTextPre(raw, { fromPath, toPath, fromSlug, toSlug })
|
|
246
|
+
out[name] = r.text
|
|
247
|
+
if (r.hits > 0) { rewriteFiles.push({ path: name, hits: r.hits }); totalHits += r.hits }
|
|
248
|
+
}
|
|
249
|
+
rewriteFiles.sort((a, b) => b.hits - a.hits)
|
|
250
|
+
return { files: out, plan: { pathChanged, fromPath, toPath, fromSlug, toSlug, rewriteFiles, totalHits } }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ───────────────────────── 预览计划(纯函数)─────────────────────────
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 计算「导入将要发生什么」(纯函数)—— **这是唯一能算出差异的地方**,
|
|
257
|
+
* import 只接受这里产出的 plan(防 TOCTOU),且预览必须能逐条列出覆盖项。
|
|
258
|
+
*
|
|
259
|
+
* @param {object} pack 已校验通过的包
|
|
260
|
+
* @param {{targetWs:string, existingFiles?:Object<string,string>, rewriteBody?:boolean,
|
|
261
|
+
* onConflict?:'keep'|'overwrite'|'rename'}} opt
|
|
262
|
+
* `existingFiles` = 目标目录现有文件(宿主负责读),用于算新增/覆盖
|
|
263
|
+
*/
|
|
264
|
+
export function planImportPre(pack, opt = {}) {
|
|
265
|
+
const { files, plan: rw } = rewritePackForTargetPre(pack, opt)
|
|
266
|
+
const targetWs = String(opt.targetWs || '')
|
|
267
|
+
const targetSlug = workspaceSlugPre(targetWs)
|
|
268
|
+
const onConflict = ['keep', 'overwrite', 'rename'].includes(opt.onConflict) ? opt.onConflict : 'keep'
|
|
269
|
+
const existing = opt.existingFiles && typeof opt.existingFiles === 'object' ? opt.existingFiles : {}
|
|
270
|
+
const additions = []
|
|
271
|
+
const overwrites = []
|
|
272
|
+
const replaced = {} // 实际会写入的文件(含改名后的键)
|
|
273
|
+
for (const name of Object.keys(files)) {
|
|
274
|
+
const next = String(files[name])
|
|
275
|
+
const has = Object.prototype.hasOwnProperty.call(existing, name)
|
|
276
|
+
if (!has) { additions.push({ path: name, bytes: Buffer.byteLength(next, 'utf8') }); replaced[name] = next; continue }
|
|
277
|
+
const old = String(existing[name])
|
|
278
|
+
if (old === next) { replaced[name] = next; continue } // 内容相同 ⇒ 无需改动,不算覆盖
|
|
279
|
+
if (onConflict === 'keep') { continue } // 默认保留 B 机 ⇒ 不写
|
|
280
|
+
if (onConflict === 'overwrite') {
|
|
281
|
+
overwrites.push({ path: name, oldBytes: Buffer.byteLength(old, 'utf8'), newBytes: Buffer.byteLength(next, 'utf8') })
|
|
282
|
+
replaced[name] = next
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
// rename:A 机的版本改名为 <名字>.from-pack,B 机原文件保持不动
|
|
286
|
+
const alt = renameForConflictPre(name)
|
|
287
|
+
additions.push({ path: alt, bytes: Buffer.byteLength(next, 'utf8'), renamedFrom: name })
|
|
288
|
+
replaced[alt] = next
|
|
289
|
+
}
|
|
290
|
+
const warnings = []
|
|
291
|
+
if (rw.pathChanged) warnings.push('path-changed')
|
|
292
|
+
else warnings.push('same-path')
|
|
293
|
+
if (!Object.keys(replaced).length) warnings.push('nothing-to-write')
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
format: pack.format,
|
|
297
|
+
source: { ws: pack.source.ws, slug: pack.source.slug },
|
|
298
|
+
target: { ws: targetWs, slug: targetSlug },
|
|
299
|
+
pathChanged: rw.pathChanged,
|
|
300
|
+
fromPath: rw.fromPath,
|
|
301
|
+
toPath: rw.toPath,
|
|
302
|
+
onConflict,
|
|
303
|
+
additions: additions.sort((a, b) => a.path.localeCompare(b.path)),
|
|
304
|
+
overwrites: overwrites.sort((a, b) => a.path.localeCompare(b.path)),
|
|
305
|
+
rewrite: { totalHits: rw.totalHits, files: rw.rewriteFiles.slice(0, 50), fileCount: rw.rewriteFiles.length },
|
|
306
|
+
writeFiles: replaced,
|
|
307
|
+
stats: { willWrite: Object.keys(replaced).length, total: Object.keys(files).length, bytes: pack.stats ? pack.stats.bytes : 0 },
|
|
308
|
+
warnings,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 冲突改名:`handoff/PLAN.md` → `handoff/PLAN.from-pack.md`(保留扩展名)。 */
|
|
313
|
+
export function renameForConflictPre(relPath) {
|
|
314
|
+
const s = String(relPath)
|
|
315
|
+
const i = s.lastIndexOf('.')
|
|
316
|
+
const j = s.lastIndexOf('/')
|
|
317
|
+
if (i > j && i > 0) return s.slice(0, i) + '.from-pack' + s.slice(i)
|
|
318
|
+
return s + '.from-pack'
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ───────────────────────── 全局记录合并(纯函数)─────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 把包内的 summaryRecord 合并进目标机的 workspaces-summary.json(**merge 不 replace**)。
|
|
325
|
+
* ★实测该文件有 7 条记录(含其它工作区)⇒ 整体覆盖会丢别人的。
|
|
326
|
+
*
|
|
327
|
+
* @param {object} summary 目标机现有 summary(可能为 null)
|
|
328
|
+
* @param {object} record 包内记录(可能为 null)
|
|
329
|
+
* @param {string} targetWs 目标真实路径
|
|
330
|
+
* @returns {{summary:object, changed:boolean, action:'appended'|'updated'|'noop'}}
|
|
331
|
+
*/
|
|
332
|
+
export function mergeSummaryRecordPre(summary, record, targetWs) {
|
|
333
|
+
const base = summary && typeof summary === 'object' ? summary : {}
|
|
334
|
+
const list = Array.isArray(base.workspaces) ? base.workspaces.slice() : []
|
|
335
|
+
const now = Date.now()
|
|
336
|
+
if (!record || typeof record !== 'object') {
|
|
337
|
+
return { summary: Object.assign({}, base, { workspaces: list, generatedAt: base.generatedAt || now }), changed: false, action: 'noop' }
|
|
338
|
+
}
|
|
339
|
+
const next = Object.assign({}, record, { path: targetWs, name: basenamePre(targetWs) })
|
|
340
|
+
const i = list.findIndex((x) => x && x.path === targetWs)
|
|
341
|
+
if (i >= 0) {
|
|
342
|
+
// 同路径:保留目标机已有的 items(更可能是较新的),但把 A 机的补进来(去重)
|
|
343
|
+
const old = list[i] || {}
|
|
344
|
+
const items = dedupeStringsPre([].concat(old.items || [], next.items || []))
|
|
345
|
+
list[i] = Object.assign({}, old, next, { items, dateRange: old.dateRange || next.dateRange })
|
|
346
|
+
return { summary: Object.assign({}, base, { workspaces: list, generatedAt: now }), changed: true, action: 'updated' }
|
|
347
|
+
}
|
|
348
|
+
list.push(next)
|
|
349
|
+
return { summary: Object.assign({}, base, { workspaces: list, generatedAt: now }), changed: true, action: 'appended' }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 去重(保持顺序,字面量比对)。 */
|
|
353
|
+
/**
|
|
354
|
+
* ★v3.1.3:日历条目**合并**(纯函数,不覆盖)。
|
|
355
|
+
*
|
|
356
|
+
* 为什么不是整篇覆盖:CALENDAR.md 在 `~/.dsh/memory/CALENDAR.md` —— **用户级、跨工作区共享**,
|
|
357
|
+
* 且两台机器都可能往里写过。整篇覆盖会丢掉本机独有的条目
|
|
358
|
+
* (与 `workspaces-summary.json` 必须 merge 是同一条纪律)。
|
|
359
|
+
*
|
|
360
|
+
* 判据:以 `date | time | title` 三元组为身份。
|
|
361
|
+
* - 身份冲突 ⇒ **保留本机已有的**(与导入冲突默认 keep 一致;不覆盖用户已改过的状态)。
|
|
362
|
+
* - 本机没有的 ⇒ 追加进来。
|
|
363
|
+
* - 输出按 日期 → 时间 排序,保证确定性(同输入必得同输出,便于断言)。
|
|
364
|
+
*
|
|
365
|
+
* @param {string} localText 本机 CALENDAR.md 原文(空 ⇒ 视为空日历)
|
|
366
|
+
* @param {string} incomingText 包内 CALENDAR.md 原文
|
|
367
|
+
* @returns {{ok:boolean, text:string, added:number, kept:number}}
|
|
368
|
+
*/
|
|
369
|
+
export function calendarMergePre(localText, incomingText) {
|
|
370
|
+
const parse = (t) => {
|
|
371
|
+
const out = []
|
|
372
|
+
let curDate = ''
|
|
373
|
+
for (const raw of String(t || '').split(/\r?\n/)) {
|
|
374
|
+
const line = raw.trim()
|
|
375
|
+
if (!line) continue
|
|
376
|
+
const dm = line.match(/^## (\d{4}-\d{2}-\d{2})/)
|
|
377
|
+
if (dm) { curDate = dm[1]; continue }
|
|
378
|
+
const m = line.match(/^- \[([ xX])\] (\d{1,2}:\d{2}) \| ([^|]+?) \| (.+?)(?: \| (.*))?$/)
|
|
379
|
+
if (m) out.push({ date: curDate, done: m[1] !== ' ', time: m[2], quadrant: m[3].trim(), title: m[4].trim(), note: (m[5] || '').trim() })
|
|
380
|
+
}
|
|
381
|
+
return out
|
|
382
|
+
}
|
|
383
|
+
const keyOf = (en) => en.date + '|' + en.time + '|' + en.title
|
|
384
|
+
const local = parse(localText).filter((en) => en.date)
|
|
385
|
+
const incoming = parse(incomingText).filter((en) => en.date)
|
|
386
|
+
const seen = new Set(local.map(keyOf))
|
|
387
|
+
const merged = local.slice()
|
|
388
|
+
let added = 0
|
|
389
|
+
for (const en of incoming) {
|
|
390
|
+
const k = keyOf(en)
|
|
391
|
+
if (seen.has(k)) continue
|
|
392
|
+
seen.add(k)
|
|
393
|
+
merged.push(en)
|
|
394
|
+
added++
|
|
395
|
+
}
|
|
396
|
+
const byDate = new Map()
|
|
397
|
+
for (const en of merged) {
|
|
398
|
+
if (!byDate.has(en.date)) byDate.set(en.date, [])
|
|
399
|
+
byDate.get(en.date).push(en)
|
|
400
|
+
}
|
|
401
|
+
const lines = ['# 日历与日程 (CALENDAR)', '', '> 由 dsh-auto-memory 维护;AI 可从对话中提取 deadline/约定写入,用户也可在 GUI 操作。', '']
|
|
402
|
+
for (const date of Array.from(byDate.keys()).sort()) {
|
|
403
|
+
lines.push('## ' + date)
|
|
404
|
+
for (const en of byDate.get(date).sort((a, b) => String(a.time || '').localeCompare(String(b.time || '')))) {
|
|
405
|
+
const mark = en.done ? 'x' : ' '
|
|
406
|
+
const note = en.note ? ' | ' + en.note : ''
|
|
407
|
+
lines.push('- [' + mark + '] ' + (en.time || '--:--') + ' | ' + (en.quadrant || '未分类') + ' | ' + en.title + note)
|
|
408
|
+
}
|
|
409
|
+
lines.push('')
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, text: lines.join('\n'), added, kept: local.length }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function dedupeStringsPre(arr) {
|
|
415
|
+
const seen = new Set()
|
|
416
|
+
const out = []
|
|
417
|
+
for (const x of arr || []) {
|
|
418
|
+
const s = String(x == null ? '' : x)
|
|
419
|
+
if (!s || seen.has(s)) continue
|
|
420
|
+
seen.add(s)
|
|
421
|
+
out.push(s)
|
|
422
|
+
}
|
|
423
|
+
return out
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 取路径最后一段(不引 node:path,保持本模块可被纯逻辑测试)。 */
|
|
427
|
+
export function basenamePre(p) {
|
|
428
|
+
const s = String(p || '').replace(/[\\/]+$/, '')
|
|
429
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
|
|
430
|
+
return i >= 0 ? s.slice(i + 1) : s
|
|
431
|
+
}
|