dsh-mengpo 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +97 -2
- package/cordis.patch.yml +5 -0
- package/icon-512.png +0 -0
- package/icon.svg +34 -0
- package/lib/chunk-S7AJP5TK.js +262 -0
- package/lib/chunk-YSAPYWNP.js +15 -0
- package/lib/client.js +783 -0
- package/lib/index.js +78 -0
- package/lib/scanner.js +11 -0
- package/lib/types/client/MemoryPage.d.ts +22 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/locale.d.ts +4 -0
- package/lib/types/client/styles.d.ts +2 -0
- package/lib/types/host/index.d.ts +25 -0
- package/lib/types/host/scanner.d.ts +52 -0
- package/lib/types/host/types.d.ts +70 -0
- package/lib/types/shared/remote.d.ts +55 -0
- package/lib/types.js +6 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +84 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LogosFu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="icon.svg" alt="梦婆·记忆" width="112" height="112" />
|
|
4
|
+
|
|
5
|
+
# 梦婆·记忆 · dsh-mengpo
|
|
6
|
+
|
|
7
|
+
**DSH(DeepSeek Harness)本地记忆的可视化管理插件** —— 在「插件」页里查看、编辑、删除、禁用你的全局记忆与每一个项目的记忆。
|
|
8
|
+
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://www.npmjs.com/package/dsh-mengpo)
|
|
11
|
+
[](https://github.com/topics/dsh-plugin)
|
|
12
|
+
|
|
13
|
+
> 名字取自「**孟婆汤**」谐音:传说喝下便忘,而这个插件反其道而行 —— 把那碗汤端到你面前,让你看清自己攒下了什么、哪些该倒掉。图标就是那只碗:碗内是汤,升起的是记忆。
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 它解决什么问题
|
|
20
|
+
|
|
21
|
+
DSH 的记忆默认只有「写入」没有「界面」:全局偏好写在 `~/.dsh-beta/AGENTS.md`,每个项目的经验/决策沉淀在 `<项目>/.dsh-project-memory/insights.json`。agent 一直在往里记(`save_lesson` / `remember` / 自动注入),但你**看不到自己攒了什么,也删不掉过时的条目**。梦婆把这个黑盒变成可浏览、可编辑的界面。
|
|
22
|
+
|
|
23
|
+
## 功能与界面
|
|
24
|
+
|
|
25
|
+
### 工作区视图:全局记忆 + 项目记忆库总览
|
|
26
|
+
|
|
27
|
+
一眼看到全局记忆(AGENTS.md)和所有项目记忆库:insight 数、最近更新时间;有目录无数据的标「无记忆文件」,坏 JSON 标「数据损坏」而不拖垮整页。
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
### 项目视图:按类型分组的 insight 列表
|
|
32
|
+
|
|
33
|
+
点任意项目进入。insight 按 kind 分组(教训 / 决策 / 流程 / 经验),标注作用域、命中次数;被禁用的条目置灰留存,防止旧坑回潮。
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
### 详情抽屉:看全文,也能动手
|
|
38
|
+
|
|
39
|
+
点任意一条 insight,右侧抽屉展示完整元信息(类型 / 范围 / 来源 / 置信度 / 命中次数 / 创建与更新时间)+ pattern / fix / 关联文件 / 触发条件全文。
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
### 编辑 / 禁用 / 删除
|
|
44
|
+
|
|
45
|
+
抽屉底部三个操作:**编辑**(标题、类型、范围、pattern、fix、文件、trigger,保存即原子写回)、**禁用/启用**(等价 `draft: true|false`)、**删除**(二次确认,永久移除)。
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 从 NPM 安装(推荐;应用内「插件中心」一键装)
|
|
53
|
+
dsh plugin --profile <your-profile> add dsh-mengpo
|
|
54
|
+
|
|
55
|
+
# 或直接从本仓库源码安装
|
|
56
|
+
dsh plugin --profile <your-profile> add github:LogosFu/dsh-mengpo
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> 桌面端的 profile 由 Electron 独占管理(CLI 会拒绝写入),请在界面里装:
|
|
60
|
+
> 侧边栏 **插件 → 添加插件 → 安装第三方插件**,填入 `dsh-mengpo`(或 GitHub 地址 / 本地目录)。
|
|
61
|
+
|
|
62
|
+
装完重启 DSH,进入 **插件** 页 → **内置插件配置** → 点「**梦婆·记忆**」卡片。
|
|
63
|
+
|
|
64
|
+
## 数据与权限
|
|
65
|
+
|
|
66
|
+
| 项 | 说明 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| 读取 | `~/.dsh-beta/AGENTS.md`、`~/.dsh-beta/storages/session_projcache/sessions/*.json`(只取 `record.identity.cwd` 用于项目发现)、各项目 `.dsh-project-memory/insights.json` |
|
|
69
|
+
| 写入 | **仅**各项目自己的 `.dsh-project-memory/insights.json`(编辑/删除/禁用时) |
|
|
70
|
+
| 不写入 | `~/.dsh-beta` 下任何文件;不联网;不采集遥测 |
|
|
71
|
+
| 写回策略 | 重读-合并-原子写(tmp + rename,提交前复查 mtime,冲突自动重试最多 3 次),保留 id/createdAt/hitCount/source |
|
|
72
|
+
|
|
73
|
+
## 兼容性
|
|
74
|
+
|
|
75
|
+
- 已验证:DSH Desktop **2.0.18-beta.1**(macOS,`desktop` profile)
|
|
76
|
+
- 只用公开插件机制:`plugins.item` 设置卡 slot + 手写 Typert Remote 描述符(**不引入** `@deepseek-ai/*` 运行时依赖,零安装体积负担)
|
|
77
|
+
- Node ≥ 22(宿主 runtime 提供)
|
|
78
|
+
|
|
79
|
+
## 开发
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pnpm install
|
|
83
|
+
pnpm build # lib/index.js(host)、lib/client.js(web)、lib/types/
|
|
84
|
+
pnpm test # vitest:项目发现、insights 读写、原子写回与 mtime 冲突重试
|
|
85
|
+
pnpm spike # 模拟 ModuleLoader/cordis 加载构建产物(33 项断言)
|
|
86
|
+
pnpm smoke # 对真实 ~/.dsh-beta 只读冒烟:项目列表 + insight 计数
|
|
87
|
+
pnpm typecheck
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
架构与已拍板决策见 [docs/design.md](docs/design.md)。
|
|
91
|
+
|
|
92
|
+
## 卸载
|
|
93
|
+
|
|
94
|
+
「插件」页里停用或卸载即可,无后台进程、无残留文件。你的记忆数据不受影响(插件只读写既有 JSON)。
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
package/icon-512.png
ADDED
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#6D4BFF"/>
|
|
5
|
+
<stop offset="1" stop-color="#E040FB"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<radialGradient id="glow2" cx="0.22" cy="0.12" r="0.95">
|
|
8
|
+
<stop offset="0" stop-color="#ffffff" stop-opacity="0.26"/>
|
|
9
|
+
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
|
10
|
+
</radialGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect width="512" height="512" rx="120" fill="url(#bg2)"/>
|
|
13
|
+
<rect width="512" height="512" rx="120" fill="url(#glow2)"/>
|
|
14
|
+
<!-- bowl -->
|
|
15
|
+
<path d="M138 292 C138 292 152 402 256 402 C360 402 374 292 374 292 Z" fill="#fff"/>
|
|
16
|
+
<rect x="122" y="274" width="268" height="28" rx="14" fill="#fff"/>
|
|
17
|
+
<!-- soup surface -->
|
|
18
|
+
<ellipse cx="256" cy="288" rx="110" ry="20" fill="#B7A5FB"/>
|
|
19
|
+
<!-- swirl: real spiral in the liquid -->
|
|
20
|
+
<path d="M256 288
|
|
21
|
+
C 268 288 276 283 276 277
|
|
22
|
+
C 276 267 262 262 252 262
|
|
23
|
+
C 236 262 224 269 224 281
|
|
24
|
+
C 224 297 244 304 264 304
|
|
25
|
+
C 296 304 318 293 318 281"
|
|
26
|
+
fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity="0.95"/>
|
|
27
|
+
<!-- steam wisps -->
|
|
28
|
+
<path d="M226 244 C214 216 236 202 228 176" stroke="#fff" stroke-width="11" fill="none" stroke-linecap="round" opacity="0.9"/>
|
|
29
|
+
<path d="M290 246 C280 220 300 206 292 180" stroke="#fff" stroke-width="11" fill="none" stroke-linecap="round" opacity="0.9"/>
|
|
30
|
+
<!-- memory dots -->
|
|
31
|
+
<circle cx="260" cy="146" r="26" fill="#fff"/>
|
|
32
|
+
<circle cx="212" cy="164" r="13" fill="#fff"/>
|
|
33
|
+
<circle cx="312" cy="168" r="12" fill="#fff"/>
|
|
34
|
+
</svg>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EDITABLE_FIELDS
|
|
3
|
+
} from "./chunk-YSAPYWNP.js";
|
|
4
|
+
|
|
5
|
+
// src/host/scanner.ts
|
|
6
|
+
import { promises as fs } from "fs";
|
|
7
|
+
import { basename, join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { randomBytes } from "crypto";
|
|
10
|
+
var MengpoError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
details;
|
|
13
|
+
isDSHRemoteError = true;
|
|
14
|
+
constructor(code, message, details) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "RemoteError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.details = details;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var MEMORY_DIR = ".dsh-project-memory";
|
|
22
|
+
var INSIGHTS_FILE = "insights.json";
|
|
23
|
+
var MAX_WRITE_ATTEMPTS = 3;
|
|
24
|
+
async function statOrNull(path) {
|
|
25
|
+
try {
|
|
26
|
+
return await fs.stat(path);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function isDirectory(path) {
|
|
32
|
+
const stat = await statOrNull(path);
|
|
33
|
+
return stat !== null && stat.isDirectory();
|
|
34
|
+
}
|
|
35
|
+
function parseInsights(text) {
|
|
36
|
+
const raw = JSON.parse(text);
|
|
37
|
+
if (raw === null || typeof raw !== "object" || typeof raw.version !== "number" || !Array.isArray(raw.items)) {
|
|
38
|
+
throw new MengpoError("mengpo/corrupt", "insights.json \u7ED3\u6784\u4E0D\u7B26\uFF1A\u7F3A\u5C11 version \u6216 items \u6570\u7EC4");
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
async function readInsightsFile(insightsPath) {
|
|
43
|
+
const stat = await statOrNull(insightsPath);
|
|
44
|
+
if (stat === null || !stat.isFile()) {
|
|
45
|
+
throw new MengpoError("mengpo/not-found", `\u8BB0\u5FC6\u6587\u4EF6\u4E0D\u5B58\u5728: ${insightsPath}`, { path: insightsPath });
|
|
46
|
+
}
|
|
47
|
+
const text = await fs.readFile(insightsPath, "utf8");
|
|
48
|
+
try {
|
|
49
|
+
return { data: parseInsights(text), mtimeMs: stat.mtimeMs };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof MengpoError) throw error;
|
|
52
|
+
throw new MengpoError("mengpo/corrupt", `insights.json \u89E3\u6790\u5931\u8D25: ${error.message}`, {
|
|
53
|
+
path: insightsPath
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function projectNameOf(projectPath) {
|
|
58
|
+
const trimmed = projectPath.replace(/[/\\]+$/, "");
|
|
59
|
+
return basename(trimmed) || trimmed;
|
|
60
|
+
}
|
|
61
|
+
var LocalJsonDataSource = class {
|
|
62
|
+
home;
|
|
63
|
+
sessionsDir;
|
|
64
|
+
fallbackRoots;
|
|
65
|
+
hooks;
|
|
66
|
+
/** 同一 insights.json 的写操作串行化(进程内) */
|
|
67
|
+
writeChains = /* @__PURE__ */ new Map();
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.home = options.home ?? join(homedir(), ".dsh-beta");
|
|
70
|
+
this.sessionsDir = options.sessionsDir ?? join(this.home, "storages", "session_projcache", "sessions");
|
|
71
|
+
this.fallbackRoots = options.fallbackRoots ?? [join(homedir(), "code"), homedir()];
|
|
72
|
+
this.hooks = options.hooks;
|
|
73
|
+
}
|
|
74
|
+
/** D1:distinct session cwd ∩ 存在 .dsh-project-memory;取不到 cwd 时降级目录扫描。 */
|
|
75
|
+
async discoverProjectPaths() {
|
|
76
|
+
const cwds = await this.readSessionCwds();
|
|
77
|
+
if (cwds.length > 0) {
|
|
78
|
+
const paths2 = await this.filterMemoryDirs(cwds);
|
|
79
|
+
return { paths: paths2.sort(), discovery: "session-projcache" };
|
|
80
|
+
}
|
|
81
|
+
const candidates = [];
|
|
82
|
+
for (const root of this.fallbackRoots) {
|
|
83
|
+
candidates.push(root);
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) candidates.push(join(root, entry.name));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const paths = await this.filterMemoryDirs([...new Set(candidates)]);
|
|
95
|
+
return { paths: paths.sort(), discovery: "fallback-scan" };
|
|
96
|
+
}
|
|
97
|
+
/** 读 session 缓存里全部 distinct cwd;目录缺失/文件损坏都按「格式不符」处理。 */
|
|
98
|
+
async readSessionCwds() {
|
|
99
|
+
let files;
|
|
100
|
+
try {
|
|
101
|
+
files = (await fs.readdir(this.sessionsDir)).filter((name) => name.endsWith(".json"));
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const cwds = /* @__PURE__ */ new Set();
|
|
106
|
+
for (const name of files) {
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(await fs.readFile(join(this.sessionsDir, name), "utf8"));
|
|
109
|
+
const cwd = raw?.record?.identity?.cwd;
|
|
110
|
+
if (typeof cwd === "string" && cwd.length > 0) cwds.add(cwd);
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...cwds];
|
|
115
|
+
}
|
|
116
|
+
async filterMemoryDirs(candidates) {
|
|
117
|
+
const results = await Promise.all(
|
|
118
|
+
candidates.map(async (path) => await isDirectory(join(path, MEMORY_DIR)) ? path : null)
|
|
119
|
+
);
|
|
120
|
+
return results.filter((path) => path !== null);
|
|
121
|
+
}
|
|
122
|
+
async summarize(projectPath) {
|
|
123
|
+
const base = {
|
|
124
|
+
path: projectPath,
|
|
125
|
+
name: projectNameOf(projectPath),
|
|
126
|
+
status: "ok",
|
|
127
|
+
insightCount: 0,
|
|
128
|
+
latestUpdatedAt: null
|
|
129
|
+
};
|
|
130
|
+
const insightsPath = join(projectPath, MEMORY_DIR, INSIGHTS_FILE);
|
|
131
|
+
try {
|
|
132
|
+
const { data } = await readInsightsFile(insightsPath);
|
|
133
|
+
base.insightCount = data.items.length;
|
|
134
|
+
base.latestUpdatedAt = data.items.map((item) => item.updatedAt ?? item.createdAt).filter((value) => typeof value === "string").sort().at(-1) ?? null;
|
|
135
|
+
return base;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof MengpoError && error.code === "mengpo/corrupt") {
|
|
138
|
+
return { ...base, status: "corrupt", error: error.message };
|
|
139
|
+
}
|
|
140
|
+
if (error instanceof MengpoError && error.code === "mengpo/not-found") {
|
|
141
|
+
return { ...base, status: "missing" };
|
|
142
|
+
}
|
|
143
|
+
return { ...base, status: "corrupt", error: error.message };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async listProjects(_cwd) {
|
|
147
|
+
const { paths, discovery } = await this.discoverProjectPaths();
|
|
148
|
+
const projects = await Promise.all(paths.map((path) => this.summarize(path)));
|
|
149
|
+
return { projects, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), discovery };
|
|
150
|
+
}
|
|
151
|
+
async getGlobal() {
|
|
152
|
+
const path = join(this.home, "AGENTS.md");
|
|
153
|
+
try {
|
|
154
|
+
return { path, content: await fs.readFile(path, "utf8") };
|
|
155
|
+
} catch {
|
|
156
|
+
return { path, content: null };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async getProject(path) {
|
|
160
|
+
if (!await isDirectory(join(path, MEMORY_DIR))) {
|
|
161
|
+
throw new MengpoError("mengpo/not-found", `\u9879\u76EE\u6CA1\u6709\u8BB0\u5FC6\u5E93: ${path}`, { path });
|
|
162
|
+
}
|
|
163
|
+
const summary = await this.summarize(path);
|
|
164
|
+
if (summary.status !== "ok") return { ...summary, insights: [] };
|
|
165
|
+
const { data } = await readInsightsFile(join(path, MEMORY_DIR, INSIGHTS_FILE));
|
|
166
|
+
return { ...summary, insights: data.items };
|
|
167
|
+
}
|
|
168
|
+
async updateInsight(projectPath, insight) {
|
|
169
|
+
return this.mutate(projectPath, (data) => {
|
|
170
|
+
const index = data.items.findIndex((item) => item.id === insight.id);
|
|
171
|
+
if (index < 0) {
|
|
172
|
+
throw new MengpoError("mengpo/not-found", `insight \u4E0D\u5B58\u5728: ${insight.id}`, { id: insight.id });
|
|
173
|
+
}
|
|
174
|
+
const existing = data.items[index];
|
|
175
|
+
const merged = { ...existing };
|
|
176
|
+
for (const field of EDITABLE_FIELDS) {
|
|
177
|
+
if (field in insight) {
|
|
178
|
+
merged[field] = insight[field];
|
|
179
|
+
} else {
|
|
180
|
+
delete merged[field];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
merged.id = existing.id;
|
|
184
|
+
merged.createdAt = existing.createdAt;
|
|
185
|
+
merged.hitCount = existing.hitCount;
|
|
186
|
+
if (existing.source !== void 0) merged.source = existing.source;
|
|
187
|
+
merged.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
188
|
+
const items = [...data.items];
|
|
189
|
+
items[index] = merged;
|
|
190
|
+
return { next: { ...data, items }, value: merged };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async deleteInsight(projectPath, insightId) {
|
|
194
|
+
await this.mutate(projectPath, (data) => {
|
|
195
|
+
const items = data.items.filter((item) => item.id !== insightId);
|
|
196
|
+
if (items.length === data.items.length) {
|
|
197
|
+
throw new MengpoError("mengpo/not-found", `insight \u4E0D\u5B58\u5728: ${insightId}`, { id: insightId });
|
|
198
|
+
}
|
|
199
|
+
return { next: { ...data, items }, value: void 0 };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async setInsightDraft(projectPath, insightId, draft) {
|
|
203
|
+
return this.mutate(projectPath, (data) => {
|
|
204
|
+
const item = data.items.find((candidate) => candidate.id === insightId);
|
|
205
|
+
if (item === void 0) {
|
|
206
|
+
throw new MengpoError("mengpo/not-found", `insight \u4E0D\u5B58\u5728: ${insightId}`, { id: insightId });
|
|
207
|
+
}
|
|
208
|
+
const merged = { ...item, draft, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
209
|
+
return {
|
|
210
|
+
next: { ...data, items: data.items.map((candidate) => candidate.id === insightId ? merged : candidate) },
|
|
211
|
+
value: merged
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* D2 写回策略:重读-合并-原子写。
|
|
217
|
+
* 读 → 合并 → 写 tmp → 提交前重查 mtime,变了则丢弃 tmp 重新合并(最多 3 次)→ rename 覆盖。
|
|
218
|
+
*/
|
|
219
|
+
mutate(projectPath, mutator) {
|
|
220
|
+
const insightsPath = join(projectPath, MEMORY_DIR, INSIGHTS_FILE);
|
|
221
|
+
const previous = this.writeChains.get(insightsPath) ?? Promise.resolve();
|
|
222
|
+
const run = previous.catch(() => void 0).then(() => this.mutateLocked(insightsPath, mutator));
|
|
223
|
+
this.writeChains.set(
|
|
224
|
+
insightsPath,
|
|
225
|
+
run.catch(() => void 0)
|
|
226
|
+
);
|
|
227
|
+
return run;
|
|
228
|
+
}
|
|
229
|
+
async mutateLocked(insightsPath, mutator) {
|
|
230
|
+
let lastConflict = null;
|
|
231
|
+
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
|
|
232
|
+
const { data, mtimeMs } = await readInsightsFile(insightsPath);
|
|
233
|
+
const { next, value } = mutator(data);
|
|
234
|
+
const tmpPath = `${insightsPath}.mengpo-${process.pid}-${randomBytes(6).toString("hex")}.tmp`;
|
|
235
|
+
await fs.writeFile(tmpPath, JSON.stringify(next), "utf8");
|
|
236
|
+
try {
|
|
237
|
+
await this.hooks?.beforeCommit?.(insightsPath, attempt);
|
|
238
|
+
const current = await statOrNull(insightsPath);
|
|
239
|
+
if (current === null || current.mtimeMs !== mtimeMs) {
|
|
240
|
+
lastConflict = new MengpoError("mengpo/conflict", "insights.json \u5728\u5199\u5165\u671F\u95F4\u88AB\u5916\u90E8\u4FEE\u6539\uFF0C\u5DF2\u91CD\u8BD5", {
|
|
241
|
+
path: insightsPath,
|
|
242
|
+
attempt
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
await fs.rename(tmpPath, insightsPath);
|
|
247
|
+
return value;
|
|
248
|
+
} finally {
|
|
249
|
+
await fs.rm(tmpPath, { force: true }).catch(() => void 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw lastConflict ?? new MengpoError("mengpo/conflict", `insights.json \u8FDE\u7EED ${MAX_WRITE_ATTEMPTS} \u6B21\u5199\u5165\u51B2\u7A81`, {
|
|
253
|
+
path: insightsPath
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export {
|
|
259
|
+
MengpoError,
|
|
260
|
+
projectNameOf,
|
|
261
|
+
LocalJsonDataSource
|
|
262
|
+
};
|