@max-null/dsh-memory 0.20.0 → 0.22.0

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/dist/self.js CHANGED
@@ -25,7 +25,8 @@ export const SELF_VERSION = (() => {
25
25
  }
26
26
  })();
27
27
  export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
28
- + '你有 14 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索);'
28
+ + '你有 16 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索);'
29
+ + '**同步工具** memory_sync / memory_conflicts:跨环境同步;`memory_sync` 的 `status` 只看状态、`pull` 拉取并导入、`push` 镜像并推送,`memory_conflicts` 列出**未被自动裁决**的冲突,同步**不会**自动决定哪一边对——两边都改过同一条时两个版本都留着等人处理,记忆被静默覆盖就没了;'
29
30
  + '**日记工具** diary_read / diary_write:日记是 md 文件,落在 `<DSH_HOME>/diary`,与记忆**分开存、不进检索、不占注入预算**。记的是经历——发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么);不是任务清单(那是 git log),也不是结论(那是 memory_save)。读它不吃预算,需要时整篇读。'
30
31
  + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
31
32
  + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
3
+ *
4
+ * **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
5
+ * 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
6
+ * 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
7
+ * `Merge made by the 'ort' strategy`,0 冲突。
8
+ *
9
+ * **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
10
+ * cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
11
+ * 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
12
+ *
13
+ * **同步面不带什么**(见设计文档 §5):
14
+ * - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
15
+ * 毫无变化时持续变脏,git 历史被噪声淹没。
16
+ * - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
17
+ * - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
18
+ * 推到远端仓库等于把那层保护绕开。
19
+ *
20
+ * @see docs/设计/2026-11-06-记忆云同步-设计方案.md
21
+ */
22
+ /** 一条记录在镜像里的位置与内容。 */
23
+ export interface SyncRecordFile {
24
+ /** 相对导出根的路径,如 `records/global/<uuid>.json`。 */
25
+ readonly path: string;
26
+ /** 文件内容(JSON + 末尾换行;已剔除同步面之外的字段)。 */
27
+ readonly content: string;
28
+ }
29
+ /** 导出的输入:各层记忆库的**解析后**文档。 */
30
+ export interface SyncExportOptions {
31
+ /** `memory.json` 解析后的对象(global 层)。 */
32
+ readonly globalDocument: unknown;
33
+ /** 各工作区的记忆库文档;`cwd` 用于算目录名,须与引擎写入时一致。 */
34
+ readonly projectDocuments?: ReadonlyArray<{
35
+ readonly cwd: string;
36
+ readonly document: unknown;
37
+ }>;
38
+ }
39
+ /** 一次导出的结果。 */
40
+ export interface SyncExportRun {
41
+ /** 待写入的记录文件(顺序稳定:global 在前,工作区按 cwd 排序)。 */
42
+ readonly files: readonly SyncRecordFile[];
43
+ /** 因处于隔离区而未导出的条数。 */
44
+ readonly skippedQuarantined: number;
45
+ }
46
+ /**
47
+ * 项目在同步面上的目录名——**项目目录名,不是路径哈希**。
48
+ *
49
+ * 2026-11-06 的第一版用的是 `cwd` 的 djb2 哈希(与引擎本地文件名同口径)。那个身份
50
+ * 只在一台机器内成立:换台机器换个盘符,同一个项目就成了另一个项目,记忆会静默分裂
51
+ * 成互不相认的两份。2026-11-11 改成项目名,依据是用户的三条约束——「以项目名为准」
52
+ * 「不会出现同名不同项目」「工作机上的目录不一样」。
53
+ *
54
+ * 统一小写:Windows 路径大小写不敏感、WSL/Linux 敏感,同一个项目在两处拼写可能不同;
55
+ * 不规范化就会变成 `records/project/WorkStation` 与 `records/project/workstation`
56
+ * 两个目录,又一次静默分裂。
57
+ */
58
+ export declare function projectName(cwd: string): string;
59
+ /**
60
+ * 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
61
+ *
62
+ * @param options - 各层文档。
63
+ * @returns 待写入的文件与隔离跳过数。
64
+ */
65
+ export declare function exportRecords(options: SyncExportOptions): SyncExportRun;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
3
+ *
4
+ * **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
5
+ * 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
6
+ * 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
7
+ * `Merge made by the 'ort' strategy`,0 冲突。
8
+ *
9
+ * **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
10
+ * cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
11
+ * 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
12
+ *
13
+ * **同步面不带什么**(见设计文档 §5):
14
+ * - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
15
+ * 毫无变化时持续变脏,git 历史被噪声淹没。
16
+ * - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
17
+ * - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
18
+ * 推到远端仓库等于把那层保护绕开。
19
+ *
20
+ * @see docs/设计/2026-11-06-记忆云同步-设计方案.md
21
+ */
22
+ import { join } from 'node:path';
23
+ /**
24
+ * 进入同步面的字段白名单。
25
+ *
26
+ * 用白名单而不是黑名单:`StoredBlock` 以后还会长出新字段(`topic` 就是 0.20.0 才加的),
27
+ * 黑名单会让新字段**默认泄漏**进来——而「一个只该存在本地的字段漏进远端仓库」这类事故,
28
+ * 发现的时候内容已经在别人能拉到的地方了。
29
+ */
30
+ const SYNCED_FIELDS = [
31
+ 'namespace',
32
+ 'status',
33
+ 'content',
34
+ 'keywords',
35
+ 'createdAt',
36
+ 'updatedAt',
37
+ 'injected',
38
+ 'injectedAuto',
39
+ 'topic',
40
+ 'kind',
41
+ 'anchor',
42
+ 'stale',
43
+ 'staleReason',
44
+ 'source',
45
+ 'meta',
46
+ ];
47
+ /** 记录文件的目录名(与清单文件同级)。 */
48
+ const RECORDS_DIR = 'records';
49
+ /** 把 `unknown` 收窄成对象;不是对象返回 `undefined`。 */
50
+ function asObject(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ ? value
53
+ : undefined;
54
+ }
55
+ /**
56
+ * 取出一份 unit 文档里的 blocks 表。
57
+ *
58
+ * 文档形状是 DSH storage 的 unit 格式:`{ unit, global, tables: { blocks: {...} } }`。
59
+ * 形状不符时返回空表而不是抛错——镜像导出不该因为一个损坏的库把整个同步卡死,
60
+ * 条数为 0 会被上层当作「没有可导出的东西」处理。
61
+ */
62
+ function blocksOf(document) {
63
+ const doc = asObject(document);
64
+ const tables = asObject(doc?.tables);
65
+ const blocks = asObject(tables?.blocks);
66
+ if (blocks === undefined)
67
+ return {};
68
+ const out = {};
69
+ for (const [id, block] of Object.entries(blocks)) {
70
+ const value = asObject(block);
71
+ if (value !== undefined)
72
+ out[id] = value;
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * 项目在同步面上的目录名——**项目目录名,不是路径哈希**。
78
+ *
79
+ * 2026-11-06 的第一版用的是 `cwd` 的 djb2 哈希(与引擎本地文件名同口径)。那个身份
80
+ * 只在一台机器内成立:换台机器换个盘符,同一个项目就成了另一个项目,记忆会静默分裂
81
+ * 成互不相认的两份。2026-11-11 改成项目名,依据是用户的三条约束——「以项目名为准」
82
+ * 「不会出现同名不同项目」「工作机上的目录不一样」。
83
+ *
84
+ * 统一小写:Windows 路径大小写不敏感、WSL/Linux 敏感,同一个项目在两处拼写可能不同;
85
+ * 不规范化就会变成 `records/project/WorkStation` 与 `records/project/workstation`
86
+ * 两个目录,又一次静默分裂。
87
+ */
88
+ export function projectName(cwd) {
89
+ // **不能用 `node:path` 的 basename**:它按平台分隔符切分,同一个字符串在 Windows 上
90
+ // 得到 `demo`、在 Linux 上得到整串 `H:\proj\demo`。2026-11-11 的 CI 就是这么红的
91
+ // (本地 Windows 全绿,Linux runner 上 `records/project/h:\proj\demo/`)。
92
+ // 两种分隔符都认,结果才跨平台一致——这也正是这个函数存在的意义。
93
+ const segments = cwd.replace(/\\/gu, '/').split('/').filter(segment => segment !== '');
94
+ const base = segments[segments.length - 1] ?? '';
95
+ // 盘符根(`H:\`)与路径根(`/`)都退到 `root`,不产生空目录名。
96
+ if (base === '' || /^[a-z]:$/iu.test(base))
97
+ return 'root';
98
+ return base.toLowerCase();
99
+ }
100
+ /** 只保留白名单字段;值为 `undefined` 的键不落进文件(避免 `"x": null` 噪声)。 */
101
+ function pickSyncedFields(block) {
102
+ const out = {};
103
+ for (const field of SYNCED_FIELDS) {
104
+ const value = block[field];
105
+ if (value !== undefined)
106
+ out[field] = value;
107
+ }
108
+ return out;
109
+ }
110
+ /** 一条记录 → 一个文件;返回 `undefined` 表示这条不该进镜像(隔离区)。 */
111
+ function recordFile(id, block, segments) {
112
+ if (block.quarantined === true)
113
+ return undefined;
114
+ const payload = pickSyncedFields(block);
115
+ return {
116
+ path: join(...segments, `${id}.json`),
117
+ content: `${JSON.stringify(payload, null, 2)}\n`,
118
+ };
119
+ }
120
+ /**
121
+ * 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
122
+ *
123
+ * @param options - 各层文档。
124
+ * @returns 待写入的文件与隔离跳过数。
125
+ */
126
+ export function exportRecords(options) {
127
+ const files = [];
128
+ let skippedQuarantined = 0;
129
+ for (const [id, block] of Object.entries(blocksOf(options.globalDocument))) {
130
+ const file = recordFile(id, block, [RECORDS_DIR, 'global']);
131
+ if (file === undefined) {
132
+ skippedQuarantined += 1;
133
+ continue;
134
+ }
135
+ files.push(file);
136
+ }
137
+ // 工作区按 cwd 排序:让两次导出的文件顺序稳定,git 不必为顺序抖动产生 diff。
138
+ const projects = [...(options.projectDocuments ?? [])].sort((left, right) => left.cwd.localeCompare(right.cwd));
139
+ for (const { cwd, document } of projects) {
140
+ for (const [id, block] of Object.entries(blocksOf(document))) {
141
+ const file = recordFile(id, block, [RECORDS_DIR, 'project', projectName(cwd)]);
142
+ if (file === undefined) {
143
+ skippedQuarantined += 1;
144
+ continue;
145
+ }
146
+ files.push(file);
147
+ }
148
+ }
149
+ return { files, skippedQuarantined };
150
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 同步镜像的本地 git 仓库层。
3
+ *
4
+ * **这一步不联网**:只做 `init` / `add` / `commit`。远端 push 是下一步,单独一层——
5
+ * 分层的理由是可验证性:本地仓库能在完全离线的情况下验通「记忆库 → 镜像 → 有历史的
6
+ * 仓库」整条链,网络问题留到那一层单独排查。
7
+ *
8
+ * `isomorphic-git` 走**动态 import**(体积理由见设计 §五):没开同步的人不加载它。
9
+ *
10
+ * **无改动不产生空提交**:定时跑一次同步是常态,每次都留一个空提交会让 `git log`
11
+ * 变成时间戳噪声,真正的变更被淹掉。
12
+ *
13
+ * @module dsh-memory/sync-git
14
+ */
15
+ /** {@link commitMirror} 的入参。 */
16
+ export interface MirrorCommitOptions {
17
+ /** 镜像仓库目录。 */
18
+ readonly dir: string;
19
+ /** 提交信息。 */
20
+ readonly message: string;
21
+ /** 作者;isomorphic-git 没有可读的全局 git 配置,必须显式传。 */
22
+ readonly author: {
23
+ readonly name: string;
24
+ readonly email: string;
25
+ };
26
+ }
27
+ /** 一次提交的结果。 */
28
+ export interface MirrorCommitRun {
29
+ /** 是否真的产生了提交(无改动时为 `false`)。 */
30
+ readonly committed: boolean;
31
+ /** 新提交的 sha;没有提交时缺省。 */
32
+ readonly sha?: string;
33
+ /** 本次涉及的路径数(新增 + 修改 + 删除)。 */
34
+ readonly filesChanged: number;
35
+ }
36
+ /**
37
+ * 把镜像目录的当前状态提交成一个 commit。
38
+ *
39
+ * 三种路径逐个处理:新增/修改走 `add`,**删除走 `remove`**——只 `add` 的话,
40
+ * 镜像里被删掉的记录在 git 看来会一直存在。
41
+ *
42
+ * @param options - 目录、提交信息与作者。
43
+ * @returns 是否提交、sha 与涉及的路径数。
44
+ */
45
+ export declare function commitMirror(options: MirrorCommitOptions): Promise<MirrorCommitRun>;
46
+ /**
47
+ * 读仓库的提交历史(从新到旧)。测试与 `memory_sync status` 共用。
48
+ *
49
+ * @param dir - 镜像仓库目录。
50
+ * @param depth - 最多取多少条;缺省不限。
51
+ * @returns 提交列表(sha / message / 时间)。仓库尚未建立时返回空数组。
52
+ */
53
+ export declare function readHistory(dir: string, depth?: number): Promise<Array<{
54
+ sha: string;
55
+ message: string;
56
+ at: number;
57
+ }>>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 同步镜像的本地 git 仓库层。
3
+ *
4
+ * **这一步不联网**:只做 `init` / `add` / `commit`。远端 push 是下一步,单独一层——
5
+ * 分层的理由是可验证性:本地仓库能在完全离线的情况下验通「记忆库 → 镜像 → 有历史的
6
+ * 仓库」整条链,网络问题留到那一层单独排查。
7
+ *
8
+ * `isomorphic-git` 走**动态 import**(体积理由见设计 §五):没开同步的人不加载它。
9
+ *
10
+ * **无改动不产生空提交**:定时跑一次同步是常态,每次都留一个空提交会让 `git log`
11
+ * 变成时间戳噪声,真正的变更被淹掉。
12
+ *
13
+ * @module dsh-memory/sync-git
14
+ */
15
+ import { existsSync, mkdirSync } from 'node:fs';
16
+ import * as nodeFs from 'node:fs';
17
+ import { join } from 'node:path';
18
+ /** 仓库默认分支——与 GitHub 侧的约定一致。 */
19
+ const DEFAULT_BRANCH = 'main';
20
+ /** 动态载入 isomorphic-git(体积理由见文件头)。 */
21
+ async function loadGit() {
22
+ return await import('isomorphic-git');
23
+ }
24
+ /** 确保目录是一个 git 仓库;已初始化时什么都不做。 */
25
+ async function ensureRepo(dir) {
26
+ mkdirSync(dir, { recursive: true });
27
+ if (existsSync(join(dir, '.git')))
28
+ return;
29
+ const git = await loadGit();
30
+ await git.init({ fs: nodeFs, dir, defaultBranch: DEFAULT_BRANCH });
31
+ }
32
+ /**
33
+ * 把镜像目录的当前状态提交成一个 commit。
34
+ *
35
+ * 三种路径逐个处理:新增/修改走 `add`,**删除走 `remove`**——只 `add` 的话,
36
+ * 镜像里被删掉的记录在 git 看来会一直存在。
37
+ *
38
+ * @param options - 目录、提交信息与作者。
39
+ * @returns 是否提交、sha 与涉及的路径数。
40
+ */
41
+ export async function commitMirror(options) {
42
+ await ensureRepo(options.dir);
43
+ const git = await loadGit();
44
+ // statusMatrix 每行是 [filepath, head, workdir, stage]:
45
+ // head=1 表示上一个 commit 里有;workdir=0 表示工作区里已经没有。
46
+ const matrix = await git.statusMatrix({ fs: nodeFs, dir: options.dir });
47
+ const changed = matrix.filter(([, head, workdir, stage]) => head !== workdir || workdir !== stage);
48
+ if (changed.length === 0) {
49
+ return { committed: false, filesChanged: 0 };
50
+ }
51
+ for (const [filepath, , workdir] of changed) {
52
+ if (workdir === 0)
53
+ await git.remove({ fs: nodeFs, dir: options.dir, filepath });
54
+ else
55
+ await git.add({ fs: nodeFs, dir: options.dir, filepath });
56
+ }
57
+ const sha = await git.commit({
58
+ fs: nodeFs,
59
+ dir: options.dir,
60
+ message: options.message,
61
+ author: options.author,
62
+ });
63
+ return { committed: true, sha, filesChanged: changed.length };
64
+ }
65
+ /**
66
+ * 读仓库的提交历史(从新到旧)。测试与 `memory_sync status` 共用。
67
+ *
68
+ * @param dir - 镜像仓库目录。
69
+ * @param depth - 最多取多少条;缺省不限。
70
+ * @returns 提交列表(sha / message / 时间)。仓库尚未建立时返回空数组。
71
+ */
72
+ export async function readHistory(dir, depth) {
73
+ if (!existsSync(join(dir, '.git')))
74
+ return [];
75
+ const git = await loadGit();
76
+ try {
77
+ const commits = await git.log({ fs: nodeFs, dir, ...depth === undefined ? {} : { depth } });
78
+ return commits.map(entry => ({
79
+ sha: entry.oid,
80
+ message: entry.commit.message.trim(),
81
+ at: entry.commit.author.timestamp * 1000,
82
+ }));
83
+ }
84
+ catch {
85
+ // 空仓库(init 之后一个 commit 都没有)会在这里抛;那是正常状态。
86
+ return [];
87
+ }
88
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 同步导入层:把 `records/` 里的记录**规划**成一组待写入的操作。
3
+ *
4
+ * **这一层只出计划,不写任何东西。** 真正落库由调用方走引擎已有的写入路径完成,
5
+ * 理由:直接改 `memory.json` 会与内存缓存打架(引擎的写路径是「读—改—写全量覆盖」,
6
+ * 绕过它等于让两边各持一份真相)。分层之后,这一层是纯函数,可以在不碰数据的前提下
7
+ * 穷举冲突场景。
8
+ *
9
+ * 冲突策略见设计 §3.3:**同一条被两边改时不自动裁决**,两个版本都交出去让调用方写进
10
+ * `conflicts/`。记忆是长期资产,被静默覆盖的那份就没了。
11
+ *
12
+ * @module dsh-memory/sync-import
13
+ */
14
+ import type { z } from 'zod';
15
+ import type { blockSchema } from './schema.ts';
16
+ /** 与存储层同构的一条记录。 */
17
+ export type ImportedBlock = z.infer<typeof blockSchema>;
18
+ /** 一条来自 `records/` 的记录及其归属。 */
19
+ export interface IncomingRecord {
20
+ /** 记录 id(文件名去 `.json`)。 */
21
+ readonly id: string;
22
+ /** `global` 或 `project`。 */
23
+ readonly scope: 'global' | 'project';
24
+ /** `project` 作用域下的项目名;`global` 时缺省。 */
25
+ readonly projectName?: string;
26
+ /** 解析后的记录体。 */
27
+ readonly block: ImportedBlock;
28
+ }
29
+ /** 本地某一层现有的记录。 */
30
+ export interface LocalLayer {
31
+ /** 该层现有的记录:id → block。 */
32
+ readonly blocks: Readonly<Record<string, ImportedBlock>>;
33
+ }
34
+ /** {@link planImport} 的入参。 */
35
+ export interface ImportPlanOptions {
36
+ readonly incoming: readonly IncomingRecord[];
37
+ /** 本地 global 层。 */
38
+ readonly localGlobal: LocalLayer;
39
+ /** 本地的项目层:项目名(已小写)→ 该项目的记录。 */
40
+ readonly localProjects: Readonly<Record<string, LocalLayer>>;
41
+ }
42
+ /** 一条待写入的操作。 */
43
+ export interface ImportUpsert {
44
+ readonly id: string;
45
+ readonly scope: 'global' | 'project';
46
+ readonly projectName?: string;
47
+ readonly block: ImportedBlock;
48
+ }
49
+ /** 一条需要人工裁决的冲突。 */
50
+ export interface ImportConflict {
51
+ readonly id: string;
52
+ readonly scope: 'global' | 'project';
53
+ readonly projectName?: string;
54
+ /** 本地版本。 */
55
+ readonly mine: ImportedBlock;
56
+ /** 远端版本。 */
57
+ readonly theirs: ImportedBlock;
58
+ /** 判据:哪一边的 `updatedAt` 更新(仅作参考,决定写不写 `upserts`)。 */
59
+ readonly newer: 'mine' | 'theirs';
60
+ }
61
+ /** 一次导入规划的结果。 */
62
+ export interface ImportPlan {
63
+ /** 需要新增或覆盖的条目。 */
64
+ readonly upserts: readonly ImportUpsert[];
65
+ /** 两边都改过、需要人裁决的条目。 */
66
+ readonly conflicts: readonly ImportConflict[];
67
+ /** 本地没有对应项目、无法落地的条目(留在远端等它出现)。 */
68
+ readonly orphaned: readonly string[];
69
+ /** 本地已一致、无需动作的条数。 */
70
+ readonly unchanged: number;
71
+ }
72
+ /**
73
+ * 规划一次导入。**不读写磁盘、不碰引擎状态。**
74
+ *
75
+ * 三种归属分别处理:
76
+ * - `global`:永远有落点。
77
+ * - `project` 且本地有同名项目:落到该项目。
78
+ * - `project` 但本地没有该项目 → 进 `orphaned`,**不丢**(留在远端等它出现)。
79
+ *
80
+ * @param options - 远端记录、本地各层、项目映射。
81
+ * @returns 计划:要写的、要裁决的、落不了的、不需要动的。
82
+ */
83
+ export declare function planImport(options: ImportPlanOptions): ImportPlan;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 同步导入层:把 `records/` 里的记录**规划**成一组待写入的操作。
3
+ *
4
+ * **这一层只出计划,不写任何东西。** 真正落库由调用方走引擎已有的写入路径完成,
5
+ * 理由:直接改 `memory.json` 会与内存缓存打架(引擎的写路径是「读—改—写全量覆盖」,
6
+ * 绕过它等于让两边各持一份真相)。分层之后,这一层是纯函数,可以在不碰数据的前提下
7
+ * 穷举冲突场景。
8
+ *
9
+ * 冲突策略见设计 §3.3:**同一条被两边改时不自动裁决**,两个版本都交出去让调用方写进
10
+ * `conflicts/`。记忆是长期资产,被静默覆盖的那份就没了。
11
+ *
12
+ * @module dsh-memory/sync-import
13
+ */
14
+ /** 把一条记录投影成可比较的形状:只比内容,不比本地瞬态字段。 */
15
+ function comparable(block) {
16
+ return JSON.stringify([
17
+ block.content,
18
+ block.keywords,
19
+ block.namespace,
20
+ block.status,
21
+ block.injected ?? null,
22
+ block.topic ?? null,
23
+ block.kind ?? null,
24
+ block.stale ?? null,
25
+ block.staleReason ?? null,
26
+ block.anchor ?? null,
27
+ ]);
28
+ }
29
+ /** 本地层里找一条。 */
30
+ function findLocal(layer, id) {
31
+ return layer?.blocks[id];
32
+ }
33
+ /**
34
+ * 规划一次导入。**不读写磁盘、不碰引擎状态。**
35
+ *
36
+ * 三种归属分别处理:
37
+ * - `global`:永远有落点。
38
+ * - `project` 且本地有同名项目:落到该项目。
39
+ * - `project` 但本地没有该项目 → 进 `orphaned`,**不丢**(留在远端等它出现)。
40
+ *
41
+ * @param options - 远端记录、本地各层、项目映射。
42
+ * @returns 计划:要写的、要裁决的、落不了的、不需要动的。
43
+ */
44
+ export function planImport(options) {
45
+ const upserts = [];
46
+ const conflicts = [];
47
+ const orphaned = [];
48
+ let unchanged = 0;
49
+ for (const record of options.incoming) {
50
+ const { id, scope, projectName, block } = record;
51
+ let localLayer;
52
+ if (scope === 'global') {
53
+ localLayer = options.localGlobal;
54
+ }
55
+ else {
56
+ const key = projectName?.toLowerCase() ?? '';
57
+ localLayer = key === '' ? undefined : options.localProjects[key];
58
+ if (localLayer === undefined) {
59
+ // 本机没有这个项目:记下来但不导入,也不报错。
60
+ orphaned.push(id);
61
+ continue;
62
+ }
63
+ }
64
+ const local = findLocal(localLayer, id);
65
+ if (local === undefined) {
66
+ upserts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, block });
67
+ continue;
68
+ }
69
+ if (comparable(local) === comparable(block)) {
70
+ unchanged += 1;
71
+ continue;
72
+ }
73
+ // 两边内容不同:不自动裁决,两边都交出去。
74
+ const newer = (block.updatedAt ?? 0) > (local.updatedAt ?? 0) ? 'theirs' : 'mine';
75
+ conflicts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, mine: local, theirs: block, newer });
76
+ // 远端更新的那份仍然写进去(否则用户看到的永远是旧内容),但冲突记录同时留下。
77
+ if (newer === 'theirs') {
78
+ upserts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, block });
79
+ }
80
+ }
81
+ return { upserts, conflicts, orphaned, unchanged };
82
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 同步镜像的远端层:`push` / `pull` / 状态查询。
3
+ *
4
+ * **必须走 HTTP**:`isomorphic-git` 的 `push` 在没有 `http` 参数时直接抛
5
+ * `The function requires a "http" parameter but none was provided`(2026-11-11 实测,
6
+ * 想用本地 bare 仓库做离线测试也走不通)。所以本层天然依赖网络,与上一层的
7
+ * `sync-git.ts`(纯本地、可离线全测)分开。
8
+ *
9
+ * **认证用 PAT(https)而不是 SSH**:SSH key 在移动端的管理成本远高于 token,
10
+ * 而 dsh-go 那类环境正是目标场景之一。
11
+ *
12
+ * **token 不进 profile 配置**:由调用方从既有 credentials 机制取出后传入。本模块只
13
+ * 负责把它放进 http 请求,不负责存。
14
+ *
15
+ * @module dsh-memory/sync-remote
16
+ */
17
+ /** 远端操作共用的参数。 */
18
+ export interface RemoteOptions {
19
+ /** 镜像仓库目录。 */
20
+ readonly dir: string;
21
+ /** 远端地址(https)。 */
22
+ readonly url: string;
23
+ /** 分支名;缺省 `main`。 */
24
+ readonly branch?: string;
25
+ /** GitHub PAT;缺省时只尝试匿名(私有仓库会失败)。 */
26
+ readonly token?: string;
27
+ }
28
+ /** 一次远端操作的结果。 */
29
+ export interface RemoteRun {
30
+ readonly ok: boolean;
31
+ /** 失败原因;成功时缺省。 */
32
+ readonly error?: string;
33
+ /**
34
+ * 失败分类——排查时最要紧的一件事:「认证错」和「连不上」的正确应对完全不同,
35
+ * 而它们的原始报错常常长得差不多。
36
+ */
37
+ readonly kind?: 'auth' | 'network' | 'unknown';
38
+ }
39
+ /**
40
+ * 确保镜像目录是一个**与远端有共同历史**的仓库。
41
+ *
42
+ * 首次同步时如果只 `git init`,本地与远端是两条无关的历史线——push 会被拒
43
+ * (`Push rejected because it was not a simple fast-forward`)。这是 2026-11-11
44
+ * 第一次用真实仓库跑时撞出来的:单元测试里没有真远端,所以永远发现不了。
45
+ *
46
+ * 因此首次必须 clone,而不是 init + push。
47
+ *
48
+ * @param options - 目录、远端地址、分支与 token。
49
+ * @returns 是否执行了 clone;失败时带分类。
50
+ */
51
+ export declare function ensureMirrorRepo(options: RemoteOptions): Promise<MirrorRepoRun>;
52
+ /** {@link ensureMirrorRepo} 的结果。 */
53
+ export interface MirrorRepoRun {
54
+ readonly cloned: boolean;
55
+ readonly error?: string;
56
+ readonly kind?: 'auth' | 'network' | 'unknown';
57
+ }
58
+ /**
59
+ * 把本地提交推到远端。
60
+ *
61
+ * @param options - 目录、远端地址、分支与 token。
62
+ * @returns 成功与否;失败时带分类。
63
+ */
64
+ export declare function pushMirror(options: RemoteOptions): Promise<RemoteRun>;
65
+ /**
66
+ * 从远端拉取并快进本地。
67
+ *
68
+ * `fastForwardOnly: true`:合并策略不该由这一层决定。本地有分叉时宁可失败让人看见,
69
+ * 也不要让 isomorphic-git 悄悄造出一个 merge commit。
70
+ *
71
+ * @param options - 目录、远端地址、分支与 token。
72
+ * @returns 成功与否;失败时带分类。
73
+ */
74
+ export declare function pullMirror(options: RemoteOptions): Promise<RemoteRun>;
75
+ /**
76
+ * 确保 `origin` 指向给定地址。地址变化时重设(用户换了仓库)。
77
+ *
78
+ * @param dir - 镜像仓库目录。
79
+ * @param url - 期望的远端地址。
80
+ */
81
+ export declare function ensureRemote(dir: string, url: string): Promise<void>;