@zoria-lind/dsh-ecolink-adapter 0.1.2 → 0.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/adapter/LICENSE +21 -0
- package/adapter/README.md +76 -0
- package/extension/core/config.mjs +21 -0
- package/extension/core/harvest.mjs +86 -0
- package/extension/core/prompt.mjs +109 -0
- package/extension/core/queue.mjs +82 -0
- package/extension/core/selector.mjs +88 -0
- package/package.json +15 -17
- package/service/LICENSE +21 -0
- package/service/README.md +72 -0
- package/service/compress.mjs +51 -0
- package/service/config.example.json +6 -0
- package/service/fix-pool.mjs +35 -0
- package/service/package.json +37 -0
- package/service/pool.mjs +555 -0
- package/service/server.mjs +294 -0
- package/service/test.mjs +535 -0
- package/service/zoria-lind-dsh-ecolink-service-0.1.3.tgz +0 -0
- /package/{cordis.patch.yml → adapter/cordis.patch.yml} +0 -0
- /package/{src → adapter/src}/config.js +0 -0
- /package/{src → adapter/src}/index.js +0 -0
- /package/{src → adapter/src}/kernel.js +0 -0
- /package/{src → adapter/src}/modules/memoryDiff.js +0 -0
- /package/{src → adapter/src}/modules/memoryInject.js +0 -0
- /package/{src → adapter/src}/modules/memoryWrite.js +0 -0
- /package/{src → adapter/src}/modules/serviceGuard.js +0 -0
package/adapter/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zoria Lind
|
|
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.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# dsh-ecolink-adapter
|
|
2
|
+
|
|
3
|
+
dsh-ecolink 的 DSH 适配层(v1):直读本地记忆池(`~/.dsh-memory/memory.json`,只读),
|
|
4
|
+
经 `agent/pre-step` 维持 DSM:memory_write 指令通道(E1),提供 `/ecolink-push` 显式写回(E4)
|
|
5
|
+
与记忆 skill 按需读取(E2),打通"网页端记住 → DSH 直接用;DSH 产出 → 网页端可用"。
|
|
6
|
+
|
|
7
|
+
> **v0.1.1 变更摘要(E0–E8)**:离线队列并发丢条目修复(E0,extension/core/queue.mjs);
|
|
8
|
+
> 指令/内容通道分离——指令每轮幂等注入(plugin 快照消息,不改写用户消息、不碰前缀缓存),
|
|
9
|
+
> 内容默认不注入、由 skill 按需 read,`contentInjectionEnabled` 为逃生舱(E1);
|
|
10
|
+
> 记忆 skill `ecolink-memory` 运行时注册(E2);服务端快照/hash/diff 三端点(E3);
|
|
11
|
+
> `/ecolink-push` 写回(复用 /memory/sync,source:'dsh',service 唯一写者)(E4);
|
|
12
|
+
> 建议确认队列(sync 进队列、确认才入池;未确认条目不进 DSH 上下文)(E5);
|
|
13
|
+
> 网页端只读面板 + chrome.alarms badge(E6);静音 + 黑名单(E7);服务默认安全(E8)。
|
|
14
|
+
>
|
|
15
|
+
> **v1.1(2026-09-14)**:service 自动拉起——探测到 17520 未监听就本机 spawn 拉起
|
|
16
|
+
> (apply + pre-step 60s 节流;仅本地 URL;`serviceAutoStart` 默认开)。E5 设计纠正:
|
|
17
|
+
> 确认点改到 DSH 侧(待实现),随仓 config.json 设 `autoConfirm: true`,网页收割直入池。
|
|
18
|
+
|
|
19
|
+
## 安装(link 挂载,与 token-optimizer 同款)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
dsh plugin --profile web add D:/dsh/dsh-plugins/dsh-ecolink/adapter
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 机制
|
|
26
|
+
|
|
27
|
+
- **只读池文件**:mtime 缓存(文件变化立即重读,TTL 5s 兜底);唯一写者是 ecolink-service
|
|
28
|
+
- **指令通道(E1,默认开)**:每轮 pre-step 末尾追加一条 `source.kind='plugin'` 的
|
|
29
|
+
`[dsh-ecolink 指令]` 快照消息(照抄内核 dsh-time-context 形态:prepend 注册 + await next +
|
|
30
|
+
createUserMessage;决策里的 messages 只喂本步请求、不写会话事件)。剥旧追加 → 每轮幂等不叠加
|
|
31
|
+
- **内容通道(E1,默认关)**:`contentInjectionEnabled: true` 恢复旧行为——改写用户消息注入
|
|
32
|
+
记忆块(2-gram 打分 + 时间衰减 + 预算 + 长提示词缩减 + singleInjection 会话去重)
|
|
33
|
+
- **`/ecolink-push`(E4)**:斜杠命令,把指定要点 POST 到 service 的 `/memory/sync`
|
|
34
|
+
(source:'dsh');token 未配置时自动读同仓 `service/config.json`;service 是唯一写者
|
|
35
|
+
- **记忆 skill(E2)**:注册 `ecolink-memory`,指引模型用 read 按需读池原文(引用前必须核对)
|
|
36
|
+
- **service 自动拉起(v1.1)**:apply 时与每次 pre-step(60s 节流)探测 `serviceUrl/memory/status`;
|
|
37
|
+
任何 HTTP 响应(含 401)= 已活;网络错误 = 未运行 → spawn 同仓 `service/server.mjs`
|
|
38
|
+
(detached + 无窗口)。仅对 127.0.0.1/localhost/::1 生效;service 目录缺失(独立发布包)静默跳过。
|
|
39
|
+
与登录自启任务兼容:端口已监听就不会重复起
|
|
40
|
+
- **fail-open**:池不存在/读失败/任何异常 → 静默放行,绝不破坏请求
|
|
41
|
+
- 只处理 `source.kind='user'` 的真实用户消息(DSH runtime-context 快照直接放行)
|
|
42
|
+
|
|
43
|
+
## 配置(cordis.patch.yml)
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
adapter:
|
|
47
|
+
enabled: true
|
|
48
|
+
poolPath: '~/.dsh-memory/memory.json'
|
|
49
|
+
poolTtlMs: 5000
|
|
50
|
+
injectEnabled: true # 总开关(false = 两个通道全关)
|
|
51
|
+
singleInjection: true # 内容通道专用(指令通道天然每轮幂等)
|
|
52
|
+
maxInjectionChars: 3000 # 内容通道注入预算
|
|
53
|
+
instructionEnabled: true # E1 指令通道
|
|
54
|
+
contentInjectionEnabled: false # E1 内容逃生舱
|
|
55
|
+
skillName: 'ecolink-memory' # E2 skill 注册名
|
|
56
|
+
serviceUrl: 'http://127.0.0.1:17520' # E4 写回目标
|
|
57
|
+
serviceToken: '' # E4;留空自动读同仓 service/config.json(E8 自动生成)
|
|
58
|
+
serviceAutoStart: true # v1.1;探测到服务未运行就自动拉起(仅本地 URL 生效)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 测试
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
node adapter/test/smoke.mjs # 66 项,零 API
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 已知限制
|
|
68
|
+
|
|
69
|
+
- 会话过滤靠 `#记忆名` 显式调用;DSH CLI 的 `--resume <session>` 只接受会话 id,
|
|
70
|
+
"按命名会话自动绑定"待 DSH 提供 `--session` 能力后再做
|
|
71
|
+
- 与 token-optimizer / behavior-enhancer 的联动开关(两插件中 `memory_bridge` 占位节)
|
|
72
|
+
待适配层稳定后启用
|
|
73
|
+
- `inject.js` 仍是经典脚本,INSTRUCTION_PROMPT/BLOCK_RE 等与 core/*.mjs 镜像维护
|
|
74
|
+
(注释锁死);彻底消除镜像需要构建期注入(P5/0D 结论)
|
|
75
|
+
- adapter npm 发布口径未解决:`src/modules/memoryInject.js` 跨目录 import `extension/core/*`,
|
|
76
|
+
npm `files` 无法引用包根之外路径(E8#5 有据缓办;Junction 挂载不受影响)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// dsh-ecolink 扩展默认配置单一来源(E6/F6:此前 background/popup/inject 三处默认值
|
|
2
|
+
// 互不一致——background 8 键、popup 7 键少 tagName、inject 4 键)。
|
|
3
|
+
// 消费方式:
|
|
4
|
+
// - background.js(MV3 module)与 popup.js(popup.html 以 type=module 加载):直接 import
|
|
5
|
+
// - inject.js 是经典脚本不能 import → 页内镜像仅保留它真正需要的 4 键,注释锁死同步
|
|
6
|
+
// - content.js 不读配置(只透传)
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
bridgeUrl: 'http://127.0.0.1:17520',
|
|
10
|
+
token: '',
|
|
11
|
+
injectEnabled: true,
|
|
12
|
+
harvestEnabled: true,
|
|
13
|
+
singleInjection: true, // 内容通道(2026-09-14 恢复):true=首条全量+后续 diff;false=每轮全量。压缩块沿用每页一次去重
|
|
14
|
+
contentInjectionEnabled: true, // 内容注入逃生舱(E1 逃生舱键,默认开):false=回到 E1 纯指令行为
|
|
15
|
+
maxInjectionChars: 3000,
|
|
16
|
+
compressMinAgeDays: 5,
|
|
17
|
+
tagName: 'DSM:memory_write', // 注意:F11——当前全仓消费点均硬编码 'DSM:memory_write',此键暂为死配置,改它不生效
|
|
18
|
+
panelEnabled: true, // E6:网页端只读面板
|
|
19
|
+
muted: false, // E7:一键静音(注入+收割全关;优先级高于两个分开关)
|
|
20
|
+
debug: false, // 背景层 console 日志门控(默认关;隐私卫生,收割内容片段不入 console)
|
|
21
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 标签收割:从模型回复流中提取 <DSM:memory_write> 标签(PLAN §3.1 主路径)。
|
|
2
|
+
// 纯函数、无浏览器依赖——SSE 累积文本在流结束时一次提取,标签从文本中剥离
|
|
3
|
+
// (ReadableStream 重写层保证标签永不进 DOM)。
|
|
4
|
+
// 黑名单校验参照 DSM 的 isValidMemoryWrite 模式:模型不服从/吐垃圾时宁弃勿存。
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TAG = 'DSM:memory_write'
|
|
7
|
+
|
|
8
|
+
function escapeRe(s) {
|
|
9
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 标签内容允许的简短修饰前缀(如 "importance:key|内容"),其余视为纯文本
|
|
13
|
+
const PREFIX_RE = /^(?:importance\s*:\s*(key|called|temp|context)\s*\|)/i
|
|
14
|
+
// DSM 属性形式:<DSM:memory_write key="snake_key" importance="always|called">value</DSM:memory_write>
|
|
15
|
+
// 模型对 DSM 格式有惯性,兼容吸收,服从率最高
|
|
16
|
+
const ATTR_RE = /^<DSM:memory_write\s+key="[^"]*"\s+importance="(always|called)">([\s\S]*?)<\/DSM:memory_write>$/i
|
|
17
|
+
|
|
18
|
+
// 模型常见不服从输出:占位/元话/自我描述/格式示例回显,一律不收(黑名单)。
|
|
19
|
+
// DSM 同款防御(其 zm 校验函数实测对抗有效):
|
|
20
|
+
// ① 杀手规则——值含方括号/花括号/尖括号一律拒([fact from user's message]、
|
|
21
|
+
// <tag> 回显等整类垃圾一条规则通杀;真实记忆极少含这三种括号,代价可接受)
|
|
22
|
+
// ② 黑名单子串匹配(非锚定,更宽)
|
|
23
|
+
// ③ user_name 长度 sanity
|
|
24
|
+
const BRACKET_RE = /\[.*?\]|\{.*?\}|<.*?>/
|
|
25
|
+
const STANDALONE_RE = /^(待补充|待填写|待确认|TODO|TBD|\.\.\.|暂无|无|空|memory[_ ]?write)$/i
|
|
26
|
+
// DSM(MIT,Md. Wahid)完整黑名单 + 本项目的增量条目(指令回显等)
|
|
27
|
+
const SUBSTRING_BLACKLIST = [
|
|
28
|
+
...['example_name', 'extracted_name', 'extracted_country', 'extracted_language', 'placeholder', 'example', '[fact from user', '[extracted_', '[user_name]', '[user_country]', '[user_language]', 'your_name_here', 'your_country_here', 'sample_name'],
|
|
29
|
+
...['brief fact', 'snake_case_key', 'fact from user', '记忆内容', '输出记忆标签', 'key: fact', '开头,紧接着写事实,以', '开头,紧接着写该条内容,以', '三部分连在一起', '事实内容'],
|
|
30
|
+
]
|
|
31
|
+
const KEY_BLACKLIST = new Set(['snake_case_key', 'example', 'example_name', 'example_key', 'placeholder', 'sample', 'key'])
|
|
32
|
+
|
|
33
|
+
export function extractTags(text, tagName = DEFAULT_TAG) {
|
|
34
|
+
const safe = escapeRe(tagName)
|
|
35
|
+
const plainRe = new RegExp(`<${safe}>([\\s\\S]*?)</${safe}>`, 'gi')
|
|
36
|
+
const attrRe = new RegExp(`<${safe}\\s+key="[^"]*"\\s+importance="[^"]*">[\\s\\S]*?</${safe}>`, 'gi')
|
|
37
|
+
const memories = []
|
|
38
|
+
// 前端可能把标签转义渲染(<DSM:memory_write>),先还原再解析(DSM Gr 同款)
|
|
39
|
+
const cleaned = String(text ?? '').replace(/</gi, '<').replace(/>/gi, '>')
|
|
40
|
+
// 属性形式保留完整标签(parseTagContent 要读属性),纯文本形式只取内容
|
|
41
|
+
.replace(attrRe, (all) => { memories.push(all); return '' })
|
|
42
|
+
.replace(plainRe, (_all, inner) => { memories.push(inner); return '' })
|
|
43
|
+
return { cleaned, memories }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseTagContent(raw) {
|
|
47
|
+
let s = String(raw ?? '').trim()
|
|
48
|
+
const m = PREFIX_RE.exec(s)
|
|
49
|
+
if (m) return { importance: m[1].toLowerCase(), content: s.slice(m[0].length).trim(), key: null }
|
|
50
|
+
// DSM 属性形式(整段 raw 含属性标签时):提取 key/value/importance
|
|
51
|
+
// (key = 压缩/更新闭环的锚点:同 key 覆盖旧值,PLAN §4.4 replace 指令的天然载体)
|
|
52
|
+
const a = ATTR_RE.exec(s)
|
|
53
|
+
if (a) {
|
|
54
|
+
const key = /key="([^"]*)"/i.exec(s)?.[1]?.trim() || null
|
|
55
|
+
return { importance: a[1] === 'always' ? 'key' : 'called', content: a[2].trim(), key }
|
|
56
|
+
}
|
|
57
|
+
return { importance: 'called', content: s, key: null }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 收割侧校验:括号规则 + 黑名单 + 长度上限 + 示例 key 拒绝,返回 null 表示丢弃
|
|
61
|
+
export function isValidMemoryWrite(content, key = null) {
|
|
62
|
+
const s = String(content ?? '').trim()
|
|
63
|
+
if (s.length === 0 || s.length > 2000) return false
|
|
64
|
+
if (BRACKET_RE.test(s)) return false
|
|
65
|
+
if (key != null && KEY_BLACKLIST.has(String(key).trim().toLowerCase())) return false
|
|
66
|
+
if (STANDALONE_RE.test(s)) return false
|
|
67
|
+
const lower = s.toLowerCase()
|
|
68
|
+
for (const bad of SUBSTRING_BLACKLIST) {
|
|
69
|
+
if (lower.includes(bad)) return false
|
|
70
|
+
}
|
|
71
|
+
if (String(key ?? '').toLowerCase() === 'user_name' && s.length < 2) return false
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 对整段流文本做收割:提取 → 校验 → 返回 { cleaned, memories }
|
|
76
|
+
export function harvestFromText(text, tagName = DEFAULT_TAG) {
|
|
77
|
+
const { cleaned, memories } = extractTags(text, tagName)
|
|
78
|
+
const out = []
|
|
79
|
+
for (const raw of memories) {
|
|
80
|
+
const parsed = parseTagContent(raw)
|
|
81
|
+
if (isValidMemoryWrite(parsed.content, parsed.key)) {
|
|
82
|
+
out.push({ content: parsed.content, importance: parsed.importance, source: 'web', key: parsed.key ?? null })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { cleaned, memories: out }
|
|
86
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 注入块组装与剥离(PLAN §3.1 读取通道 / §3.4 发送侧剥离)。
|
|
2
|
+
// 注入块以 SCOPE_PREFIX 起、SCOPE_SUFFIX 止——发送前先把旧块剥掉再拼新块,
|
|
3
|
+
// 保证 DeepSeek 侧存储永不累积注入残留(防污染第一道)。
|
|
4
|
+
// ⚠ 双份维护:inject.js 有本文件的经典脚本内联镜像(页面世界无法 import ESM)。
|
|
5
|
+
// 本文件是规范与单测基准;改逻辑时必须同步 inject.js 的 stripInjectedBlock/buildBlock/SYSTEM_PROMPT。
|
|
6
|
+
|
|
7
|
+
import { SCOPE_PREFIX } from './selector.mjs'
|
|
8
|
+
// 精度规则单一来源在服务层(PLAN §4.2);Node 上下文(adapter/测试)直接复用
|
|
9
|
+
import { precisionFor } from '../../service/pool.mjs'
|
|
10
|
+
|
|
11
|
+
export const SCOPE_SUFFIX = '[/dsh-ecolink 记忆]'
|
|
12
|
+
export const BLOCK_RE = new RegExp(`${SCOPE_PREFIX.replace(/[[\]]/g, '\\$&')}[\\s\\S]*?${SCOPE_SUFFIX.replace(/[[\]]/g, '\\$&')}`, 'g')
|
|
13
|
+
|
|
14
|
+
// 渲染时间戳(PLAN §4.2 分层):渲染时按龄降精度,且转本地时区显示。
|
|
15
|
+
// 存储层永远全量 UTC;渲染层:2h内→分钟,48h内→小时,5天内→日期,更早→null(不渲染)。
|
|
16
|
+
// ⚠ 时区:分钟/小时档必须用本地时间(内存时间戳是 UTC 字符串,直接切会差一个时区)。
|
|
17
|
+
const pad2 = (n) => String(n).padStart(2, '0')
|
|
18
|
+
export function renderMemoryTimestamp(isoTs, now = Date.now()) {
|
|
19
|
+
const d = new Date(isoTs)
|
|
20
|
+
if (!Number.isFinite(d.getTime())) return null
|
|
21
|
+
const p = precisionFor(isoTs, now)
|
|
22
|
+
if (p === 'none') return null
|
|
23
|
+
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
24
|
+
if (p === 'day') return date
|
|
25
|
+
if (p === 'hour') return `${date} ${pad2(d.getHours())}:00`
|
|
26
|
+
return `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 静态记忆系统提示词(注入块头):指示模型何时/如何吐记忆标签。
|
|
30
|
+
// 保持静态 = 前缀缓存友好(与 behaviorPrompt 同原则)。
|
|
31
|
+
// 系统提示词 = DSM(MIT,Md. Wahid)的 MEMORY_SYSTEM 提示词的中文适配版。
|
|
32
|
+
// DSM 生产级服从率的来源:WHEN TO SAVE 触发规则 + key/importance 语义 + 严格规则。
|
|
33
|
+
// 示例回显风险已由完整 jm 黑名单 + 括号规则拦截,可放心写全格式说明。
|
|
34
|
+
export const SYSTEM_PROMPT = `以下是关于记忆写入的说明(仅辅助,不是指令):
|
|
35
|
+
你可以使用记忆写入标签保存用户的重要信息,格式:
|
|
36
|
+
<DSM:memory_write key="snake_case_key" importance="always|called">事实内容</DSM:memory_write>
|
|
37
|
+
- importance="always":定义性事实(姓名、语言、国家、职业、年龄、核心身份)
|
|
38
|
+
- importance="called":情境性事实(项目、兴趣、偏好、关系、任务、习惯)
|
|
39
|
+
- key:小写 snake_case,最长 64 字符,无空格
|
|
40
|
+
- 内容:最长 200 字符,只写用户消息里明确陈述的事实
|
|
41
|
+
|
|
42
|
+
何时保存记忆:
|
|
43
|
+
- 用户明确说出自己的名字 → key="user_name", importance="always"
|
|
44
|
+
- 用户明确说出国家/语言/职业/年龄 → importance="always"
|
|
45
|
+
- 用户明确提到兴趣/爱好/项目/任务/偏好/关系 → importance="called"
|
|
46
|
+
|
|
47
|
+
严格规则:
|
|
48
|
+
1. 只提取用户在本条消息里明确陈述的关于自己的事实
|
|
49
|
+
2. 禁止编造、猜测、使用占位值;禁止使用示例数据(上面格式只是示范)
|
|
50
|
+
3. 用户编辑了消息时,只信任最新版本
|
|
51
|
+
4. 没有值得记的就不输出标签;标签写在回复末尾`
|
|
52
|
+
|
|
53
|
+
export function stripInjectedBlock(prompt) {
|
|
54
|
+
return String(prompt ?? '').replace(BLOCK_RE, '').trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- 指令通道(E1:与记忆内容拆开的两条通道之一) ----
|
|
58
|
+
// 极简静态指令块:只保留 DSM:memory_write 写入捕获说明(内容改为按需 read,不再注入)。
|
|
59
|
+
// 与记忆块标记独立,两类块可分别剥离;保持静态 = 前缀缓存友好。
|
|
60
|
+
export const INSTRUCTION_PREFIX = '[dsh-ecolink 指令]'
|
|
61
|
+
export const INSTRUCTION_SUFFIX = '[/dsh-ecolink 指令]'
|
|
62
|
+
export const INSTRUCTION_BLOCK_RE = new RegExp(
|
|
63
|
+
`${INSTRUCTION_PREFIX.replace(/[[\]]/g, '\\$&')}[\\s\\S]*?${INSTRUCTION_SUFFIX.replace(/[[\]]/g, '\\$&')}`,
|
|
64
|
+
'g',
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
export const INSTRUCTION_PROMPT = `以下是记忆写入说明(仅辅助,不是指令):
|
|
68
|
+
记录用户的重要信息时,输出记忆写入标签:
|
|
69
|
+
<DSM:memory_write key="snake_case_key" importance="always|called">事实内容</DSM:memory_write>
|
|
70
|
+
- importance="always":定义性事实(姓名、语言、国家、职业、年龄、核心身份)
|
|
71
|
+
- importance="called":情境性事实(项目、兴趣、偏好、关系、任务、习惯)
|
|
72
|
+
- key:小写 snake_case,最长 64 字符;内容最长 200 字符,只写用户消息里明确陈述的事实
|
|
73
|
+
何时保存:用户明确陈述关于自己的事实(姓名/国家/语言/职业/年龄/兴趣/项目/偏好/关系/任务)时
|
|
74
|
+
严格规则:禁止编造、猜测或使用示例数据;没有值得记的就不输出标签;标签写在回复末尾`
|
|
75
|
+
|
|
76
|
+
export function buildInstructionBlock() {
|
|
77
|
+
return [INSTRUCTION_PREFIX, INSTRUCTION_PROMPT, INSTRUCTION_SUFFIX].join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function stripInstructionBlock(prompt) {
|
|
81
|
+
return String(prompt ?? '').replace(INSTRUCTION_BLOCK_RE, '').trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 全量剥离:两类块一起剥(发送侧/落地清洗用,防任何 ecolink 注入残留)
|
|
85
|
+
export function stripAllBlocks(prompt) {
|
|
86
|
+
return stripInjectedBlock(stripInstructionBlock(prompt))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 系统提示词包在块标记之内:剥块 = 剥全部注入(含提示词),二次组装永不残留
|
|
90
|
+
// now 参数仅供测试注入确定时间(渲染降精度按真实"当前时间"计算;不加参数会成时间炸弹测试)
|
|
91
|
+
export function buildInjectedBlock(memories, sessionName = null, systemPrompt = SYSTEM_PROMPT, now = Date.now()) {
|
|
92
|
+
const lines = [SCOPE_PREFIX, systemPrompt]
|
|
93
|
+
if (sessionName) lines.push(`[会话:${sessionName}]`)
|
|
94
|
+
for (const m of memories ?? []) {
|
|
95
|
+
// 渲染降精度:按龄显示分钟/小时/日期,超过 5 天不渲染时间戳(内容里自写时间点自然保留)
|
|
96
|
+
const ts = m?.timestamp ? renderMemoryTimestamp(m.timestamp, now) : null
|
|
97
|
+
lines.push(ts ? `- (${ts}) ${m?.content ?? ''}` : `- ${m?.content ?? ''}`)
|
|
98
|
+
}
|
|
99
|
+
lines.push(SCOPE_SUFFIX)
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 组装最终 prompt:剥旧块 → 前置新块。
|
|
104
|
+
// 冷启动关键:记忆为空时也注入"仅系统提示词"块——教模型吐记忆标签,
|
|
105
|
+
// 收割才能开始;否则空池永远选不出记忆 → 永远不注入 → 池永远空(死锁)。
|
|
106
|
+
export function composePrompt(originalPrompt, memories = [], sessionName = null, systemPrompt = SYSTEM_PROMPT) {
|
|
107
|
+
const clean = stripInjectedBlock(originalPrompt)
|
|
108
|
+
return `${buildInjectedBlock(memories ?? [], sessionName, systemPrompt)}\n\n${clean}`
|
|
109
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 离线队列(PLAN §3.3):bridge 未启动时记忆缓存在 chrome.storage.local,
|
|
2
|
+
// 重连后补发。依赖注入化(storage/fetch),node:test 可用内存实现测退避逻辑。
|
|
3
|
+
// 序列化:入队与 flush 的「读-裁-写」都串到同一条 promise 链上(队列变更单一执行者),
|
|
4
|
+
// 网络调用保持在链外(退避等待不能卡链)。并发 push 不丢条目,失败保序不越队。
|
|
5
|
+
|
|
6
|
+
export function createOfflineQueue({ storage, fetchImpl, bridgeUrl, token = '', maxRetries = 6, baseDelayMs = 1000 }) {
|
|
7
|
+
const KEY = 'ecolink_outbox'
|
|
8
|
+
let flushing = false
|
|
9
|
+
let chain = Promise.resolve()
|
|
10
|
+
const serialize = (fn) => (chain = chain.then(fn, fn))
|
|
11
|
+
|
|
12
|
+
async function readQueue() {
|
|
13
|
+
const raw = await storage.get(KEY)
|
|
14
|
+
return Array.isArray(raw?.[KEY]) ? raw[KEY] : []
|
|
15
|
+
}
|
|
16
|
+
async function writeQueue(items) {
|
|
17
|
+
await storage.set({ [KEY]: items })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function postToBridge(entry) {
|
|
21
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
22
|
+
if (token) headers['X-Ecolink-Token'] = token
|
|
23
|
+
const resp = await fetchImpl(`${bridgeUrl.replace(/\/$/, '')}/memory/${entry.kind === 'session' ? 'session' : entry.kind === 'touch' ? 'touch' : 'sync'}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers,
|
|
26
|
+
body: JSON.stringify(entry.payload),
|
|
27
|
+
})
|
|
28
|
+
if (!resp.ok) throw new Error(`bridge ${resp.status}`)
|
|
29
|
+
return resp.json()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 入队(链内读-改-写,原子;并发 push 依次落盘,不再互相覆盖)+ 触发 flush(不阻塞调用方)
|
|
33
|
+
function enqueue(entry) {
|
|
34
|
+
return serialize(async () => {
|
|
35
|
+
const items = await readQueue()
|
|
36
|
+
items.push(entry)
|
|
37
|
+
await writeQueue(items)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
function push(entry) {
|
|
41
|
+
const p = enqueue(entry)
|
|
42
|
+
p.catch(() => {}).then(() => flush())
|
|
43
|
+
return p
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 串行 flush:每次都在链内重读队首(不沿用旧快照——快照裁剪会把并发写入覆盖掉),
|
|
47
|
+
// 逐条按序发,失败指数退避重试 maxRetries 次;失败路径只读不写(保序、不越队),
|
|
48
|
+
// 下次 push/定时器再试
|
|
49
|
+
async function flush() {
|
|
50
|
+
if (flushing) return
|
|
51
|
+
flushing = true
|
|
52
|
+
try {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const entry = await serialize(async () => (await readQueue())[0])
|
|
55
|
+
if (entry === undefined) break
|
|
56
|
+
let ok = false
|
|
57
|
+
let delay = baseDelayMs
|
|
58
|
+
for (let attempt = 0; attempt < maxRetries && !ok; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
await postToBridge(entry)
|
|
61
|
+
ok = true
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (attempt < maxRetries - 1) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
65
|
+
delay = Math.min(delay * 2, 60000)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!ok) break // bridge 仍不可达:停,保序等下次
|
|
70
|
+
await serialize(async () => {
|
|
71
|
+
const items = await readQueue()
|
|
72
|
+
items.shift()
|
|
73
|
+
await writeQueue(items)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
flushing = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { push, flush, pendingCount: () => readQueue().then((q) => q.length) }
|
|
82
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 注入选择器(PLAN §3.5):打分排序 + token 预算 + #记忆名 显式调用。
|
|
2
|
+
// 打分:关键词重叠(内容 2-gram 命中 ×5)+ 会话名匹配 ×15 + 时间衰减 + 访问计数 + pinned。
|
|
3
|
+
// 预算:maxInjectionChars(默认 3000 字符),长提示词自动缩减。
|
|
4
|
+
// ⚠ 双份维护:inject.js 有本文件的经典脚本内联镜像(页面世界无法 import ESM)。
|
|
5
|
+
// 本文件是规范与单测基准;改逻辑时必须同步 inject.js 的 selectMemories/effectiveBudget/parseMemoryCommand。
|
|
6
|
+
|
|
7
|
+
export const SCOPE_PREFIX = '[dsh-ecolink 记忆]'
|
|
8
|
+
|
|
9
|
+
// 中文友好的大块匹配:内容 2-gram 与提示词 2-gram 的重叠命中数
|
|
10
|
+
export function bigrams(s) {
|
|
11
|
+
const t = String(s ?? '')
|
|
12
|
+
if (t.length < 2) return new Set()
|
|
13
|
+
const set = new Set()
|
|
14
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
|
|
15
|
+
return set
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function keywordHits(content, promptBigrams, promptLower) {
|
|
19
|
+
const c = String(content ?? '')
|
|
20
|
+
const cb = bigrams(c)
|
|
21
|
+
let hits = 0
|
|
22
|
+
for (const g of cb) if (promptBigrams.has(g)) hits += 1
|
|
23
|
+
// 整词附加分(英文/数字词)
|
|
24
|
+
const words = promptLower.split(/[^\w一-龥]+/).filter((w) => w.length >= 2)
|
|
25
|
+
const cl = c.toLowerCase()
|
|
26
|
+
for (const w of words) if (cl.includes(w)) hits += 2
|
|
27
|
+
return hits
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scoreItem(item, prompt, now = Date.now()) {
|
|
31
|
+
const promptLower = String(prompt ?? '').toLowerCase()
|
|
32
|
+
const pb = bigrams(promptLower)
|
|
33
|
+
const content = item?.content ?? ''
|
|
34
|
+
let score = Math.min(20, keywordHits(content, pb, promptLower) * 5)
|
|
35
|
+
// 会话名匹配(identity 即"名字",权重 ×15)
|
|
36
|
+
const identity = item?.identity
|
|
37
|
+
if (identity && promptLower.includes(String(identity).toLowerCase())) score += 15
|
|
38
|
+
// 时间衰减:越新越相关(半衰 ~7 天)
|
|
39
|
+
const ts = new Date(item?.timestamp).getTime()
|
|
40
|
+
if (Number.isFinite(ts)) {
|
|
41
|
+
const ageDays = Math.max(0, (now - ts) / 86400e3)
|
|
42
|
+
score += 10 / (1 + ageDays / 7)
|
|
43
|
+
}
|
|
44
|
+
// 访问过的记忆加权(选择器自举:用过的更可能再用)
|
|
45
|
+
if (item?.last_accessed) score += 5
|
|
46
|
+
if (item?.pinned) score += 10
|
|
47
|
+
return score
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 在预算内精选:按分数排序取前 N,单条截断到 500 字符
|
|
51
|
+
export function selectMemories(items, prompt, budgetChars = 3000, now = Date.now()) {
|
|
52
|
+
const scored = items
|
|
53
|
+
.map((item) => ({ item, score: scoreItem(item, prompt, now) }))
|
|
54
|
+
.sort((a, b) => b.score - a.score || (b.item?.timestamp ?? '').localeCompare(a.item?.timestamp ?? ''))
|
|
55
|
+
const chosen = []
|
|
56
|
+
let used = 0
|
|
57
|
+
for (const { item } of scored) {
|
|
58
|
+
const content = String(item?.content ?? '').trim()
|
|
59
|
+
if (!content) continue
|
|
60
|
+
const capped = content.length > 500 ? content.slice(0, 500) + '…' : content
|
|
61
|
+
const cost = capped.length + 24 // 条目头部格式开销
|
|
62
|
+
if (used + cost > budgetChars) break
|
|
63
|
+
chosen.push({ ...item, content: capped })
|
|
64
|
+
used += cost
|
|
65
|
+
}
|
|
66
|
+
return { memories: chosen, chars: used }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 长提示词自动缩减预算:提示词本身越长,留给记忆的空间越小
|
|
70
|
+
export function effectiveBudget(prompt, maxInjectionChars = 3000) {
|
|
71
|
+
const len = String(prompt ?? '').length
|
|
72
|
+
if (len <= 5000) return maxInjectionChars
|
|
73
|
+
if (len <= 12000) return Math.floor(maxInjectionChars * 0.5)
|
|
74
|
+
return Math.floor(maxInjectionChars * 0.25)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// #记忆名 显式调用(PLAN §3.5,WebTool parseMemoryCommand 模式):
|
|
78
|
+
// 提示词里的 "#<会话名>" 命中会话池 identity → 只注入该会话的记忆,返回会话 id
|
|
79
|
+
export function parseMemoryCommand(prompt, sessionPools) {
|
|
80
|
+
const m = /#([^\s#,,。.!??、#]{1,40})/.exec(String(prompt ?? ''))
|
|
81
|
+
if (!m) return null
|
|
82
|
+
const name = m[1].trim()
|
|
83
|
+
if (!name) return null
|
|
84
|
+
for (const [sid, pool] of Object.entries(sessionPools ?? {})) {
|
|
85
|
+
if (pool?.identity && String(pool.identity).toLowerCase() === name.toLowerCase()) return sid
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zoria-lind/dsh-ecolink-adapter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "dsh-ecolink DSH 适配层:直读本地记忆池、pre-step 注入(打分+预算+会话去重)、#记忆名 过滤,打通网页端记忆生态",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "src/index.js",
|
|
7
|
+
"main": "adapter/src/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": "./src/index.js"
|
|
9
|
+
".": "./adapter/src/index.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"src",
|
|
13
|
-
"cordis.patch.yml",
|
|
12
|
+
"adapter/src",
|
|
13
|
+
"adapter/cordis.patch.yml",
|
|
14
|
+
"extension/core",
|
|
15
|
+
"service",
|
|
14
16
|
"README.md",
|
|
15
17
|
"LICENSE"
|
|
16
18
|
],
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18.0.0"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/Zoria-Lind/dsh-ecolink.git"
|
|
19
25
|
},
|
|
20
26
|
"dsh": {
|
|
21
27
|
"bundle": {
|
|
22
|
-
"patch": "./cordis.patch.yml"
|
|
28
|
+
"patch": "./adapter/cordis.patch.yml"
|
|
23
29
|
}
|
|
24
30
|
},
|
|
25
31
|
"keywords": [
|
|
@@ -29,13 +35,5 @@
|
|
|
29
35
|
"deepseek",
|
|
30
36
|
"dsh-plugin",
|
|
31
37
|
"deepseek-harness"
|
|
32
|
-
]
|
|
33
|
-
"repository": {
|
|
34
|
-
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/Zoria-Lind/dsh-ecolink.git",
|
|
36
|
-
"directory": "adapter"
|
|
37
|
-
},
|
|
38
|
-
"engines": {
|
|
39
|
-
"node": ">=18.0.0"
|
|
40
|
-
}
|
|
38
|
+
]
|
|
41
39
|
}
|
package/service/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zoria Lind
|
|
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.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# dsh-ecolink-service
|
|
2
|
+
|
|
3
|
+
dsh-ecolink 的本地记忆池服务:memory.json 的**唯一写者**,浏览器扩展经 localhost POST 推送记忆,DSH 适配层只读文件(PLAN.md §3.3 / §7)。
|
|
4
|
+
|
|
5
|
+
零依赖(node:http)、零 LLM 调用、只绑 127.0.0.1。
|
|
6
|
+
|
|
7
|
+
## 启动
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node service/server.mjs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**日常不需要手动起**:DSH 的 adapter 会探测到服务未运行并自动拉起(v1.1,`serviceAutoStart` 默认开);
|
|
14
|
+
另有登录自启任务兜底(`scripts/install-autostart.ps1`,不开 DSH 只刷网页时服务也在)。
|
|
15
|
+
手动运维:`scripts/start-service.ps1`(隐藏窗口启动)/ `scripts/stop-service.ps1`(停服务)。
|
|
16
|
+
|
|
17
|
+
默认监听 `http://127.0.0.1:17520`,记忆池落盘 `~/.dsh-memory/memory.json`(归档 `archive.json`)。
|
|
18
|
+
|
|
19
|
+
## 配置(service/config.json,或环境变量覆盖)
|
|
20
|
+
|
|
21
|
+
| 键 | 默认 | 环境变量 | 说明 |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| port | 17520 | ECOLLINK_PORT | 监听端口 |
|
|
24
|
+
| token | `""` | ECOLLINK_TOKEN | 请求头 `X-Ecolink-Token` 必须一致。**留空时启动自动生成随机 token 并写回 config.json**(需把同一 token 填进扩展 popup);**空 token 一律 401**(显式设 `ECOLLINK_TOKEN=""` 即锁死服务) |
|
|
25
|
+
| poolDir | `~/.dsh-memory` | ECOLLINK_POOL_DIR | 记忆池目录 |
|
|
26
|
+
| retentionDays | 30 | ECOLLINK_RETENTION_DAYS | 归档保留期(天) |
|
|
27
|
+
| autoConfirm | false(代码默认;随仓 config.json 已设 **true**) | ECOLLINK_AUTO_CONFIRM | **2026-09-14 设计纠正**:确认点从网页端入池闸门改到 DSH 侧(待实现),故随仓默认 **true** = `/memory/sync` 直入池;**false** = 进建议确认队列,popup 确认后才入池(队列代码保留,显式关断才走)。两者均在服务启动时读取,改动后需重启 |
|
|
28
|
+
| deepseekApiKey | `""` | ECOLLINK_DEEPSEEK_API_KEY | 备用 API 压缩用。**出于安全不再落盘 config.json**,以环境变量为默认来源(兼容读取旧字段) |
|
|
29
|
+
|
|
30
|
+
## 端点
|
|
31
|
+
|
|
32
|
+
| 方法 | 路径 | 说明 |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| POST | /memory/sync | 推送记忆:`{ session_id?, memories: [{ content, importance?, source?, pinned?, action?: 'add'\|'replace', id? }], confirm? }`;session_id 缺省/null → 共享池。autoConfirm=true(当前随仓默认)或请求体 `confirm:true`(压缩闭环/手动保存等显式动作)直接入库;**autoConfirm=false 时进建议确认队列**——响应保留 `{ ok, added:0, replaced:0, archived:0 }` 并带 `queued:N` |
|
|
35
|
+
| POST | /memory/suggest | 显式提交建议:`{ session_id?, memories: [...] }` → `{ ok, queued }` |
|
|
36
|
+
| POST | /memory/suggest/confirm | `{ id }` 确认建议 → 按 sync 语义入池 `{ ok, confirmed, added, replaced, archived }` |
|
|
37
|
+
| POST | /memory/suggest/reject | `{ id }` 拒绝(丢弃)→ `{ ok, rejected }` |
|
|
38
|
+
| POST | /memory/touch | `{ ids: [] }` 记录访问时间(选择器打分 + 归档豁免) |
|
|
39
|
+
| POST | /memory/delete | `{ ids?: [], keys?: [] }` 按 id/key 删除(压缩闭环用) |
|
|
40
|
+
| POST | /memory/session | `{ session_id, name?, delete? }` 会话命名/删除 |
|
|
41
|
+
| POST | /memory/import-dsm | `{ entries: [{ key, value, importance }] }` DeepSeek Memory(DSM)一键导入,内容去重 |
|
|
42
|
+
| POST | /memory/diag | `{ msg }` 诊断事件落 service.log(截 500 字) |
|
|
43
|
+
| POST | /memory/compress | `{ days }`(0 合法)备用 API 压缩;需 `ECOLLINK_DEEPSEEK_API_KEY` |
|
|
44
|
+
| POST | /memory/snapshot | 生成快照:`{ ok, hash }`;快照落 `<poolDir>/snapshots/<hash>.json`,latest.json 指向当前 |
|
|
45
|
+
| GET | /memory/pool | 完整记忆池 |
|
|
46
|
+
| GET | /memory/recent?n=10&session_id= | 最近 N 条(可按会话过滤) |
|
|
47
|
+
| GET | /memory/session/{id} | 指定会话池(不存在 404) |
|
|
48
|
+
| GET | /memory/snapshots | 快照清单:`{ ok, latest, snapshots: [{ hash, mtimeMs }] }`(mtime 倒序) |
|
|
49
|
+
| GET | /memory/diff?since=<hash> | 与快照对比:`{ ok, since, current, added, removed, updated }`(同时覆盖共享池与会话池;`since=latest` 取最新快照;未知快照 404) |
|
|
50
|
+
| GET | /memory/suggestions | 待确认建议清单:`{ ok, suggestions: [...] }`(落盘 `<poolDir>/suggestions.json`,与 memory.json 分离 → DSH 适配层读不到未确认条目) |
|
|
51
|
+
| GET | /memory/stale?days=5 | 超过 N 天未更新的记忆清单(压缩流程用) |
|
|
52
|
+
| GET | /memory/status | 条数/会话数/归档数/池文件 |
|
|
53
|
+
|
|
54
|
+
所有请求需带 `X-Ecolink-Token: <token>`(E8 默认安全;OPTIONS 预检除外)。
|
|
55
|
+
|
|
56
|
+
所有请求落盘日志到 `<poolDir>/service.log`(1MB 轮转),排障直接读文件。
|
|
57
|
+
|
|
58
|
+
所有响应 JSON;写操作经内部队列串行化,落盘为 tmp+rename 原子写;文件损坏时自动备份并空池重启。
|
|
59
|
+
|
|
60
|
+
## 时间戳分层(§4.2)
|
|
61
|
+
|
|
62
|
+
存储永远全量 UTC;`time_precision` 落盘时算初始值,**渲染降精度由消费者按 `precisionFor` 按龄重算**:2 小时内→分钟、48 小时内→小时、5 天内→日期、更早→不渲染。
|
|
63
|
+
|
|
64
|
+
## 归档(§4.3)
|
|
65
|
+
|
|
66
|
+
超过保留期(默认 30 天)且未被访问过的非 pinned 条目 → `archive.json`(不硬删,可恢复)。每次写入后检查。
|
|
67
|
+
|
|
68
|
+
## 测试
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
node --test service/test.mjs # 零 API:pool 逻辑 + HTTP 全端点 + 鉴权 + 持久化
|
|
72
|
+
```
|