dsh-session-ops 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +117 -2
- package/README.zh.md +111 -0
- package/cordis.patch.yml +38 -0
- package/docs/DETAIL.md +24 -0
- package/docs/DETAIL.zh.md +23 -0
- package/icon.svg +1 -0
- package/lib/admin.js +955 -0
- package/lib/client.js +2167 -0
- package/lib/engine.js +237 -0
- package/lib/index.js +289 -0
- package/lib/memory.js +205 -0
- package/lib/move.js +844 -0
- package/lib/sessionlog.js +30 -0
- package/lib/store/frames.js +261 -0
- package/lib/store/host.js +105 -0
- package/lib/store/http.js +128 -0
- package/lib/store/index.js +31 -0
- package/lib/store/paths.js +163 -0
- package/lib/store/registry.js +223 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +79 -4
package/lib/memory.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 项目作用域记忆的「随会话走」支持(**可选,默认关**)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:`dsh-cross-memory` 的项目作用域是**工作区级**的 —— 存到
|
|
5
|
+
* `$DSH_HOME/memories/projects/<slug>/entries/<id>.md`,slug 由工作区根目录派生
|
|
6
|
+
* (`slugify(basename)`,只取项目名、与机器路径无关,见其 storage.js)。
|
|
7
|
+
* 所以搬会话**不需要**搬记忆:会话换了 cwd,作用域自然跟到目标工作区。
|
|
8
|
+
* 但"这条会话在源工作区下写下的条目"留在源工作区,如果这次搬迁属于**纠正错放**
|
|
9
|
+
* (会话本来就该在目标工作区),那些条目也该一起过去。本模块只负责这一件事。
|
|
10
|
+
*
|
|
11
|
+
* 两条设计原则:
|
|
12
|
+
* 1. **默认不做**。项目记忆是工作区共享的,把单条会话的条目拖过去会改变另一个
|
|
13
|
+
* 工作区的记忆视图——这种事必须显式开启(`memoryFollow: 'copy' | 'move'`)。
|
|
14
|
+
* 2. **文件级、可逆、可预演**。`dsh-cross-memory` 的存储契约写明「条目文件才是真相源,
|
|
15
|
+
* 索引是可重建的缓存,手改/删除索引会在下次读取时自愈」,所以复制 `.md` 文件
|
|
16
|
+
* 就是受支持的写入方式,也不必依赖它的运行时。
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-session-ops/memory
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { basename, join, resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** 与 dsh-cross-memory/lib/storage.js 的 slugify 逐字一致。 */
|
|
25
|
+
export function slugify(value) {
|
|
26
|
+
const slug = String(value)
|
|
27
|
+
.normalize('NFKD')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[\u0300-\u036f]/gu, '')
|
|
30
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/gu, '-')
|
|
31
|
+
.replace(/^-+|-+$/gu, '')
|
|
32
|
+
.slice(0, 64)
|
|
33
|
+
return slug.length > 0 ? slug : 'memory'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 与 dsh-cross-memory/lib/storage.js 的 projectSlug 逐字一致:**只取项目名**,
|
|
38
|
+
* 不再拼 `sha1(resolve(root))` 后缀。dsh-cross-memory 已把项目 slug 改成
|
|
39
|
+
* 「一台机器上算出来、另一台也对得上」的名字派生(旧的后缀方案会把同一个
|
|
40
|
+
* 项目按机器拆成多个目录,迁移逻辑见其 storage.ts 的 LEGACY_PROJECT_SLUG)。
|
|
41
|
+
*/
|
|
42
|
+
export function projectSlug(root) {
|
|
43
|
+
return slugify(root.replace(/[\\/]+$/u, '').split(/[\\/]/u).pop() ?? 'workspace')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `$DSH_HOME/memories/projects`。 */
|
|
47
|
+
export function projectsDirOf(dshHome) {
|
|
48
|
+
return join(dshHome, 'memories', 'projects')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Windows 上路径比较不区分大小写(其余平台按字面比较)。 */
|
|
52
|
+
function samePath(left, right) {
|
|
53
|
+
const a = resolve(left)
|
|
54
|
+
const b = resolve(right)
|
|
55
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 在 projects 目录里按 root 找已存在的项目(优先命中已有 slug,避免算出第二个目录)。
|
|
60
|
+
* @param projectsDir - `…/memories/projects`。
|
|
61
|
+
* @param root - 工作区根目录。
|
|
62
|
+
* @returns `{slug, root, descriptorFile, dir}`,没有时 `undefined`。
|
|
63
|
+
*/
|
|
64
|
+
export function findProjectByRoot(projectsDir, root) {
|
|
65
|
+
let entries
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(projectsDir, { withFileTypes: true })
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.isDirectory()) continue
|
|
73
|
+
const descriptorFile = join(projectsDir, entry.name, 'project.json')
|
|
74
|
+
if (!existsSync(descriptorFile)) continue
|
|
75
|
+
try {
|
|
76
|
+
const descriptor = JSON.parse(readFileSync(descriptorFile, 'utf8'))
|
|
77
|
+
if (typeof descriptor?.root === 'string' && samePath(descriptor.root, root)) {
|
|
78
|
+
return { slug: entry.name, root: descriptor.root, descriptorFile, dir: join(projectsDir, entry.name) }
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* 坏的 project.json 跳过 */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 极简 frontmatter 解析:只认 `key: value` 单行(dsh-cross-memory 的条目就是这种形状)。 */
|
|
88
|
+
export function parseFrontmatter(text) {
|
|
89
|
+
const out = {}
|
|
90
|
+
if (!text.startsWith('---')) return out
|
|
91
|
+
const end = text.indexOf('\n---', 3)
|
|
92
|
+
const head = end === -1 ? text.slice(3) : text.slice(3, end)
|
|
93
|
+
for (const line of head.split('\n')) {
|
|
94
|
+
const at = line.indexOf(':')
|
|
95
|
+
if (at <= 0) continue
|
|
96
|
+
const key = line.slice(0, at).trim()
|
|
97
|
+
if (key.length === 0) continue
|
|
98
|
+
out[key] = line.slice(at + 1).trim().replace(/^"(.*)"$/u, '$1')
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 计算一次「记忆随会话走」的计划。**只读**。
|
|
105
|
+
*
|
|
106
|
+
* @param options - `{dshHome, sessionId, fromCwd, toCwd}`。
|
|
107
|
+
* @returns 计划:源/目标项目、匹配到的条目(按 frontmatter 的 `session:` 归属过滤)。
|
|
108
|
+
*/
|
|
109
|
+
export function planMemoryFollow(options) {
|
|
110
|
+
const { dshHome, sessionId, fromCwd, toCwd } = options
|
|
111
|
+
const projectsDir = projectsDirOf(dshHome)
|
|
112
|
+
const source = findProjectByRoot(projectsDir, fromCwd)
|
|
113
|
+
const targetFound = findProjectByRoot(projectsDir, toCwd)
|
|
114
|
+
const target = targetFound ?? {
|
|
115
|
+
slug: projectSlug(toCwd),
|
|
116
|
+
root: resolve(toCwd),
|
|
117
|
+
descriptorFile: join(projectsDir, projectSlug(toCwd), 'project.json'),
|
|
118
|
+
dir: join(projectsDir, projectSlug(toCwd)),
|
|
119
|
+
planned: true,
|
|
120
|
+
}
|
|
121
|
+
const plan = {
|
|
122
|
+
projectsDir,
|
|
123
|
+
source: source === undefined ? null : { slug: source.slug, root: source.root, dir: source.dir },
|
|
124
|
+
target: { slug: target.slug, root: target.root, dir: target.dir, descriptorFile: target.descriptorFile, existed: targetFound !== undefined },
|
|
125
|
+
sessionId,
|
|
126
|
+
totalInSource: 0,
|
|
127
|
+
matched: [],
|
|
128
|
+
}
|
|
129
|
+
if (source === undefined) return plan
|
|
130
|
+
const entriesDir = join(source.dir, 'entries')
|
|
131
|
+
let files
|
|
132
|
+
try {
|
|
133
|
+
files = readdirSync(entriesDir)
|
|
134
|
+
} catch {
|
|
135
|
+
return plan
|
|
136
|
+
}
|
|
137
|
+
const wanted = String(sessionId).replace(/^session-/u, '')
|
|
138
|
+
for (const name of files) {
|
|
139
|
+
if (!name.endsWith('.md')) continue
|
|
140
|
+
const file = join(entriesDir, name)
|
|
141
|
+
let front
|
|
142
|
+
try {
|
|
143
|
+
front = parseFrontmatter(readFileSync(file, 'utf8'))
|
|
144
|
+
} catch {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
plan.totalInSource += 1
|
|
148
|
+
const owner = typeof front.session === 'string' ? front.session.replace(/^session-/u, '') : undefined
|
|
149
|
+
if (owner !== wanted) continue
|
|
150
|
+
const targetFile = join(target.dir, 'entries', name)
|
|
151
|
+
plan.matched.push({
|
|
152
|
+
id: front.id ?? name.replace(/\.md$/u, ''),
|
|
153
|
+
title: front.title ?? '',
|
|
154
|
+
sourceFile: file,
|
|
155
|
+
targetFile,
|
|
156
|
+
existsInTarget: existsSync(targetFile),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
return plan
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 建立目标项目目录(含 project.json 与 entries/)。 */
|
|
163
|
+
function ensureProject(target) {
|
|
164
|
+
mkdirSync(join(target.dir, 'entries'), { recursive: true })
|
|
165
|
+
if (!existsSync(target.descriptorFile)) {
|
|
166
|
+
writeFileSync(target.descriptorFile, `${JSON.stringify({
|
|
167
|
+
root: target.root,
|
|
168
|
+
name: basename(target.root),
|
|
169
|
+
slug: target.slug,
|
|
170
|
+
updatedAt: Date.now(),
|
|
171
|
+
}, null, 2)}\n`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 执行计划。`copy` 只加不删;`move` 复制后从源项目移除(= 纠正错放)。
|
|
177
|
+
* 目标已有同名条目时跳过(不覆盖、按名去重)。
|
|
178
|
+
*
|
|
179
|
+
* @param plan - {@link planMemoryFollow} 的返回值。
|
|
180
|
+
* @param mode - `'copy'` 或 `'move'`。
|
|
181
|
+
* @returns `{mode, copied, skipped, removed, files}`。
|
|
182
|
+
*/
|
|
183
|
+
export function applyMemoryFollow(plan, mode) {
|
|
184
|
+
const outcome = { mode, copied: 0, skipped: 0, removed: 0, files: [] }
|
|
185
|
+
if (plan.source === null || plan.matched.length === 0) return outcome
|
|
186
|
+
ensureProject(plan.target)
|
|
187
|
+
for (const entry of plan.matched) {
|
|
188
|
+
if (entry.existsInTarget) {
|
|
189
|
+
outcome.skipped += 1
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
copyFileSync(entry.sourceFile, entry.targetFile)
|
|
193
|
+
outcome.copied += 1
|
|
194
|
+
outcome.files.push(entry.id)
|
|
195
|
+
if (mode === 'move') {
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(entry.sourceFile)
|
|
198
|
+
outcome.removed += 1
|
|
199
|
+
} catch (error) {
|
|
200
|
+
outcome.removeError = String(error?.message ?? error)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return outcome
|
|
205
|
+
}
|