solopup-memory 0.21.0-beta.1 → 0.21.0-beta.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0-beta.3 - private-memory exposure reduction
4
+
5
+ - Exclude recognized personal profiles, birth details, health, intimate and private contact information from automatic index projection and unrelated capture/review context, including private source quotations.
6
+ - Hide these records from default current/history memory searches; explicit includePrivate=true enables on-demand retrieval and its normal log/model exposure.
7
+ - Preserve stored records, source identities and append-only session logs. Forget results now distinguish index withdrawal from historical erasure.
8
+ - This is a conservative content classifier, not exhaustive PII detection, encryption, a file-access boundary, or remote deletion. Raw Markdown, source ledgers and existing archives still require separate handling.
9
+
10
+ ## 0.21.0-beta.2 — explicit memory workflow
11
+
12
+ - Move model-facing memory instructions into a separate protocol context, available with an empty index and reporting scoped write-tool availability.
13
+ - Direct explicit durable-memory requests to the real tool in the same turn; distinguish recorded/duplicate, pending and rejected results. Personal statements remain claims; guesses are not promoted.
14
+ - Bind short automatic-capture source labels back to original message identities before the unchanged role and quotation checks.
15
+ - Preserve source validation, managed withdrawal, storage schemas and historical beta.1 evidence. This remains a public beta.
16
+
3
17
  ## 0.21.0-beta.1 — SoloPup Memory public beta
4
18
 
5
19
  - Publish the dev.45-derived plugin as SoloPup Memory (`solopup-memory`), with localized DSH metadata, executable ESM and a Git-compatible bundle.
package/README.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Remember what matters. Keep up when you change your mind.** · [中文](README.md)
4
4
 
5
- Source-linked, layered **text memory** for DeepSeek Harness. **0.21.0-beta.1 is a public beta, not a stable release.** See [RELEASE.md](RELEASE.md) for tested scope and known gaps.
5
+ Source-linked, layered **text memory** for DeepSeek Harness. **0.21.0-beta.3 is a public beta, not a stable release.** See [RELEASE.md](RELEASE.md) for tested scope and known gaps.
6
6
 
7
7
  - Separate global preferences from project decisions.
8
8
  - Keep original owner-message citations, conditions, exceptions and optional expiry.
@@ -14,10 +14,10 @@ Source-linked, layered **text memory** for DeepSeek Harness. **0.21.0-beta.1 is
14
14
  ## Install in DSH
15
15
 
16
16
  ```sh
17
- dsh plugin --profile desktop add github:OakcoderX/solopup-memory#v0.21.0-beta.1 --ignore-scripts
17
+ dsh plugin --profile desktop add github:OakcoderX/solopup-memory#v0.21.0-beta.3 --ignore-scripts
18
18
  ```
19
19
 
20
- The release page supplies the full commit SHA for an immutable pin. This package ships executable ESM and has no dependency installation or build scripts. Alternatively download its release tarball and install the local file with `dsh plugin --profile desktop add /absolute/path/to/solopup-memory-0.21.0-beta.1.tgz --offline --ignore-scripts`.
20
+ This pins the privacy update; the release page supplies a full commit SHA for an immutable Git pin. npm availability may follow the GitHub release: check the registry and release notes before installing `solopup-memory@0.21.0-beta.3`. The npm beta tag may still point to an earlier release. This package ships executable ESM and has no dependency installation or build scripts. Alternatively download its release tarball and install the local file with `dsh plugin --profile desktop add /absolute/path/to/solopup-memory-0.21.0-beta.3.tgz --offline --ignore-scripts`.
21
21
 
22
22
  Restart the profile and use `memory_status`. Existing legacy stores may require explicit local review. Do not delete control files to bypass that review. If upgrading from `dsh-memory`, create a full private backup, stop the hosts using that data, remove the old package, then install SoloPup Memory. Do not run both packages together. The data location and legacy format identifiers are intentionally retained.
23
23
 
@@ -58,3 +58,7 @@ node accept.mjs --profile desktop
58
58
  `verify` additionally requires a locally installed DSH and runs isolated load and mechanical gates. `accept` reads the selected profile and the live version marker; it does not certify natural-dialogue quality. See [SECURITY.md](SECURITY.md), [COMPATIBILITY.md](COMPATIBILITY.md) and [CHANGELOG.md](CHANGELOG.md).
59
59
 
60
60
  MIT License. Copyright © 2026 oakfilm.
61
+
62
+ Explicit requests to remember durable information are directed to the native `memory_write` tool in the same turn. A separate protocol context is available even when the memory store is empty. Personal self-reports remain claims; assistant guesses are not confirmed facts. Automatic capture uses short, code-bound source labels before the original identity and quote checks. This remains a public beta, and instructions do not guarantee model compliance.
63
+
64
+ Recognized private content is excluded from automatic memory projection and unrelated saved-memory context in capture/admission review. Current and historical search hide it by default; includePrivate=true enables explicit on-demand retrieval. These are conservative content rules, not exhaustive classification or a filesystem security boundary. Raw Markdown and provenance remain local plaintext. Explicit retrieval still enters session history and model requests. Existing session archives, exports, backups and already-sent data are not erased.
package/README.md CHANGED
@@ -2,10 +2,16 @@
2
2
 
3
3
  **记住重要的事,跟上你的改变。** · [English](README.en.md)
4
4
 
5
- 当前公开版本为 **0.21.0-beta.1**,是文字记忆测试版,尚未通过稳定版验收。已验证范围和已知问题见 [RELEASE.md](RELEASE.md)。
5
+ 当前公开版本为 **0.21.0-beta.3**,是文字记忆测试版,尚未通过稳定版验收。已验证范围和已知问题见 [RELEASE.md](RELEASE.md)。
6
6
 
7
7
  把用户明确表达的长期偏好、项目决定、可查证线索和暂定信念保存下来,在后续对话中按项目与适用条件找回。首次公开版本只处理文字。
8
8
 
9
+ ## 个人资料与按需读取
10
+
11
+ 已识别的生日、性别、健康、亲密关系和私人联系方式默认不进入自动索引投影,也不作为无关后台整理或写入审查的旧记忆发送。普通 `memory_search` 默认隐藏这些内容;用户询问相关资料时,才明确使用 `includePrivate: true`。主题文件含有未分段的敏感内容时,普通搜索会保守跳过整个主题文件,安全的索引摘要仍可检索。
12
+
13
+ 这是内容规则提供的降载保护,不是完整的隐私分类器。未识别的改写仍可能漏过;文件工具仍能读原始文件,按需读取结果也会进入当前会话日志和模型请求。此版本没有把原始 `MEMORY.md` 改成无明文的指针库,没有擦除既有日志、缓存、备份或已发送数据。`memory_forget` 表示停止使用索引条目,不表示彻底删除个人信息。
14
+
9
15
  ## 工作过程
10
16
 
11
17
  1. 从本会话已接收的用户消息提出候选,并绑定原话引用。
@@ -13,6 +19,8 @@
13
19
  3. 通过检查后才发布到有效记忆。明确拒绝的不写入;无法判断的新候选可以留在不自动注入的待审区。未通过的替换请求不降格成普通追加。
14
20
  4. 后续对话自动得到受预算限制的索引;检索同时查当前项目与全局库,结果标明归属。其他项目不会因此自动变成当前项目的记忆。
15
21
 
22
+ 用户明确要求记住持久信息时,插件指令要求本轮使用正式 `memory_write`,等待实际结果再确认;不等待后台整理。本人陈述的个人资料按未独立核实的 `claim` 保存,助手猜测不能冒充本人确认。模型仍可能不遵循指令,因此需要实际调用与新会话验收。
23
+
16
24
  默认每四轮尝试一次后台整理。主动 `memory_write` 也经过来源审查。`pending-review`、`rejected`、`not-applied` 都不表示已经记住。
17
25
 
18
26
  ## 记忆的层次
@@ -40,10 +48,10 @@ Markdown 索引和主题正文便于阅读;来源、候选、历史与调用
40
48
  使用 DeepSeek Harness 官方插件管理器安装。代码为可直接执行的 ESM,无安装时构建脚本,也不需要 npm 账户。以下以 desktop profile 为例。
41
49
 
42
50
  ```sh
43
- dsh plugin --profile desktop add github:OakcoderX/solopup-memory#v0.21.0-beta.1 --ignore-scripts
51
+ dsh plugin --profile desktop add github:OakcoderX/solopup-memory#v0.21.0-beta.3 --ignore-scripts
44
52
  ```
45
53
 
46
- 标签便于阅读;需要不可变来源时使用发行页给出的完整提交 SHA 替换标签。也可下载发行页的 `.tgz`,使用 `dsh plugin --profile desktop add /absolute/path/to/solopup-memory-0.21.0-beta.1.tgz --offline --ignore-scripts`。
54
+ 以上固定本次修复版本;需要不可变 Git 来源时,用发行页的完整提交 SHA 替换标签。npm 发布可能晚于 GitHub,请先核对注册表和发行说明,再使用 `dsh plugin --profile desktop add solopup-memory@0.21.0-beta.3 --ignore-scripts`;旧的 `beta` 标签可能仍指向先前版本。也可下载发行页的 `.tgz`,使用 `dsh plugin --profile desktop add /absolute/path/to/solopup-memory-0.21.0-beta.3.tgz --offline --ignore-scripts`。
47
55
 
48
56
  已安装旧名 `dsh-memory` 时,先完成完整备份、关闭使用该库的宿主,用官方管理器移除旧包,再安装新包;不要同时启用两个记忆插件。新名字继续使用原记忆库和控制格式。
49
57
 
@@ -100,3 +108,5 @@ node accept.mjs --profile desktop
100
108
  详细安全边界见 [SECURITY.md](SECURITY.md),宿主兼容范围见 [COMPATIBILITY.md](COMPATIBILITY.md)。
101
109
 
102
110
  用户单条消息的完整来源窗口目前上限为 2000 字符。超长消息不从前半段建立记忆;自动整理显示来源不完整并暂不执行,主动写入也拒绝使用仍在当前核对窗口内的截断来源。可在新的短消息会话中完整重述需长期保留的约定。这个修复不会自动纠正旧版已经保存的解释。
111
+
112
+ 市场源请求失败时,按 [DSH 官方代理手册](https://deepseek-harness.github.io/deepseek-harness/guide/network-proxy) 配置 DSH 自身的代理环境;DSH 不自动读取 macOS 系统代理。
package/RELEASE.md CHANGED
@@ -1,3 +1,30 @@
1
+ # 0.21.0-beta.3 - private-memory exposure reduction
2
+
3
+ Public beta. Conservative known-category filtering, not full privacy isolation or stable semantic acceptance.
4
+
5
+ Recognized private summaries, details and quoted evidence are excluded from automatic memory projection and unrelated saved-memory context for capture and admission review. Default current/history searches hide this content; includePrivate=true allows on-demand retrieval. Existing records and original session logs are preserved. File tools can still read raw data, classifier coverage is incomplete, and on-demand output still enters logs and model requests. Raw-index pointer migration and physical history cleanup are not part of this release.
6
+
7
+ Prior beta.2 evidence below is historical and not automatically a beta.3 result.
8
+
9
+ ---
10
+
11
+ # 0.21.0-beta.2 — explicit memory workflow update
12
+
13
+ Public beta, not stable semantic acceptance. The beta.1 evidence in the historical section below is not automatically a beta.2 result.
14
+
15
+ - Add a separate, scoped protocol context directing explicit remember requests to the real memory tool in the same turn, with truthful success/pending/failure reporting. It is available even with an empty index. Model compliance is still checked through actual calls, not assumed from instructions.
16
+ - Keep personal self-reports as claims and assistant guesses separate; do not use shell imports or fabricated provenance to bypass native validation.
17
+ - Shorten automatic-capture message labels with a request-local code binding. Original role, quote, scope and source withdrawal checks still apply.
18
+ - No data schema change; existing records and provenance are preserved. Existing semantic and platform gaps remain open.
19
+
20
+ Verified in the daily macOS DSH host using the actual DeepSeek account and Standard preset: an ordinary remember request invoked the native memory tool and was recorded in its first turn; a fresh conversation without inherited history recovered the project fact. A separate fresh conversation recovered an existing global personal fact. One real extraction preserved the original user source through the short labels and omitted an assistant-invented place. Synthetic test sources were withdrawn and original Markdown remained unchanged.
21
+
22
+ The first test attempt exhausted its own call cap before the global-personal query could answer; that attempt is retained as incomplete, with a separately identified successful follow-up. These bounded cases do not establish general semantic accuracy or stable-release readiness.
23
+
24
+ ---
25
+
26
+ ## Historical beta.1 release evidence
27
+
1
28
  # 0.21.0-beta.1 — public beta / 公开测试版
2
29
 
3
30
  SoloPup Memory is the public product name of the former local `dsh-memory` plugin. This release derives from the privately tested dev.45 implementation. It is **not stable** and does not claim superiority over other memory systems.
@@ -23,4 +50,4 @@ Stable release still requires prospective semantic acceptance, handling/document
23
50
 
24
51
  - [Package and install a plugin](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md): the package declares `dsh.bundle.patch`; direct Git and archive installation are supported.
25
52
  - [Plugin markets](https://deepseek-harness.github.io/deepseek-harness/en/guide/plugin-market): publish a format-1 HTTPS index with a Git version pinned to a full commit; users add the source themselves.
26
- - The official [DSH site](https://www.deepseek.com/en/harness/) links its community plugin entry to the GitHub `dsh-plugin` topic. The repository uses that topic. No central listing approval or npm publication is claimed.
53
+ - The official [DSH site](https://www.deepseek.com/en/harness/) links its community plugin entry to the GitHub `dsh-plugin` topic. The repository uses that topic. No central listing approval is claimed. The same tested beta archive was subsequently published to npm as `solopup-memory@0.21.0-beta.1` with the `beta` tag; the market index now installs this exact npm version.
package/SECURITY.md CHANGED
@@ -71,3 +71,7 @@
71
71
  ## 报告问题
72
72
 
73
73
  私密报告:在本机开 issue 或直接告知维护者,不要在公开渠道贴出可利用细节。
74
+
75
+ ## beta.3 privacy boundary
76
+
77
+ Recognized private records, including their quoted evidence, are omitted from automatic projection and unrelated saved-memory contexts. The classifier is heuristic and incomplete; explicit [private] or [private:category] text is conservatively treated as on-demand. Searches default to excluding recognized private content; includePrivate=true is a disclosure control, not a user authentication boundary. Other tools can read the raw files. Private facts explicitly present in the current conversation or prior ordinary messages remain in that conversation. Existing append-only archives, caches, exports, backups and provider-side copies are not purged. Never claim that hiding an index or memory_forget erases those copies. Physical raw-index migration and reviewed local erasure are separate work.
package/capture-llm.mjs CHANGED
@@ -15,6 +15,7 @@ import {createHash} from 'node:crypto'
15
15
  import {TEXT_MEMORY_POLICY} from './memory-policy.mjs'
16
16
  import {VALIDATION_CONTRACT} from './capture-validation-contract.mjs'
17
17
  import {CaptureSubmission,CAPTURE_SUBMISSION_TOOL} from './capture-submission.mjs'
18
+ import {captureSourceAliases} from './capture-source-aliases.mjs'
18
19
  import {parseCaptureBatch,CAPTURE_RESPONSE_BYTES} from './capture-diagnostics.mjs'
19
20
  import { escapeFraming, findSecrets, normalizeText, redactSecrets } from './security.mjs'
20
21
 
@@ -52,6 +53,7 @@ const SYSTEM = [
52
53
  '- 新观测明确支持或反驳已有主张时,使用meaning.kind=episode或claim并给relations:[{target:完整recordId,stance:supports或contradicts}]。只能引用下方同scope现行目标清单,目标不明确则省略,不猜ID;不把旧摘要复述当新实验,不自动将信念取代为已证事实,明确更正个人决定仍用supersedes。关系是陈述映射,不是独立查证。',
53
54
  '- 竞争解释用 meaning.alternatives,可能推翻判断的观测用 meaning.falsifiedBy(各最多3条,仅有依据时给)。这些是待检验内容,不是已经查证的反证。不输出 verified、confidence、授权等字段;',
54
55
  '- 每条evidence最少1条、最多3条不同用户消息的引用,每条含真实messageId和8到500字的连续原话quote。合并同一信念时选择支撑主张、竞争解释和反证条件的最多3个直接来源,不为重复说明增加第4个引用;超过3条会整条拒绝,不会自动截掉证据。不得引用助手消息作用户证据。',
56
+ '- 对话中的id是本次请求绑定的短来源标签(如source1);messageId只逐字复制对应用户消息的id,不猜会话编号、UUID或事件序号。代码会还原真实来源并继续核对角色和原话。',
55
57
  '- relations.target仅能逐字复制现行目标清单提供的64位十六进制recordId,不是sessionId、messageId或8位索引id。目标清单为空时不得填target;不要为本批尚未发布的新主张伪造身份。',
56
58
  '- explicit 表示用户明确陈述了本人长期身份、署名或固定工作关系,或明确表达了持久偏好、纠正、决定、价值、本人持有的暂定信念,或明确要求长期保留的未核实主张;不表示外部命题已被证明。用户明确说“我长期持有但可推翻的假设”应记录为explicit且meaning.kind=belief,仍是暂定信念。模型从行为猜测偏好、补猜用户未说的原因仍必须标inferred,仅进候选;',
57
59
  '- 每条一句话,不超过 120 字,写清「做什么/不做什么」,不要写「用户希望」这类空话;',
@@ -218,7 +220,10 @@ export function selectCaptureControls(controls, modelInfo) {
218
220
  */
219
221
  export async function extractWithLlm(options) {
220
222
  if(Array.isArray(options.messages)&&options.messages.some(m=>m?.role==='user'&&m.sourceComplete===false))throw Error('capture-input-source-incomplete')
221
- return runCaptureModel(options,{system:SYSTEM,tool:CAPTURE_SUBMISSION_TOOL,prepare:(messages,indexLines,maxEntries,maxInputBytes,targets,records)=>({text:buildUserPrompt(messages,indexLines,maxEntries,maxInputBytes,targets,records)}),submission:()=>new CaptureSubmission()})
223
+ return runCaptureModel(options,{system:SYSTEM,tool:CAPTURE_SUBMISSION_TOOL,prepare:(messages,indexLines,maxEntries,maxInputBytes,targets,records)=>{
224
+ const sources=captureSourceAliases(messages)
225
+ return {text:buildUserPrompt(sources.messages,indexLines,maxEntries,maxInputBytes,targets,records),restore:sources.restore}
226
+ },submission:()=>new CaptureSubmission(),finalize:(batch,prepared)=>prepared.restore(batch)})
222
227
  }
223
228
  export async function runCaptureModel(options,task) {
224
229
  const { llm, sessionId, messages, indexLines, maxEntries = 4, maxTokens = 800, maxInputBytes, timeoutMs = 25_000, route, signal, relationTargets=[],structuredRecords=[] } = options
@@ -276,7 +281,8 @@ export async function runCaptureModel(options,task) {
276
281
  }
277
282
  controller.signal.throwIfAborted()
278
283
  if (!submission.sawTool&&text.trim().length === 0) throw new Error('capture-empty-response')
279
- const batch=submission.batch(text,maxEntries)
284
+ const parsed=submission.batch(text,maxEntries)
285
+ const batch=task.finalize?task.finalize(parsed,prepared):parsed
280
286
  return options.includeDiagnostics===true?batch:batch.candidates
281
287
  }
282
288
  try {
@@ -0,0 +1,20 @@
1
+ /** Short request-local labels, bound by code to the original message identity. */
2
+ export function captureSourceAliases(messages = []) {
3
+ const originals = new Set(messages.map(message => message?.id).filter(id => typeof id === 'string'))
4
+ const ids = new Map()
5
+ const mapped = messages.map((message, index) => {
6
+ if (!message || typeof message !== 'object' || typeof message.id !== 'string') return message
7
+ let alias = `source${index + 1}`
8
+ while (originals.has(alias) || ids.has(alias)) alias = '_' + alias
9
+ ids.set(alias, message.id)
10
+ return { ...message, id: alias }
11
+ })
12
+ return {
13
+ messages: mapped,
14
+ restore(batch) {
15
+ return { ...batch, candidates: batch.candidates.map(candidate => ({ ...candidate,
16
+ evidence: candidate.evidence?.map(ref => ({ ...ref, messageId: ids.get(ref.messageId) ?? ref.messageId })),
17
+ })) }
18
+ },
19
+ }
20
+ }
package/index.mjs CHANGED
@@ -4,6 +4,8 @@ import {assertRestorationInactive} from './restoration-lock.mjs'
4
4
  export {initializeManagedMemory,managedMemoryStatus} from './managed-access.mjs'
5
5
  import {createObservationTracker,observationRoots,OBSERVATION_REFERENCE_SCHEMA,OBSERVATION_LIST_SCHEMA} from './tool-observations.mjs'
6
6
  import {createToolInputTracker} from './tool-inputs.mjs'
7
+ import {memoryProtocol} from './memory-protocol.mjs'
8
+ import {privateMemory,publicMemoryRecords,publicMemoryIndex,PRIVATE_MEMORY_NOTICE} from './privacy.mjs'
7
9
  import {nativeOwnerEvidence,validateNativeWrite} from './native-write-validation.mjs'
8
10
  import {toolProposal} from './source-record.mjs'
9
11
  import {revisionMode,prepareValueRevision,inheritedRoots} from './field-lineage.mjs'
@@ -290,8 +292,9 @@ function renderScopeLocal(scope, dir) {
290
292
  let flagged = 0
291
293
  const kept = []
292
294
  const policy = withdrawalPolicy(dir)
293
- const typed=activeMeanings(candidateSnapshot(dir,scope).records),assessmentRecords=effectiveSourceRecords(dir,scope)
295
+ const typed=activeMeanings(candidateSnapshot(dir,scope).records),assessmentRecords=publicMemoryRecords(effectiveSourceRecords(dir,scope))
294
296
  for (const line of filterWithdrawn(index.text, policy, true).split('\n')) {
297
+ if (privateMemory(meaningForLine(typed,line),line)) continue
295
298
  if (line.trim().startsWith('- [')) {
296
299
  if (isExpired(line, now)) {
297
300
  hidden += 1
@@ -1047,16 +1050,19 @@ function writeMemoryLocal({ type, dir, line, detail, summary, expires, supersede
1047
1050
  evidence: provenance?.evidence,
1048
1051
  basis: provenance?.basis,
1049
1052
  })
1053
+ const privateLines=new Set(liveSourceRecords(dir,scope).filter(privateMemory).map(record=>record.indexLine).filter(Boolean))
1054
+ const hiddenLine=line=>privateLines.has(line)||privateMemory(line)
1055
+ const visibleWarnings=warnings.filter(w=>!privateMemory(w)&&![...privateLines].some(line=>w.includes(summaryOf(line))))
1050
1056
  return {
1051
1057
  path: indexFile,
1052
1058
  indexLine: cleanLine,
1053
1059
  duplicate: false,
1054
1060
  lineCount: next.split(/\r?\n/).length,
1055
1061
  ...(detailPath === undefined ? {} : { detailPath }),
1056
- ...(supersededLine === undefined ? {} : { superseded: supersededLine }),
1062
+ ...(supersededLine === undefined||hiddenLine(supersededLine) ? {} : { superseded: supersededLine }),
1057
1063
  id: newId,
1058
- related,
1059
- ...(warnings.length === 0 ? {} : { warning: warnings.join(';') }),
1064
+ related:related.filter(edge=>!hiddenLine(edge.line)),
1065
+ ...(visibleWarnings.length === 0 ? {} : { warning: visibleWarnings.join(';') }),
1060
1066
  }
1061
1067
  }
1062
1068
  return lockToken === REVIEW_LOCK ? execute() : withLock(dir, execute)
@@ -1244,7 +1250,7 @@ function effectiveSourceRecords(dir,scope,indexText,includeUntyped=false){
1244
1250
  }
1245
1251
 
1246
1252
  /** Current index-only legacy entries are visible as unclassified data, never inherited sources. */
1247
- function validationCurrentRecords(dir,scope,records){
1253
+ function validationCurrentRecords(dir,scope,records,privateTarget){
1248
1254
  const policy=withdrawalPolicy(dir),index=filterWithdrawn(readTextSafe(join(dir,'MEMORY.md'))??'',policy,true)
1249
1255
  const known=new Set(records.map(r=>r.indexLine??indexLine(r.type,r.summary,r.expires,r.detail!==undefined))),rows=[...records]
1250
1256
  for(const line of new Set(index.split(/\r?\n/).map(line=>line.trim()))){
@@ -1252,7 +1258,7 @@ function validationCurrentRecords(dir,scope,records){
1252
1258
  const type=/^- \[(user|feedback|project|reference)\] /.exec(line)?.[1];if(!type)continue
1253
1259
  rows.push({id:createHash('sha256').update('memory-unclassified-current-index:'+scope+':'+line).digest('hex'),status:'recorded',scope,type,summary:summaryOf(line),indexLine:line,legacyIndexOnly:true,expires:/ \(exp:(\d{4}-\d{2}-\d{2})\)$/.exec(line)?.[1]})
1254
1260
  }
1255
- return rows
1261
+ return rows.filter(row=>!privateMemory(row)||typeof privateTarget==='string'&&privateTarget.length>0&&row.indexLine?.includes(privateTarget))
1256
1262
  }
1257
1263
 
1258
1264
  function publishIndexChange(dir,before,after,reason){
@@ -1268,6 +1274,7 @@ function searchHistoricalStores(stores,needle,options,context,task,limit,mode){
1268
1274
  const policy=withdrawalPolicy(dir),index=filterWithdrawn(readTextSafe(join(dir,'MEMORY.md'))??'',policy,true)
1269
1275
  const result=historyRows(dir,{scope,index,revoked:policy.revoked,atSequence:options.atSequence,asOf:options.asOf,expiredAt:isExpired});coverage.push(result.coverage)
1270
1276
  for(const {row,line,history}of result.rows){
1277
+ if(options.includePrivate!==true&&privateMemory(row.source,row.line))continue
1271
1278
  const interpretation=row.source.meaning?meaningView(row.source,context,task):undefined
1272
1279
  if(interpretation?.applicability==='not-applicable')continue
1273
1280
  if(interpretation)interpretation.advice={action:'continue',reason:'historical-record-is-not-a-current-instruction',authority:'none'}
@@ -1297,6 +1304,7 @@ function searchScopedStoresLocal(stores, query, options = {}) {
1297
1304
  if (needle.length > 200) throw new Error('memory_search: query 太长(上限 200 字)')
1298
1305
  const limit = Math.max(1, Math.min(20, options.limit ?? 8))
1299
1306
  const mode = options.mode ?? 'ranked'
1307
+ if(options.includePrivate!==undefined&&typeof options.includePrivate!=='boolean')throw Error('memory-search-invalid-private-option')
1300
1308
  if (mode !== 'exact' && mode !== 'ranked') throw new Error('memory_search: mode 必须是 exact 或 ranked')
1301
1309
  const context=normalizeContext(options.context),task=normalizeTask(options.task)
1302
1310
  if(options.view!==undefined&&!['current','history'].includes(options.view))throw new Error('memory-search-invalid-view')
@@ -1305,7 +1313,7 @@ function searchScopedStoresLocal(stores, query, options = {}) {
1305
1313
 
1306
1314
  const files = stores.flatMap(({ dir, scope }) => {
1307
1315
  const policy = withdrawalPolicy(dir)
1308
- const owned=liveSourceRecords(dir,scope).filter(record=>['recorded','adopted'].includes(record.status)),typed=activeMeanings(owned),assessmentRecords=effectiveSourceRecords(dir,scope)
1316
+ const owned=liveSourceRecords(dir,scope).filter(record=>['recorded','adopted'].includes(record.status)),typed=activeMeanings(owned),assessmentRecords=options.includePrivate===true?effectiveSourceRecords(dir,scope):publicMemoryRecords(effectiveSourceRecords(dir,scope))
1309
1317
  return ['MEMORY.md', ...TYPES.map(type => `${type}.md`)].map(name => ({
1310
1318
  name, path: join(dir, name), policy, scope, typed, owned, assessmentRecords, key: scope === undefined ? name : `${scope}/${name}`,
1311
1319
  }))
@@ -1318,12 +1326,16 @@ function searchScopedStoresLocal(stores, query, options = {}) {
1318
1326
  for (const file of files) {
1319
1327
  const text = readTextSafe(file.path)
1320
1328
  if (text === undefined) continue
1329
+ // Legacy topic paragraphs may span lines without record markers. Never
1330
+ // return the unlabelled continuation of a recognized private paragraph.
1331
+ if(file.name!=='MEMORY.md'&&options.includePrivate!==true&&privateMemory(text))continue
1321
1332
  let lineNumber = 0
1322
1333
  const filtered=filterWithdrawn(text,file.policy,file.name==='MEMORY.md'),details=file.name==='MEMORY.md'?undefined:detailedMeanings(file.owned,filtered)
1323
1334
  for (const raw of filtered.split('\n')) {
1324
1335
  lineNumber += 1
1325
1336
  if(file.name==='MEMORY.md'&&isExpired(raw))continue
1326
1337
  const record=file.name==='MEMORY.md'?meaningForLine(file.typed,raw):details.get(lineNumber)
1338
+ if(options.includePrivate!==true&&privateMemory(record,raw))continue
1327
1339
  if(record?.expires&&isExpired(`placeholder (exp:${record.expires})`))continue
1328
1340
  const interpretation=record?.meaning?meaningView(record,context,task,file.assessmentRecords):undefined
1329
1341
  if(interpretation?.applicability==='not-applicable')continue
@@ -1598,6 +1610,8 @@ function readLog(dir) {
1598
1610
  * @param query - `{ id }` 或 `{ match }`(索引行子串,必须唯一)。
1599
1611
  */
1600
1612
  function lookupLinksLocal(dir, query) {
1613
+ const hiddenLines=new Set(liveSourceRecords(dir).filter(privateMemory).map(record=>record.indexLine).filter(Boolean))
1614
+ const hidden=line=>hiddenLines.has(line)||privateMemory(line)
1601
1615
  assertPublicationClear(dir)
1602
1616
  const policy = withdrawalPolicy(dir)
1603
1617
  const events = readLog(dir).filter((e) => !policy.memoryIds.includes(e.id) && !policy.lineHashes.includes(normalizedLineHash(String(e.line ?? ''))))
@@ -1606,7 +1620,7 @@ function lookupLinksLocal(dir, query) {
1606
1620
  const live = new Map()
1607
1621
  for (const line of current.split('\n')) {
1608
1622
  const trimmed = line.trim()
1609
- if (trimmed.startsWith('- [')) live.set(entryId(trimmed), trimmed)
1623
+ if (trimmed.startsWith('- [')&&!hidden(trimmed)) live.set(entryId(trimmed), trimmed)
1610
1624
  }
1611
1625
 
1612
1626
  let key = query.id
@@ -1627,8 +1641,9 @@ function lookupLinksLocal(dir, query) {
1627
1641
  const inserted = events.filter((event) => event.op === 'insert' && event.id === key)
1628
1642
  const line = live.get(key) ?? inserted.at(-1)?.line ?? events.find((event) => event.id === key)?.line
1629
1643
  if (line === undefined) return { found: false, related: [], backlinks: [] }
1644
+ if(hidden(line))return{found:false,related:[],backlinks:[]}
1630
1645
 
1631
- const related = inserted.at(-1)?.related ?? []
1646
+ const related = (inserted.at(-1)?.related ?? []).filter(item=>!hidden(item.line))
1632
1647
 
1633
1648
  // 入边要顺着取代链找:指向被取代旧条目的边,等同指向新条目。
1634
1649
  // 否则「改了一条记忆」就等于把它从图上拔掉。
@@ -1639,6 +1654,7 @@ function lookupLinksLocal(dir, query) {
1639
1654
  const backlinks = []
1640
1655
  for (const event of events) {
1641
1656
  if (event.op !== 'insert' || lineage.has(event.id)) continue
1657
+ if(hidden(event.line))continue
1642
1658
  if (!(event.related ?? []).some((item) => lineage.has(item.id))) continue
1643
1659
  if (backlinks.some((item) => item.id === event.id)) continue
1644
1660
  backlinks.push({ id: event.id, line: event.line })
@@ -1660,7 +1676,7 @@ function lookupLinksLocal(dir, query) {
1660
1676
  backlinks: backlinks.slice(0, RELATION_LIMIT),
1661
1677
  backlinksTotal: backlinks.length,
1662
1678
  ...(replaced?.supersededById === undefined ? {} : { supersededById: replaced.supersededById }),
1663
- ...(supersedesLine === undefined ? {} : { supersedesLine }),
1679
+ ...(supersedesLine === undefined||hidden(supersedesLine) ? {} : { supersedesLine }),
1664
1680
  }
1665
1681
  }
1666
1682
 
@@ -1776,7 +1792,7 @@ async function admitNativeWrite(ctx,sourceInputs,config,args,exec,target){
1776
1792
  const revision=readOwned(()=>{
1777
1793
  const value=storeRevision(target.dir)
1778
1794
  const sourceRecords=effectiveSourceRecords(target.dir,target.scope,undefined,true)
1779
- currentRecords=validationCurrentRecords(target.dir,target.scope,sourceRecords)
1795
+ currentRecords=validationCurrentRecords(target.dir,target.scope,sourceRecords,target.supersedes)
1780
1796
  if(target.supersedes!==undefined){
1781
1797
  const hits=(readTextSafe(join(target.dir,'MEMORY.md'))??'').split(/\r?\n/).filter(line=>line.trim().startsWith('- [')&&line.includes(target.supersedes));if(hits.length!==1)throw Error('memory-native-supersedes-not-unique')
1782
1798
  const compiled=prepareValueRevision(target,sourceRecords,hits[0].trim())
@@ -1945,6 +1961,7 @@ function defineSearchTool(ctx) {
1945
1961
  name: 'memory_search',
1946
1962
  description:
1947
1963
  '在记忆索引与主题文件里检索。默认 ranked 支持中英多词问题与有限英文词形归并;exact 用于精确子串定位;' +
1964
+ '默认隐藏已识别的个人敏感内容。用户询问相关资料时才设includePrivate=true;返回内容会进入当前会话日志和模型请求,不能声称只在本地读取。' +
1948
1965
  '默认同时查当前项目和全局偏好,不查其他项目;可指定单库。view=history显式查询带来源的旧值、取代与移出记录,返回历史身份及覆盖范围,不能当当前指令。',
1949
1966
  parameters: {
1950
1967
  type: 'object',
@@ -1952,6 +1969,7 @@ function defineSearchTool(ctx) {
1952
1969
  required: ['query'],
1953
1970
  properties: {
1954
1971
  query: { type: 'string', description: '检索词,尽量具体(≤200 字);也可给返回的完整64位recordId精确定位来源条目,不能用8位索引id' },
1972
+ includePrivate:{type:'boolean',description:'仅在用户询问相关个人资料时明确设true,允许读取被自动降载的个人内容。默认false;不是文件访问权限或完整隐私识别器。'},
1955
1973
  mode: { type: 'string', enum: ['exact', 'ranked'], description: 'ranked 默认多词排序;exact 精确定位。不是语义向量检索,不支持完整同义词或英文词形分析' },
1956
1974
  view:{type:'string',enum:['current','history'],description:'current默认只查当前视图;history查受管历史档案(已撤回来源仍不可见)。旧日志不会被伪造成完整历史。'},
1957
1975
  atSequence:{type:'integer',description:'仅history:按选定单库的记录序号查询当时有效的条目,必须指定scope=project或global;不是跨库统一版本。'},
@@ -1999,10 +2017,10 @@ function defineSearchTool(ctx) {
1999
2017
  return searchScopedStores([
2000
2018
  { dir: resolveStoreDir(ctx, exec, 'memory_search', 'project'), scope: 'project' },
2001
2019
  { dir: resolveStoreDir(ctx, exec, 'memory_search', 'global'), scope: 'global' },
2002
- ], args.query, { limit: args.limit, mode: args.mode, context:args.context,task:args.task,view:args.view,atSequence:args.atSequence,asOf:args.asOf })
2020
+ ], args.query, { includePrivate:args.includePrivate,limit: args.limit, mode: args.mode, context:args.context,task:args.task,view:args.view,atSequence:args.atSequence,asOf:args.asOf })
2003
2021
  }
2004
2022
  const dir = resolveStoreDir(ctx, exec, 'memory_search', args.scope)
2005
- return searchStore(dir, args.query, { limit: args.limit, mode: args.mode, context:args.context,task:args.task,view:args.view,atSequence:args.atSequence,asOf:args.asOf })
2023
+ return searchStore(dir, args.query, { includePrivate:args.includePrivate,limit: args.limit, mode: args.mode, context:args.context,task:args.task,view:args.view,atSequence:args.atSequence,asOf:args.asOf })
2006
2024
  },
2007
2025
  }
2008
2026
  }
@@ -2254,7 +2272,7 @@ function defineForgetTool(ctx) {
2254
2272
  text:
2255
2273
  value.removed === 0
2256
2274
  ? `没有匹配到任何索引行:${value.path}`
2257
- : `已删除 ${value.removed} 条(${value.path})`,
2275
+ : `已停止使用 ${value.removed} 条索引记忆(${value.path})。原话、主题正文、会话日志、备份及已发送内容未由此清除;这不是历史数据擦除。`,
2258
2276
  },
2259
2277
  ],
2260
2278
  },
@@ -2429,13 +2447,14 @@ function setupAutoCapture(ctx, config) {
2429
2447
  const captureDirs=[storeRoot(),projectDir].filter(Boolean)
2430
2448
  const memorySnapshot=managedReadMany(captureDirs,()=>{
2431
2449
  const stores=captureDirs.map(dir=>{const scope=dir===storeRoot()?'global':'project',policy=withdrawalPolicy(dir);return{dir,scope,policy,records:effectiveSourceRecords(dir,scope,undefined,true),indexLines:filterWithdrawn(readTextSafe(join(dir,'MEMORY.md'))??'',policy,true).split('\n').map(line=>line.trim()).filter(line=>line.startsWith('- [')).slice(-60)}})
2432
- const projections=stores.flatMap(store=>store.records.flatMap(record=>{
2450
+ for(const store of stores)store.indexLines=publicMemoryIndex(store.indexLines,store.records)
2451
+ const projections=stores.flatMap(store=>publicMemoryRecords(store.records).flatMap(record=>{
2433
2452
  const line=record.indexLine??indexLine(record.type,record.summary,record.expires,record.detail!==undefined)
2434
2453
  if(!store.indexLines.includes(line))return[]
2435
2454
  const topic=filterWithdrawn(readTextSafe(join(store.dir,record.type+'.md'))??'',store.policy)
2436
2455
  return[captureProjection(record,store.scope,`[${store.scope}] ${line}`,topic)]
2437
2456
  })).slice(-60)
2438
- return{validationRecords:stores.flatMap(store=>validationCurrentRecords(store.dir,store.scope,store.records)),sourceRecords:stores.flatMap(store=>store.records),structuredRecords:projections,relationTargets:stores.flatMap(store=>store.records.filter(record=>['claim','belief'].includes(record.meaning?.kind)&&!record.meaning.relations?.length).map(record=>({scope:store.scope,recordId:record.id,kind:record.meaning.kind,summary:record.summary}))).slice(-60),revisions:new Map(captureDirs.map(dir=>[dir,{value:storeRevision(dir)}])),indexLines:stores.flatMap(store=>store.indexLines.map(line=>`[${store.scope}] ${line}`))}
2457
+ return{validationRecords:stores.flatMap(store=>validationCurrentRecords(store.dir,store.scope,store.records)),sourceRecords:stores.flatMap(store=>store.records),structuredRecords:projections,relationTargets:stores.flatMap(store=>publicMemoryRecords(store.records).filter(record=>['claim','belief'].includes(record.meaning?.kind)&&!record.meaning.relations?.length).map(record=>({scope:store.scope,recordId:record.id,kind:record.meaning.kind,summary:record.summary}))).slice(-60),revisions:new Map(captureDirs.map(dir=>[dir,{value:storeRevision(dir)}])),indexLines:stores.flatMap(store=>store.indexLines.map(line=>`[${store.scope}] ${line}`))}
2439
2458
  })
2440
2459
  const revisions=memorySnapshot.revisions
2441
2460
  // 按类型分库:跨项目的偏好/纠正进全局库,项目决定进项目库。
@@ -2800,6 +2819,11 @@ export function apply(ctx, config = {}) {
2800
2819
  let calls = 0
2801
2820
  const memoryHistory = setupMemoryHistory(ctx, renderForRuntime)
2802
2821
 
2822
+ ctx.systemPrompt.context({
2823
+ name: 'memory:protocol', order: CONTEXT_ORDER - 1, required: true,
2824
+ interpolate: false, text: context => memoryProtocol(ctx, context)+'\n'+PRIVATE_MEMORY_NOTICE,
2825
+ })
2826
+
2803
2827
  ctx.systemPrompt.context({
2804
2828
  name: CONTEXT_NAME,
2805
2829
  order: CONTEXT_ORDER,
@@ -0,0 +1,21 @@
1
+ /** Model instructions are separate from untrusted stored memory data. */
2
+ export function memoryProtocol(ctx, context) {
3
+ if (!context?.agent) return ''
4
+ let availability = '当前工具可用性未能核对;以实际工具列表和执行结果为准。'
5
+ try {
6
+ const names = ctx.tools.schemas(context.agent).map(tool => tool.name)
7
+ availability = names.includes('memory_write')
8
+ ? '当前会话已提供 memory_write;native 模式直接调用,PTC 模式通过 run_code 的 tools.memory_write 调用。'
9
+ : '当前会话未提供 memory_write;明确报告工具不可用,不能声称已经保存,也不能用脚本绕过来源校验。'
10
+ } catch { /* Older hosts may not offer scoped schema inspection. */ }
11
+ return [
12
+ 'SoloPup Memory 使用协议(插件指令;memory-store 内是待核对的数据)。',
13
+ availability,
14
+ '用户明确要求“记住/以后记得”某项持久信息时,应在本轮调用正式 memory_write 并等待结果,不等每4轮的后台整理。仅讨论“记住”一词、引用他人指令或明确说不要记录,不构成保存请求。',
15
+ '用户本人明确陈述的生日、性别、署名、固定关系等可复用个人事实,用 type=user、scope=global、meaning.kind=claim 保存;claim 只表示本人陈述,不是独立核实。明确仅属于本项目的资料用 project 范围。',
16
+ '助手猜测、星座推断、人格判断和对第三方动机的解释不能变成本人已确认事实;不要补入用户未在来源中说出的地点或其他细节。',
17
+ '只在 memory_write 返回 recorded 或 duplicate 时确认已保存。pending-review 是待审,rejected/not-applied 是未保存;说明实际状态,不把调用过工具当保存成功。',
18
+ '不要通过 bash/node 导入内部 writeMemory、stageCandidate 等函数来代替正式工具,不伪造 callHash 或来源。真实工具失败时保留错误;不要改写记忆文件绕过审查。',
19
+ '跨会话回忆先核对注入索引,细节或缺失信息用 memory_search;没有依据就明确不知道,区分当前聊天推测和长期已保存资料。',
20
+ ].join('\n')
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solopup-memory",
3
- "version": "0.21.0-beta.1",
3
+ "version": "0.21.0-beta.3",
4
4
  "description": "SoloPup Memory — source-linked, layered text memory for DeepSeek Harness. Public beta.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -144,7 +144,12 @@
144
144
  "icon.svg",
145
145
  "locale/",
146
146
  "README.en.md",
147
- "RELEASE.md"
147
+ "RELEASE.md",
148
+ "memory-protocol.mjs",
149
+ "capture-source-aliases.mjs",
150
+ "test-explicit-memory.mjs",
151
+ "privacy.mjs",
152
+ "test-privacy.mjs"
148
153
  ],
149
154
  "engines": {
150
155
  "node": ">=24.9",
@@ -157,7 +162,7 @@
157
162
  },
158
163
  "license": "MIT",
159
164
  "scripts": {
160
- "test": "node --test --test-concurrency=4 test.mjs test-security.mjs test-adversarial.mjs test-concurrency.mjs test-supersede.mjs test-history.mjs test-smell.mjs test-links.mjs test-packaging.mjs test-patch.mjs test-compat.mjs test-conflict.mjs test-usability.mjs test-compaction-compat.mjs test-schema.mjs test-capture.mjs test-paired-analysis.mjs test-retrieval.mjs test-candidates.mjs test-publication.mjs test-withdrawal.mjs test-capture-jobs.mjs test-context-history.mjs test-capture-inputs.mjs test-inline-summary.mjs test-withdrawal-archives.mjs test-plugin-contract.mjs test-project-stores.mjs test-service-readiness.mjs test-source-key.mjs test-tool-inputs.mjs test-tool-write-lifecycle.mjs test-tool-write-guard.mjs test-source-retirement.mjs test-managed-access.mjs test-managed-activation.mjs test-runtime-startup.mjs test-capture-revision.mjs test-meaning.mjs test-episodes.mjs test-belief-revision.mjs test-tool-observations.mjs test-capture-diagnostics.mjs test-snapshots.mjs test-restore.mjs test-status-both.mjs test-text-admission.mjs test-capture-submission.mjs test-capture-projections.mjs test-capture-validation.mjs test-capture-validation-job.mjs test-capture-validation-flow.mjs test-capture-validation-lineage.mjs test-native-write-validation.mjs test-native-service-lifecycle.mjs test-field-lineage.mjs test-capture-field-lineage.mjs test-validation-transition-context.mjs test-control-schema.mjs test-revision-admission.mjs test-rule-admission.mjs test-validation-source-refs.mjs test-source-completeness.mjs test-legacy-source-identity.mjs",
165
+ "test": "node --test --test-concurrency=4 test.mjs test-security.mjs test-adversarial.mjs test-concurrency.mjs test-supersede.mjs test-history.mjs test-smell.mjs test-links.mjs test-packaging.mjs test-patch.mjs test-compat.mjs test-conflict.mjs test-usability.mjs test-compaction-compat.mjs test-schema.mjs test-capture.mjs test-paired-analysis.mjs test-retrieval.mjs test-candidates.mjs test-publication.mjs test-withdrawal.mjs test-capture-jobs.mjs test-context-history.mjs test-capture-inputs.mjs test-inline-summary.mjs test-withdrawal-archives.mjs test-plugin-contract.mjs test-project-stores.mjs test-service-readiness.mjs test-source-key.mjs test-tool-inputs.mjs test-tool-write-lifecycle.mjs test-tool-write-guard.mjs test-source-retirement.mjs test-managed-access.mjs test-managed-activation.mjs test-runtime-startup.mjs test-capture-revision.mjs test-meaning.mjs test-episodes.mjs test-belief-revision.mjs test-tool-observations.mjs test-capture-diagnostics.mjs test-snapshots.mjs test-restore.mjs test-status-both.mjs test-text-admission.mjs test-capture-submission.mjs test-capture-projections.mjs test-capture-validation.mjs test-capture-validation-job.mjs test-capture-validation-flow.mjs test-capture-validation-lineage.mjs test-native-write-validation.mjs test-native-service-lifecycle.mjs test-field-lineage.mjs test-capture-field-lineage.mjs test-validation-transition-context.mjs test-control-schema.mjs test-revision-admission.mjs test-rule-admission.mjs test-validation-source-refs.mjs test-source-completeness.mjs test-legacy-source-identity.mjs test-explicit-memory.mjs test-privacy.mjs",
161
166
  "eval": "node eval/run-eval.mjs",
162
167
  "gate": "node eval/run-eval.mjs --check",
163
168
  "verify": "node scripts/verify.mjs",
package/privacy.mjs ADDED
@@ -0,0 +1,25 @@
1
+ /** Conservative exposure reduction, not a confidentiality boundary or exhaustive PII detector. */
2
+ import {normalizeText} from './security.mjs'
3
+ const patterns = [
4
+ ['exact-dob', /生日|出生(?:日期|年月|于|在)?|\bbirth(?:day|date)\b|\bdate\s+of\s+birth\b|\bdob\b|\bborn\b/i],
5
+ ['personal-profile', /个人档案|性别|男性|女性|\bpersonal\s+profile\b|\bgender\b/i],
6
+ ['health', /病史|医疗诊断|疾病诊断|医生诊断|诊断(?:为|出)|用药|过敏|怀孕|月经|健康状况|医疗资料|\bmedical\b|\bclinical\s+diagnosis\b|\bdiagnosed\s+with\b|\bmedication\b/i],
7
+ ['intimate', /亲密关系|性取向|性生活|性行为|伴侣|男朋友|女朋友|前任|恋爱|情人|\bsexual\b|\bintimate\b|\bromantic\b/i],
8
+ ['personal-contact', /身份证|护照号码|家庭住址|私人电话|银行卡号|\bpassport\s+number\b|\bhome\s+address\b/i],
9
+ ['marked-private', /\[private(?::[a-z-]+)?\]|保密资料|私密档案|敏感个人/i],
10
+ ]
11
+
12
+ export function memoryPrivacy(value, line = '') {
13
+ const content = typeof value === 'string' ? value : value && typeof value === 'object'
14
+ ? [value.summary, value.detail, value.indexLine, JSON.stringify(value.meaning ?? {}),
15
+ ...(value.evidence ?? []).map(e => e.quote), JSON.stringify(value.fieldLineage ?? {})].filter(Boolean).join('\n') : ''
16
+ const text = normalizeText(content + '\n' + line)
17
+ const categories = patterns.filter(([, pattern]) => pattern.test(text)).map(([category]) => category)
18
+ return {exposure: categories.length ? 'on-demand' : 'automatic', categories}
19
+ }
20
+ export const privateMemory = (value, line = '') => memoryPrivacy(value, line).exposure === 'on-demand'
21
+ export function publicMemoryRecords(records) { return records.filter(record => !privateMemory(record)) }
22
+ export function publicMemoryIndex(lines, records) {
23
+ return lines.filter(line => !privateMemory(records.find(record => record.indexLine === line || line.endsWith(' ' + record.indexLine)), line))
24
+ }
25
+ export const PRIVATE_MEMORY_NOTICE = '个人资料可能按需保存在本地;未自动提供其内容。仅在用户询问相关资料时使用 memory_search 并明确 includePrivate=true。读取结果仍会进入当前会话及模型请求;这不是加密或文件访问隔离。'
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {memoryProtocol} from './memory-protocol.mjs'
4
+ import {captureSourceAliases} from './capture-source-aliases.mjs'
5
+ import {extractWithLlm} from './capture-llm.mjs'
6
+ import {assessCandidate} from './capture-evidence.mjs'
7
+
8
+ test('protocol reports scoped availability and remains available with no memory data', () => {
9
+ const agent={session:{id:'owner'}}
10
+ const text=memoryProtocol({tools:{schemas:scope=>{assert.equal(scope,agent);return [{name:'memory_write'}]}}},{agent})
11
+ assert.match(text,/本轮调用正式 memory_write/)
12
+ assert.match(text,/claim 只表示本人陈述/)
13
+ assert.match(text,/pending-review 是待审/)
14
+ assert.match(text,/不伪造 callHash/)
15
+ assert.match(memoryProtocol({tools:{schemas:()=>[]}},{agent}),/当前会话未提供/)
16
+ assert.equal(memoryProtocol({},{}),'')
17
+ assert.match(memoryProtocol({}, {agent}),/未能核对/)
18
+ })
19
+
20
+ test('source labels are request-local and cannot collide with actual identifiers', () => {
21
+ const messages=[{id:'source1',role:'user',text:'原话不变'},{id:'long-session:22',role:'assistant',text:'助手猜测'}]
22
+ const mapped=captureSourceAliases(messages)
23
+ assert.equal(mapped.messages[0].id,'_source1')
24
+ const batch=mapped.restore({candidates:[{evidence:[{messageId:'_source1',quote:'原话不变'},{messageId:'source2',quote:'助手猜测'},{messageId:'unknown',quote:'未知'}]}],parserRejected:0})
25
+ assert.deepEqual(batch.candidates[0].evidence.map(e=>e.messageId),['source1','long-session:22','unknown'])
26
+ assert.equal(messages[0].id,'source1')
27
+ })
28
+
29
+ test('real extraction boundary binds short ids back before owner quote validation',async()=>{
30
+ const quote='记住:验收角色沈沐出生于1990年5月12日,男性。'
31
+ const messages=[{id:'session-synthetic:7177',role:'user',text:quote,hash:'a'.repeat(64)},
32
+ {id:'session-synthetic:7179',role:'assistant',text:'我猜他出生于长沙。',hash:'b'.repeat(64)}]
33
+ const llm={async prepareCall(config){return{config,async*stream(request){
34
+ assert.match(request.messages[0].content[0].text,/"id":"source1"/)
35
+ assert.doesNotMatch(request.messages[0].content[0].text,/session-synthetic:7177/)
36
+ yield {type:'text-delta',text:JSON.stringify([{type:'user',scope:'global',basis:'explicit',meaning:{kind:'claim'},summary:quote,evidence:[{messageId:'source1',quote}]}])}
37
+ }}}}
38
+ const candidates=await extractWithLlm({llm,messages,indexLines:[]})
39
+ assert.equal(candidates[0].evidence[0].messageId,messages[0].id)
40
+ assert.equal(assessCandidate(candidates[0],messages,true).status,'eligible')
41
+ for(const ref of [{messageId:messages[1].id,quote:messages[1].text},{messageId:'unknown',quote},{messageId:messages[0].id,quote:'他出生于长沙'}])
42
+ assert.equal(assessCandidate({...candidates[0],evidence:[ref]},messages,true).reason,'invalid-user-evidence')
43
+ })
@@ -40,6 +40,12 @@ test('native approval publishes the unchanged typed candidate; exact replay is a
40
40
  assert.equal(result.status,'recorded');assert.equal(f.records()[0].summary,proposal.summary);assert.equal(f.requests.length,1)
41
41
  assert.equal((await f.tool.execute(proposal,exec)).status,'duplicate');assert.equal(f.requests.length,1)
42
42
  }))
43
+ test('unrelated native admission does not disclose saved private records to its reviewer',()=>box({duringStream:(_f,input)=>{
44
+ assert.doesNotMatch(JSON.stringify(input.currentMemory),/1993|4月16|个人档案/)
45
+ }},async f=>{
46
+ writeFixtureMemory({dir:f.root,type:'user',scope:'global',summary:'个人档案:出生于1993年4月16日,女性',meaning:{kind:'claim'}})
47
+ f.owner();const result=await f.tool.execute(proposal,f.call());assert.equal(result.status,'recorded')
48
+ }))
43
49
  test('definite rejection leaves no current or pending memory and does not render success',()=>box({state:'unsupported'},async f=>{
44
50
  f.owner();const result=await f.tool.execute(proposal,f.call());assert.equal(result.status,'rejected');assert.equal(f.records().length,0);assert.doesNotMatch(f.rendered(result),/已写入/);assert.equal(searchStore(f.root,'长期反馈').hits.length,0)
45
51
  }))
@@ -0,0 +1,65 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {mkdtempSync,realpathSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs'
4
+ import {join} from 'node:path'
5
+ import {tmpdir} from 'node:os'
6
+ import {apply,searchStore,lookupLinks} from './index.mjs'
7
+ import {writeFixtureMemory} from './fixture-host.mjs'
8
+ import {memoryPrivacy,privateMemory,publicMemoryRecords,publicMemoryIndex} from './privacy.mjs'
9
+
10
+ test('actual background extractor receives no unrelated saved private source or summary',async()=>{
11
+ const home=realpathSync(mkdtempSync(join(tmpdir(),'memory-private-background-'))),old=process.env.DSH_HOME;process.env.DSH_HOME=home
12
+ const cwd=join(home,'project'),handlers=[],effects=[];mkdirSync(cwd);let requestText,finished=false
13
+ const llm={async prepareCall(config){return{config,async*stream(request){requestText=JSON.stringify(request);yield{type:'text-delta',text:'[]'};yield{type:'finish',reason:{kind:'stop'}};finished=true}}}}
14
+ try{
15
+ apply({llm,systemPrompt:{context(){}},workingDirectory:{get:()=>cwd},tools:{register(){}},on:(name,fn)=>handlers.push([name,fn]),effect:fn=>effects.push(fn())},
16
+ {autoCapture:{everyTurns:1,provider:'fixture',model:'fixture'}})
17
+ writeFixtureMemory({dir:join(home,'memory'),type:'user',scope:'global',summary:'个人档案:出生于1993年4月16日,女性',meaning:{kind:'claim'}})
18
+ const session={id:'synthetic-unrelated-work'},emit=e=>{for(const [n,f]of handlers)if(n==='session/event')f(session,e)}
19
+ emit({type:'user/message',seq:1,data:{source:{kind:'user'},content:[{type:'text',text:'本项目以后交付字幕时固定先检查断句。'}]}})
20
+ emit({type:'step/end',seq:2})
21
+ for(let i=0;i<100&&!finished;i++)await new Promise(r=>setTimeout(r,5))
22
+ assert.equal(finished,true)
23
+ assert.doesNotMatch(requestText,/1993|4月16|个人档案:/)
24
+ }finally{for(const f of effects.reverse())if(typeof f==='function')f();if(old===undefined)delete process.env.DSH_HOME;else process.env.DSH_HOME=old;rmSync(home,{recursive:true,force:true})}
25
+ })
26
+
27
+ test('known personal categories default to on-demand, including evidence-only private data',()=>{
28
+ for(const text of ['生日是1993年4月16日','个人档案:女性','病史与用药资料','亲密关系与伴侣','家庭住址','[private:personal] opaque','birthday is April 16'])assert.equal(privateMemory(text),true)
29
+ assert.equal(privateMemory({summary:'工作反馈先给结论',evidence:[{quote:'我的生日是1993年4月16日,我希望先给结论。'}]}),true)
30
+ assert.equal(memoryPrivacy('字幕交付先检查中英断句').exposure,'automatic')
31
+ assert.equal(privateMemory('工具失败的原生记录仅用于诊断'),false)
32
+ })
33
+ test('background projections and pointers omit private evidence without changing originals',()=>{
34
+ const secret={indexLine:'- [user] 个人档案:出生于1993年4月16日 — user.md',summary:'个人档案:出生于1993年4月16日',evidence:[]}
35
+ const mixed={indexLine:'- [user] 回答先给结论 — user.md',summary:'回答先给结论',evidence:[{quote:'我的生日是1993年4月16日,回答先给结论'}]}
36
+ const safe={indexLine:'- [feedback] 字幕保留断句 — feedback.md',summary:'字幕保留断句',evidence:[]}
37
+ const records=[secret,mixed,safe],before=JSON.stringify(records)
38
+ assert.deepEqual(publicMemoryRecords(records),[safe])
39
+ assert.deepEqual(publicMemoryIndex(records.map(r=>r.indexLine),records),[safe.indexLine])
40
+ assert.equal(JSON.stringify(records),before)
41
+ })
42
+ test('real plugin render and both current/history search hide private records by default',()=>{
43
+ const home=realpathSync(mkdtempSync(join(tmpdir(),'memory-privacy-'))),old=process.env.DSH_HOME;process.env.DSH_HOME=home
44
+ const cwd=join(home,'project'),providers=[],tools=[];mkdirSync(cwd)
45
+ try{
46
+ apply({systemPrompt:{context:p=>providers.push(p)},workingDirectory:{get:()=>cwd},tools:{register:t=>tools.push(t),schemas:()=>[{name:'memory_write'}]},on(){},effect(){}},{autoCapture:{enabled:false}})
47
+ const dir=join(home,'memory'),secret='固定个人档案:出生于1993年4月16日,女性。'
48
+ const privateWrite=writeFixtureMemory({dir,type:'user',scope:'global',summary:secret,detail:'私人资料分行\n1993-04-16\n女性',meaning:{kind:'claim'}})
49
+ writeFixtureMemory({dir,type:'feedback',scope:'global',summary:'字幕验收先看断句',meaning:{kind:'preference'}})
50
+ const before=readFileSync(join(dir,'MEMORY.md'),'utf8')
51
+ const rendered=providers.find(p=>p.name==='memory:index').text({agent:{session:{}}})
52
+ assert.doesNotMatch(rendered,/1993|女性|固定个人档案/)
53
+ assert.match(rendered,/字幕验收先看断句/)
54
+ assert.match(providers.find(p=>p.name==='memory:protocol').text({agent:{session:{}}}),/includePrivate=true/)
55
+ assert.equal(searchStore(dir,'1993',{mode:'exact'}).hits.length,0)
56
+ assert.ok(searchStore(dir,'1993',{mode:'exact',includePrivate:true}).hits.length>0)
57
+ assert.equal(lookupLinks(dir,{id:privateWrite.id}).found,false)
58
+ assert.equal(lookupLinks(dir,{match:'个人档案'}).found,false)
59
+ assert.equal(searchStore(dir,'1993',{mode:'exact',view:'history',scope:'global'}).hits.length,0)
60
+ assert.ok(searchStore(dir,'1993',{mode:'exact',view:'history',scope:'global',includePrivate:true}).hits.length>0)
61
+ assert.throws(()=>searchStore(dir,'1993',{includePrivate:'true'}),/private-option/)
62
+ assert.equal(readFileSync(join(dir,'MEMORY.md'),'utf8'),before)
63
+ assert.ok(tools.find(t=>t.name==='memory_search').parameters.properties.includePrivate)
64
+ }finally{if(old===undefined)delete process.env.DSH_HOME;else process.env.DSH_HOME=old;rmSync(home,{recursive:true,force:true})}
65
+ })
package/test-restore.mjs CHANGED
@@ -94,7 +94,7 @@ test('a live second worker cannot resume or steal the in-progress journal',()=>b
94
94
  test('a host mounted during the missing-directory phase writes no new root and sees only a restore notice',()=>box(async f=>{
95
95
  seed(f.data,'private7360');createMemorySnapshot(f.data,f.snapshot);assert.throws(()=>restore(f,{fault:e=>{if(e.phase==='old-moved')throw Error('stop')}}),/stop/);assert.equal(existsSync(f.data),false)
96
96
  const tools=[],providers=[],effects=[];let calls=0;const ctx={systemPrompt:{context:p=>providers.push(p)},tools:{register:t=>tools.push(t)},workingDirectory:{get:()=>f.cwd},llm:{listProviders:()=>[],prepareCall(){calls++;throw Error('must not call')}},on(){},effect(fn){effects.push(fn())}}
97
- apply(ctx,{autoCapture:{everyTurns:1}});const status=await tools.find(t=>t.name==='memory_status').execute({scope:'global'});assert.equal(status.sourceControl.code,'managed-memory-restore-in-progress');const text=providers[0].text({agent:{session:{id:'during-restore'}}});assert.match(text,/恢复尚未完成/);assert.doesNotMatch(text,/private7360/);assert.equal(existsSync(f.data),false);assert.equal(calls,0);for(const fn of effects.reverse())fn?.();resumeMemoryRestore(f.data,{apply:true})
97
+ apply(ctx,{autoCapture:{everyTurns:1}});const status=await tools.find(t=>t.name==='memory_status').execute({scope:'global'});assert.equal(status.sourceControl.code,'managed-memory-restore-in-progress');const text=providers.find(p=>p.name==='memory:index').text({agent:{session:{id:'during-restore'}}});assert.match(text,/恢复尚未完成/);assert.doesNotMatch(text,/private7360/);assert.equal(existsSync(f.data),false);assert.equal(calls,0);for(const fn of effects.reverse())fn?.();resumeMemoryRestore(f.data,{apply:true})
98
98
  }))
99
99
  test('CLI performs a reviewed restore in a fresh process; incomplete arguments cannot mutate the target',()=>box(f=>{
100
100
  seed(f.data,'before7471');createMemorySnapshot(f.data,f.snapshot);seed(f.data,'after7582');const cli=join(here,'scripts/backup.mjs'),run=args=>spawnSync(process.execPath,[cli,...args],{encoding:'utf8'}),base=['restore','--root',f.data,'--snapshot',f.snapshot]
package/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  * 版本单点。package.json 里的 version 必须与它一致,由 test-packaging.mjs 断言。
3
3
  * 打包工具不能读 ESM 常量时会回退读 package.json,所以两处都要在。
4
4
  */
5
- export const VERSION = '0.21.0-beta.1'
5
+ export const VERSION = '0.21.0-beta.3'