coding-agents
vectorize-io/hindsightHindsight:会学习的 Agent 长期记忆系统,自动召回/保存、知识页、深度反思与按仓库隔离的记忆银行。

简体中文 · English
[!IMPORTANT] 中文:在消息里说
大记忆恢复术—— 让 DSH 的 agent 回想起这个工作区之前聊过的内容。English: Say
Memory unlockedin a message — let a DSH agent recall earlier conversations in this workspace.
DSH 会自动压缩长会话,被压缩掉的内容就从模型上下文里消失了。这个插件通过官方的 ctx.sessionQuery 服务,把当前工作区里更早的对话找回来。它不读磁盘上的会话文件,也不关心日志格式,所以 DSH 换存储方式不影响它。
零依赖,装上就用。包名 dsh-memory-unlocked,插件名 memory-unlocked。
要求:一个带 ctx.sessionQuery 的 DSH profile(自带的 dsh-base 就够),Node.js 18+。
已发布到 npm。可以从 DSH 插件市场安装(收录后),或用 DSH 官方插件管理按包名 dsh-memory-unlocked 安装;也可以从本仓库安装:
dsh-memory-unlocked 安装;或用插件管理安装 npm 包 dsh-memory-unlocked;或用本仓库地址安装到当前 profile。无需手工修改 profile 配置文件或安装依赖。cordis.patch.yml 负责把 memory-unlocked 插进组合。装好后直接说一句:
Memory unlocked 上次那个部署脚本放哪了?
或者让模型自己调 recall_history。
具体安装命令看你的 DSH 版本,用 DSH 自带的插件管理就行——README 里不猜命令。
recall_history:先搜一遍在当前工作区里找。每条命中都带 seq,拿去给 recall_expand 展开。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
query | string | 否 | — | 搜什么,最长 500 字符;不给就返回本会话被压缩掉的部分 |
limit | integer | 否 | 5 | 最多几条命中,最大 20 |
排序取决于检索路径,输出会说明模式:
limit 即停。未检查的候选不参与排序,因此不是全工作区的相关性排名或全量时间排名。摘要先对完整事件文本脱敏,再优先围绕关键词截取最多 240 个 UTF-16 码元,并避免切断代理对;清洗后找不到关键词时取开头。索引给出的短片段可能已失去密钥周围的判定信息,因此插件会按命中 seq 补取完整语义文本,只接受 sessionId 与 seq 都严格匹配的事件;补取失败或未能在预算内启动时,保留定位并明确「摘要省略」,不直接输出原始索引片段。可用 recall_expand 继续尝试展开,展开仍受脱敏与预算约束。
recall_expand:把原文拿回来| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
seq | integer | 否 | — | recall_history 给的位置;不给就返回被压缩掉的部分 |
sessionId | string | 否 | 当前会话 | 一般不用给;给了必须落在当前工作区内 |
before / after | integer | 否 | 3 / 3 | 前后各取几条,最多 50 |
/recall:给人用的/recall 部署脚本
效果和 recall_history 一样。不给关键词会报用法错误,不会假装成功。
消息里出现下面任意一个词,这一轮会自动回顾:
| 触发词 | 匹配 |
|---|---|
Memory unlocked | 不区分大小写 |
大记忆恢复术 | 精确匹配 |
DJYHFS | 精确匹配 |
触发词后面的文字当关键词,只取第一行 / 第一句——所以不小心粘一大段文档,也不会把整段都当成查询。没有关键词就给被压缩掉的部分。
同一轮最多注入一次。自动回顾把宿主取消信号与 8 秒等待计时器关联:取消或超时时跳过注入,并停止后续读取;结束时清理计时器和监听器。计时器不能抢占同步计算,也不能硬取消已在飞的 filterEvents 读取,因此这不是严格的 8 秒总耗时上限,也不是工具调用的统一超时承诺。
对话一长,DSH 会压缩会话。压缩之后,模型记得最近发生的事,但更早的细节——尤其是你明确让它记住的东西——就不在上下文里了。
所以这里最值得拿回来的不是「最近几条」(那几条本来就在上下文里,喂回去纯属浪费),而是被压缩掉的那部分。带关键词时,在当前工作区的历史中做有界检索;不带关键词时,返回本会话被压缩掉的部分。
配置写在 profile 里插入插件的那一行:
| 键 | 默认 | 说明 |
|---|---|---|
enabled | true | 设成 false 只关自动触发,工具和 /recall 照常可用 |
triggers | 三个内置触发词 | 最多 8 个;也接受单数 trigger: "…" |
maxInjectChars | 3000 | 单次注入的字符预算(400–20000) |
limit | 5 | 每次返回几条命中(1–20) |
before / after | 3 / 3 | 展开时前后各取几条(0–50) |
没有范围类配置。 scope、includeSubagents 写进来也不生效——范围是安全边界,不是开关。
召回只发生在当前工作区:
recall_history 没有 scope / sessionIds 参数,硬塞也会被忽略。历史文本按不可信输入处理,先在完整源字段上净化,再通过统一的 publish() 出口发布:
error 使用与可读输出相同的安全、有长度预算文本,使程序化调用者也不会取得原始错误值;这不表示该结构化原值曾被宿主写入会话或发送给模型。1,000,000 个 UTF-16 码元时整字段遮挡,不把未扫描尾部放行,也避免掩码替换膨胀导致前后两遍行为不同;高熵匹配不会在第 50 次后停止保护。不能把超限遮挡当成完整原文。scheme://user:password@host 的用户名、密码不做长度截断,密码整个遮挡;PEM 使用单向标记扫描,完整块不受固定字符窗口限制。未闭合、嵌套、类型错配或孤立结束标记会整字段遮挡,可能舍弃无关上下文。仅识别约定的大写私钥标记,损坏或未知编码不保证识别。TEST、$、< 等字符开头,不足以证明是真占位符。Windows 用户目录识别兼顾大小写与 JSON 转义反斜杠。redacted / redactLabels 统计本次处理源字段与最终出口的脱敏命中,可能包含后来被折叠、采样或结果上限省略的内容,不等于最终可见掩码数,也不代表扫描过所有历史。
脱敏是已知形态的纵深防御,不是匿名化或保险箱。例如 URL 的 user:password@host 只遮密码,用户名、主机名及正常路径可能保留;邮箱、项目名和普通业务文本也不保证移除。已知格式外的秘密仍可能漏过。插件本身不新增外部服务,但召回文本进入模型上下文后,仍会按 DSH 当前配置交给模型提供方,并可能留在会话日志中。不要把自动脱敏当成公开分享日志的许可;报告问题前请人工检查,安全问题使用私密漏洞报告,详见安全政策。
被压缩掉的内容,DSH 自己是有标记的,不用猜「跳过最近 N 条」:
filterEvents(sessionId, [{ kind: "surface", values: ["current", "shadowed", "log-only"] }])
shadowed 就是「被压缩替换掉、但还在日志里」的那些事件。
没给关键词时,插件尽量把整个区间折成一份骨架:完整事件先脱敏,再每条一行;用户消息保留 80 字,工具调用写成「名字 + 目标 + 结果摘要」,助手消息取第一行。超预算就按用户消息折叠;再不行只留用户消息、均匀抽样。首/末用户消息会尽量作为锚点:只有整段候选都被处理时才写「区间首/末条」;触到条数/字符上限或取消时只写「本次已展开的首/末条」—— 末尾窗口仍受源字符上限约束,不保证末条一定进来。想看更多,用 seq 调 recall_expand;展开并不绕过脱敏或字符预算。
骨架里会过滤掉宿主自己注入的消息(role: "user" 不等于人说的),比如压缩提示、后台任务通知、goal 轮次、团队消息等。
关键词检索仍然是「索引优先、扫描兜底」。索引命中补读最多 4 路并发,8 秒后不再启动新补读;扫描也有候选数与启动时间预算。按 seq 过滤虽然只返回选中事件的语义文本,宿主仍可能读取并重放整条会话,所以这是隐私保护换来的额外读取成本,不是恒定时间查询。filterEvents 不接收取消信号:插件可在等待返回后检查取消、停止后续工作,但无法硬中断已经开始的读取。
一次调用有一份共享预算,检索、骨架渲染、展开、发布都受它约束:工具 20 秒,自动回顾 8 秒。时间之外还有确定性上界——骨架最多处理 5000 条候选事件的前缀、150 万源字符(触界后尽力补读区间末尾 64 条,总处理量可到 5064 条;这个尾窗仍受源字符上限约束,前缀把额度用尽时一条也补不进来),展开最多 40 万源字符。为什么不能只靠时间:一次脱敏、一次宿主日志重放都不可中断,只限时间的话单篇超大文档就能吃掉整份预算。触界时正文会写明「只展开了 N/M 条」及原因;超界的条目整条跳过,不会留下未扫描的半截文本。结果产出后预算即封存,遗留任务不能再补发布或改动脱敏统计。这里的上界按被准入渲染的条数/字符计;骨架规划阶段仍会同步 filter / sort / 工具配对调用方传入的事件数组(只读元数据与长度,成本随事件数增长),同步导出(renderCompactedBody / renderExpandBody)也只执行这些确定性上限、没有 deadline/取消/让出 —— 因此不宣称「所有代码路径的硬上限」;生产路径走异步入口并共享预算。
filterEvents / readEvent 仍可能比预算长,单个超大字段的脱敏也按整段处理(超 100 万字符整段遮挡)。因此不承诺「所有调用都在 N 秒内结束」,也刻意不把墙钟阈值写成 CI 断言。filter / sort / 工具配对等全量元数据遍历仍同步执行,成本随宿主已返回的事件数增长,不计入 150 万源字符额度;同步导出只保留确定性条数/字符上限,不接 deadline、取消或让出。这里没有「所有路径的 CPU/内存硬上限」承诺。user:password@host 密码规则。truncated 仅表示输出预算截断;安全遮挡以文本提示和脱敏计数说明。# 在包根目录
node --test
Node 18+、ESM、无依赖、无构建。测试用 node:test;别写成 node --test test/,Node 24 上会报 Cannot find module。
代码都在 lib/:
| 文件 | 干什么 |
|---|---|
index.js | 接宿主:两个工具、一个命令、pre-step 钩子,以及范围解析 |
recall.js | 纯函数:配置、校验、请求构造、触发词、渲染 |
search.js | 按工作区搜索(索引优先、扫描兜底)与原文展开 |
redact.js | 凭据脱敏 |
publish.js | 唯一的发布出口 |
normalize.js | 宿主返回值形状归一化 |
budget.js | 单次调用的共享时限与确定性渲染上限 |
@deepseek-ai/* 0.2.x 系列)与 Node 24 上开发并验证;插件零运行时依赖,Node 18+ 可运行。node --test 覆盖参数校验、范围隔离、脱敏、预算与取消、工具接线;合成基准只测插件在假内存服务上的 CPU 与调度,不代表宿主日志 I/O 或 FTS 性能。node --test
这里只读研究了以下固定版本的实际源码,借鉴的是渐进检索、输出预算、失败闭合和范围隔离等抽象实践;没有复制第三方源码,也没有引入它们的依赖或服务:
claude-mem,提交 9e4f313a89236f7fb593a639a77ee02e21e94469:渐进检索与安全错误输出、完整字段脱敏及超限遮挡;该版本许可证为 Apache-2.0。
mem0,提交 0724291bd864cf807e88ee8981c8226644e8fb42:身份范围构造(_strip_identity_keys / _build_filters_and_metadata / _build_session_scope)及范围键碰撞测试;该版本许可证为 Apache-2.0。
Gitleaks:Finding.Redact 同时净化结构化字段,提交 b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b(MIT)。
Secretlint:消息与结构化数据同步净化,提交 97eb5fb97ec47a64d49e8843b7727b280221d53f(MIT)。
不采用向量库、外部服务、模型生成记忆、多租户游标或跨工作区搜索。本插件仍为纯 JavaScript、零运行时依赖,许可不变。源码核验不等于运行过第三方项目或认可其所有安全路径。
CC BY-NC-SA 4.0:可以自由阅读、使用、修改、非商业分享,但要署名、修改版保持同一协议,不能商用。商业用途需要单独授权。
署名见 NOTICE;官方简体中文译本见 LICENSE.zh,仅供参考,以 LICENSE 的英文原文为准。
简体中文 · English
DSH compacts long conversations, and whatever compaction folds away disappears from the model's context. This plugin brings earlier conversations back — scoped to the current workspace — through the official ctx.sessionQuery service. It never reads session files from disk and doesn't care how they are stored, so a change in DSH's storage format doesn't break it.
Zero dependencies. Package dsh-memory-unlocked, plugin id memory-unlocked.
Requirements: a DSH profile with ctx.sessionQuery (the shipped dsh-base is enough), Node.js 18+.
The package is published on npm. Install it from the DSH plugin market (once listed), with DSH's official plugin manager by package name dsh-memory-unlocked, or from this repository:
dsh-memory-unlocked with the plugin manager; or use the plugin manager with this repository URL. Do not edit profile files or install dependencies by hand.cordis.patch.yml inserts the memory-unlocked row.Then just say:
Memory unlocked where did we put that deploy script?
or let the model call recall_history.
The exact install command depends on your DSH version. Use DSH's own plugin manager; this README doesn't guess it.
recall_history — search firstFinds matches in the current workspace. Every hit carries a seq you can hand to recall_expand.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
query | string | no | — | What to search for, up to 500 characters; omit it to get what compaction removed from the current session |
limit | integer | no | 5 | Maximum number of hits, up to 20 |
Ordering depends on the retrieval path, which is reported in the output:
limit is met. Uninspected candidates never enter the ranking, so this is neither a workspace-wide relevance ranking nor an exhaustive time ranking.Snippets are drawn around the query only after the complete event text has been redacted, with a maximum of 240 UTF-16 code units and without splitting surrogate pairs. If the query cannot be located in the sanitized text, the snippet starts at the beginning. An index snippet may have already lost the surrounding context needed to recognize a secret, so the plugin retrieves the complete semantic text by the hit's seq and accepts only an exact sessionId and seq match. If that read fails or cannot start within budget, it keeps the location and explicitly omits the snippet instead of publishing the raw index fragment. You can still try recall_expand; expansion remains subject to redaction and output limits.
recall_expand — get the original text| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
seq | integer | no | — | The position reported by recall_history; omit it to get the compaction-removed part |
sessionId | string | no | current session | Usually unnecessary; if given it must fall inside the current workspace |
before / after | integer | no | 3 / 3 | Events to take before / after, up to 50 each |
/recall — for humans/recall deploy script
Same as recall_history. With no keyword it returns a usage error instead of pretending to succeed.
If a message contains any of the following, the plugin recalls automatically for that turn:
| Phrase | Matching |
|---|---|
Memory unlocked | case-insensitive |
大记忆恢复术 | literal |
DJYHFS | literal |
Text after the phrase becomes the keyword, and only the first line / first sentence is used — so pasting a long document by accident won't turn all of it into a query. With no keyword you get the compaction-removed content.
At most one injection per turn. Automatic recall combines the host's cancellation signal with an 8-second wait timer: cancellation or timeout skips injection and stops subsequent reads, with timer and listener cleanup on exit. The timer cannot preempt synchronous computation or forcibly cancel an in-flight filterEvents read. It is therefore not a strict 8-second total-runtime bound or a universal timeout for tool calls.
Conversations get long, so DSH compacts them. After that the model remembers the recent parts, but older details — especially the things you explicitly asked it to remember — are gone from the context.
So the valuable thing to recover is not "the last N messages" (those are still in context, and feeding them back would be a waste) but what compaction removed. A keyword starts a bounded search within the current workspace's history; without one, it returns the compaction-removed part of the current session.
Configure the row that inserts the plugin in the profile:
| Key | Default | Notes |
|---|---|---|
enabled | true | Set to false to turn off automatic recall only; the tools and /recall keep working |
triggers | the three built-ins | Up to 8; a single string trigger: "…" is also accepted |
maxInjectChars | 3000 | Character budget per injection (400–20000) |
limit | 5 | Hits returned per recall (1–20) |
before / after | 3 / 3 | Events taken on each side when expanding (0–50) |
There are no scope options. scope and includeSubagents do nothing here — scope is a safety boundary, not a switch.
Recall only happens inside the current workspace:
recall_history has no scope / sessionIds parameters; even if one is sent, it is ignored.History is treated as untrusted input. Complete source fields are sanitized before shortening, then published through one publish() exit:
error uses the same safe, length-bounded text as the readable output, so programmatic callers do not receive the original error value either. This does not imply that the host logged or sent the raw structured value to the model.1,000,000 UTF-16 code units is masked as a whole rather than leaking an unscanned tail; this also prevents replacement expansion from changing the outcome on a second pass. High-entropy protection does not stop after the 50th match. An oversize marker is not a complete copy of the original.scheme://user:password@host matching does not truncate usernames or passwords by length, and the complete password is masked. A forward PEM-marker scan covers complete blocks without a fixed character window. Unclosed, nested, mismatched or orphan end markers mask the entire field, sacrificing unrelated context when necessary. Only recognized uppercase private-key markers are covered; damaged markers or unknown encodings are not guaranteed.TEST, $ or < prefix alone is not enough. Windows user-directory matching handles case differences and JSON-escaped backslashes.redacted / redactLabels count matches in source fields processed during this call and at the final exit. They can include content later omitted by folding, sampling or result limits. They do not count just the visible masks, and do not imply that all history was scanned.
Redaction is known-pattern defence in depth, not anonymization or a vault. In URL userinfo such as user:password@host, only the password is masked; usernames, hostnames and ordinary paths may remain. Emails, project names and ordinary business text are not guaranteed to be removed. Unknown secret formats can still leak. The plugin adds no external service, but recalled text becomes model context and can reach the configured model provider and session logs through DSH. Do not treat automatic redaction as permission to publish logs. Review reports manually, use private vulnerability reporting for security issues, and read the security policy.
Compaction-removed events are already marked by DSH, so there is no need to guess "skip the last N":
filterEvents(sessionId, [{ kind: "surface", values: ["current", "shadowed", "log-only"] }])
shadowed is exactly "replaced by compaction but still in the log".
Without a keyword, the plugin tries to fold the whole range into a skeleton: redact complete events first, then render one line per event; user messages quoted to 80 characters; each tool call as its name + target + a result excerpt; assistant messages shortened to their first line. Over budget it folds by user message; if that still doesn't fit it keeps user messages only, then samples evenly. The first and last user messages are used as anchors where available: the body says range first/last only when the whole candidate range was processed; on a count/character/cancellation bound it says first/last expanded this round, because the tail window is still subject to the source-character cap and cannot guarantee the closing anchor. Request more with recall_expand by seq; expansion does not bypass redaction or character budgets.
Host-injected messages are filtered out of the skeleton (role: "user" doesn't mean a human wrote it), such as compaction notices, background-job notices, goal rounds and team messages.
Keyword retrieval remains index-first with a scan fallback. Index snippet hydration runs at most 4 reads concurrently and stops starting new reads after 8 seconds; scanning also has candidate and start-time budgets. Filtering by seq returns only the selected semantic event, but the host may still read and replay the complete session. That extra work is a privacy trade-off, not a constant-time lookup. filterEvents does not accept a cancellation signal: the plugin checks cancellation after awaiting it and stops subsequent work, but cannot forcibly interrupt a read already in flight.
One call shares a single budget across retrieval, skeleton rendering, expansion and publication: 20 seconds for tools, 8 seconds for automatic recall. Besides time there are deterministic bounds — the skeleton processes at most a 5,000-candidate prefix and 1,500,000 source characters (after hitting a bound it tries the last 64 candidates, so up to 5,064 candidates total; that window is still subject to the source-character cap, so it may admit none), and expansion is capped at 400,000 source characters. Time alone is not enough: a single redaction pass or one host log replay cannot be interrupted, so one huge field could consume the whole budget. When a bound is reached the body states how many candidates it actually rendered and why; candidates beyond it are skipped whole, never partially printed before redaction. The budget is sealed once the result exists, so late tasks can neither publish nor change redaction statistics. These bounds count admitted rendering work: planning still scans, sorts and pairs the whole event array the host returned (metadata and lengths only) synchronously, so that cost grows with the event count and is not charged to the character cap. The synchronous exports (renderCompactedBody / renderExpandBody) enforce only these deterministic caps and have no deadline, cancellation or yielding; production uses the async entries. This is therefore not a hard bound on every code path.
filterEvents / readEvent may outlast the budget, and a single oversize field is redacted as a whole (anything above 1,000,000 characters is masked entirely). The plugin therefore does not promise "every call finishes within N seconds", and deliberately does not assert wall-clock thresholds in CI.