sage-mem 1.9.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/README.zh.md +29 -6
- package/lib/autodream/audit.js +82 -2
- package/lib/autodream/config.js +10 -0
- package/lib/autodream/engine.js +4 -0
- package/lib/autodream/llm-loop.js +77 -10
- package/lib/autodream/prompt.js +3 -3
- package/lib/autodream/report.js +20 -10
- package/lib/autodream/settings.js +6 -0
- package/lib/autodream/tools.js +77 -12
- package/lib/client.js +960 -0
- package/lib/index.js +189 -0
- package/lib/memory/arbitrate.js +236 -0
- package/lib/memory/config.js +16 -0
- package/lib/memory/conflicts.js +225 -22
- package/lib/memory/frontmatter.js +22 -1
- package/lib/memory/scan.js +12 -0
- package/lib/memory/status.js +39 -7
- package/lib/memory/validity.js +156 -0
- package/lib/recall/compose.js +15 -5
- package/lib/recall/injector.js +38 -2
- package/lib/tools/memory-tools.js +47 -4
- package/lib/typert.host.js +166 -0
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -23,6 +23,8 @@ import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, p
|
|
|
23
23
|
import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
24
24
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
25
25
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
|
+
import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
|
|
27
|
+
import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
|
|
26
28
|
import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
27
29
|
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
28
30
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
@@ -57,6 +59,10 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
57
59
|
super(ctx, 'memory')
|
|
58
60
|
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
59
61
|
new StarmapGateway(ctx)
|
|
62
|
+
// 2.0.0:冲突裁决入口(列表 / 读 / 标失效 / 驳回 / 撤销 / 删除记录)—— conflict.* 服务。
|
|
63
|
+
// 与 memory / starmap 分开成独立 namespace:混成一个面会让「读记忆」与「改记忆的有效性」
|
|
64
|
+
// 共用一张契约表,而后者是**写用户文件**的动作。
|
|
65
|
+
new ConflictGateway(ctx)
|
|
60
66
|
|
|
61
67
|
// 四块挂载(1.2.0 从构造函数里搬出的四段)。顺序保持原样:
|
|
62
68
|
// autodream → 检索工具 → 观测 → 写入门。
|
|
@@ -663,6 +669,189 @@ export class StarmapGateway extends TypertRemoteService {
|
|
|
663
669
|
markRemote(StarmapGateway, 'listStars', 'listStars')
|
|
664
670
|
markRemote(StarmapGateway, 'readFile', 'readFile')
|
|
665
671
|
|
|
672
|
+
/**
|
|
673
|
+
* 2.0.0 的裁决入口:冲突队列的「可列、可读、可裁、可撤销」。
|
|
674
|
+
*
|
|
675
|
+
* 为什么单独一个 namespace(`conflict`)而不并进 `memory`:这一层会**写用户的记忆文件**
|
|
676
|
+
* (把 `invalid_at` 写进 frontmatter),而 memory 面是读写正文。两张契约表的失败语义、
|
|
677
|
+
* 危险等级都不同,混在一起只会让「读一下」和「改有效性」共用一个权限面。
|
|
678
|
+
*
|
|
679
|
+
* 四条硬约束(对应 `docs/2.0.0-plan.md` §5.5 / §7.3 / §7.6):
|
|
680
|
+
* 1. **先写文件、后改记录**。反过来会出现「记录说已裁决、文件没改」的半态,
|
|
681
|
+
* 用户再也看不到它(记录已 resolved,文件仍照旧注入)。
|
|
682
|
+
* 2. `invalidate` 只能写**这条记录自己提到的那一方**(`file` / `otherFile`)——
|
|
683
|
+
* 防「借裁决入口写任意记忆文件」。文件名本身还要再过 `safeName` 闸门。
|
|
684
|
+
* 3. 失败一律返回对象、绝不抛;`ok` 只在真的做了报告里那件事时为 true。
|
|
685
|
+
* 4. 写路径走宿主 `fs.writeText`:版本守卫 + 逐次沙箱策略(收窄到记忆目录),
|
|
686
|
+
* 与 autodream 同一口径 —— `read-only` 不替用户翻案。
|
|
687
|
+
*/
|
|
688
|
+
export class ConflictGateway extends TypertRemoteService {
|
|
689
|
+
constructor(ctx) { super(ctx, 'conflict') }
|
|
690
|
+
|
|
691
|
+
/** 解析「这次要标的是哪一方」:只能是记录里的 file 或 otherFile,否则 null。 */
|
|
692
|
+
targetOf(record, requested) {
|
|
693
|
+
const file = typeof requested === 'string' && requested !== '' ? requested : record.file
|
|
694
|
+
if (file !== record.file && file !== record.otherFile) return null
|
|
695
|
+
return file
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** 列全部记录的**摘要**(不含两份正文:一份记录正文上限 96 KB,列表不该整批搬运)。 */
|
|
699
|
+
async list() {
|
|
700
|
+
const listed = await listConflicts({ stateRoot: STATE_ROOT })
|
|
701
|
+
if (!listed.ok) return { ok: false, code: listed.code, error: listed.error }
|
|
702
|
+
const items = listed.items.map(summarizeConflict)
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
items,
|
|
706
|
+
broken: [...listed.broken],
|
|
707
|
+
ignored: [...listed.ignored],
|
|
708
|
+
counts: { open: items.filter((i) => i.status === 'open').length, total: items.length },
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 读一条完整记录(含两份正文)。 */
|
|
713
|
+
async read(opts) {
|
|
714
|
+
const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
715
|
+
if (!read.ok) return { ok: false, code: read.code, error: read.error }
|
|
716
|
+
return { ok: true, record: read.record, warnings: [...read.warnings] }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 把记录里的一方标为失效:写记忆 frontmatter 的 `invalid_at`(+ 指向另一方的
|
|
721
|
+
* `superseded_by`),**再**把记录改成已裁决。
|
|
722
|
+
*
|
|
723
|
+
* 幂等:同一方重复点,文件不重写(`invalidateMemory` 里判),记录也已 resolved,
|
|
724
|
+
* 直接回原收据。
|
|
725
|
+
*/
|
|
726
|
+
async invalidate(opts) {
|
|
727
|
+
const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
728
|
+
if (!read.ok) return { ok: false, code: read.code, error: read.error }
|
|
729
|
+
const record = read.record
|
|
730
|
+
if (record.kind !== 'contradiction') {
|
|
731
|
+
return {
|
|
732
|
+
ok: false,
|
|
733
|
+
code: 'invalid',
|
|
734
|
+
error: '只有矛盾型(contradiction)记录能标失效:写失败型的那份盘上内容仍是现行版本,标失效会把有效记忆藏起来',
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const target = this.targetOf(record, opts?.file)
|
|
738
|
+
if (!target) {
|
|
739
|
+
return { ok: false, code: 'invalid', error: 'file 必须是这条记录里的 file 或 otherFile(不能借裁决入口写别的记忆)' }
|
|
740
|
+
}
|
|
741
|
+
if (record.status !== 'open') {
|
|
742
|
+
if (record.status === 'resolved' && record.decision === 'invalidate' && record.invalidatedFile === target) {
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
record,
|
|
746
|
+
wrote: { file: target, invalidAt: record.decidedAt || '', version: record.appliedVersion || '' },
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return { ok: false, code: 'invalid_transition', error: '这条记录已经裁决过了;要改判断先撤销,再重新裁决' }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 事件时间由这一层给(纯函数不读时钟):裁决时间就是失效时间 —— 用户此刻决定「从现在起不作数」。
|
|
753
|
+
const invalidAt = new Date().toISOString()
|
|
754
|
+
const otherFile = target === record.file ? record.otherFile : record.file
|
|
755
|
+
const written = await invalidateMemory(
|
|
756
|
+
{ ctx: this.ctx, memoryDir: MEMORY_DIR },
|
|
757
|
+
{ file: target, invalidAt, supersededBy: otherFile },
|
|
758
|
+
)
|
|
759
|
+
if (!written.ok) return { ok: false, code: written.code, error: written.error }
|
|
760
|
+
|
|
761
|
+
const patched = await patchConflict(
|
|
762
|
+
record.id,
|
|
763
|
+
{
|
|
764
|
+
status: 'resolved',
|
|
765
|
+
decision: 'invalidate',
|
|
766
|
+
decidedAt: invalidAt,
|
|
767
|
+
appliedVersion: written.version,
|
|
768
|
+
invalidatedFile: target,
|
|
769
|
+
},
|
|
770
|
+
{ stateRoot: STATE_ROOT },
|
|
771
|
+
)
|
|
772
|
+
if (!patched.ok) {
|
|
773
|
+
// 文件已经改了、记录没改成 —— 这个方向是**可重试**的(再点一次幂等,只补记录),
|
|
774
|
+
// 与「记录已裁决、文件没改」相反。如实报失败,别把半态说成成功。
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
code: patched.code,
|
|
778
|
+
error: `记忆已标失效,但冲突记录状态没更新成功(再点一次即可,写入是幂等的):${patched.error ?? ''}`,
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return { ok: true, record: patched.record, wrote: { file: written.file, invalidAt, version: written.version } }
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** 标记「看过、决定不管」:只改记录,不碰记忆文件。 */
|
|
785
|
+
async dismiss(opts) {
|
|
786
|
+
const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
787
|
+
if (!read.ok) return { ok: false, code: read.code, error: read.error }
|
|
788
|
+
// 记录里的 `note` 是冲突的由来(写进记录那一刻的事实),裁决理由**不覆盖**它 ——
|
|
789
|
+
// 覆盖等于把证据改写成结论。
|
|
790
|
+
const patched = await patchConflict(
|
|
791
|
+
read.record.id,
|
|
792
|
+
{ status: 'dismissed', decision: 'dismiss', decidedAt: new Date().toISOString() },
|
|
793
|
+
{ stateRoot: STATE_ROOT },
|
|
794
|
+
)
|
|
795
|
+
if (!patched.ok) return { ok: false, code: patched.code, error: patched.error }
|
|
796
|
+
return { ok: true, record: patched.record }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* 撤销失效:删掉 frontmatter 里那两行,再把记录退回「已驳回」。
|
|
801
|
+
*
|
|
802
|
+
* 状态机不允许回到 `open`(判断理由会丢),所以撤销后的落地状态是 `dismissed` ——
|
|
803
|
+
* 界面文案要说明「已撤销、记忆重新参与注入」,而不是「又变回待裁决」。
|
|
804
|
+
*/
|
|
805
|
+
async undo(opts) {
|
|
806
|
+
const read = await readConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
807
|
+
if (!read.ok) return { ok: false, code: read.code, error: read.error }
|
|
808
|
+
const record = read.record
|
|
809
|
+
if (record.decision !== 'invalidate') {
|
|
810
|
+
return { ok: false, code: 'invalid_transition', error: '这条记录没有标过失效,没有可撤销的标记' }
|
|
811
|
+
}
|
|
812
|
+
const target = record.invalidatedFile || record.file
|
|
813
|
+
const cleared = await undoMemoryInvalidation({ ctx: this.ctx, memoryDir: MEMORY_DIR }, target)
|
|
814
|
+
if (!cleared.ok) return { ok: false, code: cleared.code, error: cleared.error }
|
|
815
|
+
|
|
816
|
+
const patched = await patchConflict(
|
|
817
|
+
record.id,
|
|
818
|
+
{ status: 'dismissed', decision: '', decidedAt: '', appliedVersion: '', invalidatedFile: '' },
|
|
819
|
+
{ stateRoot: STATE_ROOT },
|
|
820
|
+
)
|
|
821
|
+
if (!patched.ok) {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
code: patched.code,
|
|
825
|
+
error: `失效标记已撤销,但冲突记录状态没更新成功(再点一次即可,写入是幂等的):${patched.error ?? ''}`,
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { ok: true, record: patched.record, cleared: { file: cleared.file } }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* 删掉一条记录(清理;不动记忆文件本身)。危险动作,两段式确认在界面侧。
|
|
833
|
+
*
|
|
834
|
+
* ⚠️ 方法名**不能叫 `remove`**:DSH 的客户端 RemoteNamespaceService 是 Cordis Service 的
|
|
835
|
+
* 子类,`remove` 在它的原型上已被占用 —— 客户端声明同名方法会在 mountContribution 的
|
|
836
|
+
* `assertMethodAvailable` 直接抛错,**整个 client 插件 activate 失败**(不是单个按钮失效)。
|
|
837
|
+
* 2026-10-27 真机复现(`client api: method "conflict/remove" conflicts with its namespace service`)。
|
|
838
|
+
* 所以两端统一用 `removeRecord`,id 也一起改;契约不承诺 remote 方法名(契约 §7),
|
|
839
|
+
* 这条改名不影响对外承诺。
|
|
840
|
+
*/
|
|
841
|
+
async removeRecord(opts) {
|
|
842
|
+
const removed = await removeConflict(opts?.id, { stateRoot: STATE_ROOT })
|
|
843
|
+
if (!removed.ok) return { ok: false, code: removed.code, error: removed.error }
|
|
844
|
+
return { ok: true }
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
markRemote(ConflictGateway, 'list', 'list')
|
|
849
|
+
markRemote(ConflictGateway, 'read', 'read')
|
|
850
|
+
markRemote(ConflictGateway, 'invalidate', 'invalidate')
|
|
851
|
+
markRemote(ConflictGateway, 'dismiss', 'dismiss')
|
|
852
|
+
markRemote(ConflictGateway, 'undo', 'undo')
|
|
853
|
+
markRemote(ConflictGateway, 'removeRecord', 'removeRecord')
|
|
854
|
+
|
|
666
855
|
/**
|
|
667
856
|
* autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
|
|
668
857
|
*
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 裁决写入(2.0.0 矛盾检测的写路径)。
|
|
3
|
+
*
|
|
4
|
+
* 「这条记忆不再作数」是一个**写进记忆文件 frontmatter 的事实**,不是旁路索引:
|
|
5
|
+
* 记忆目录自包含、可 git diff、状态根丢了标记还在(D29 Q2)。代价是这个写入碰的是
|
|
6
|
+
* **用户的记忆文件**,所以这里每一条都是硬约束(docs/2.0.0-plan.md §5.5):
|
|
7
|
+
*
|
|
8
|
+
* 1. 幂等:同一份标记连写两次,第二次不写盘、不报错;
|
|
9
|
+
* 2. 不半份:原子写(宿主的 `fs.writeText`);
|
|
10
|
+
* 3. 不动别的字节:只加/删 `invalid_at` / `superseded_by` 两行,键顺序、缩进、CRLF 原样;
|
|
11
|
+
* 4. 不改 `updated`:失效不是「文件被改过」,改了会让新鲜度提示说谎;
|
|
12
|
+
* 5. 给收据:成功返回 `{ok, file, invalidAt, version}`,失败返回 `{ok:false, code}`。
|
|
13
|
+
*
|
|
14
|
+
* 拆分:**纯函数**(`upsertValidityLines` / `removeValidityLines`)负责字节级改写,
|
|
15
|
+
* 能直接单测;**IO**(`invalidateMemory` / `undoMemoryInvalidation`)负责版本守卫、
|
|
16
|
+
* 沙箱策略与错误码。不读时钟 —— 裁决时间由调用方给(`nowIso` 的取值落在 Gateway 一层)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { inspectMemoryEntry, readVerified, yamlScalar } from './scan.js'
|
|
21
|
+
import { safeName } from './naming.js'
|
|
22
|
+
import { parseValidityTime } from './validity.js'
|
|
23
|
+
|
|
24
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
25
|
+
|
|
26
|
+
/** 失效标记的两行(只认顶层键:`metadata:` 嵌套块里同名的键不是我们的)。 */
|
|
27
|
+
const VALIDITY_LINE_RE = /^(?:invalid_at|superseded_by):/
|
|
28
|
+
|
|
29
|
+
function eolOf(text) {
|
|
30
|
+
const src = String(text ?? '')
|
|
31
|
+
const i = src.indexOf('\r\n')
|
|
32
|
+
if (i >= 0) return '\r\n'
|
|
33
|
+
return '\n'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 给 frontmatter 写(或更新)`invalid_at` / `superseded_by`。
|
|
38
|
+
*
|
|
39
|
+
* 写法与归档留痕(`scan.js` 的 `addArchiveMeta`)同一口径:**先剥旧行再插**,
|
|
40
|
+
* 免得同一份文件里出现两个 `invalid_at`。新行插在 frontmatter 末尾 ——
|
|
41
|
+
* 插在别处就要决定「放第几行」,而键顺序本身没有语义,末尾最不容易碰到别的字节。
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content — 记忆全文
|
|
44
|
+
* @param {{invalidAt:string, supersededBy?:string}} input
|
|
45
|
+
* @returns {{ok:true, content:string, changed:boolean} | {ok:false, code:'invalid', error:string}}
|
|
46
|
+
*/
|
|
47
|
+
export function upsertValidityLines(content, input) {
|
|
48
|
+
if (typeof content !== 'string') return { ok: false, code: 'invalid', error: '记忆内容必须是字符串' }
|
|
49
|
+
const invalidAt = typeof input?.invalidAt === 'string' ? input.invalidAt.trim() : ''
|
|
50
|
+
if (!invalidAt) return { ok: false, code: 'invalid', error: 'invalid_at 不能为空' }
|
|
51
|
+
const supersededBy = typeof input?.supersededBy === 'string' ? input.supersededBy.trim() : ''
|
|
52
|
+
|
|
53
|
+
const eol = eolOf(content)
|
|
54
|
+
const lines = [`invalid_at: ${yamlScalar(invalidAt)}`]
|
|
55
|
+
if (supersededBy) lines.push(`superseded_by: ${yamlScalar(supersededBy)}`)
|
|
56
|
+
|
|
57
|
+
const m = content.match(FRONTMATTER_RE)
|
|
58
|
+
if (!m) {
|
|
59
|
+
// 没有 frontmatter:补一个**最小块**,正文原样跟在后面。失效标记必须有地方放,
|
|
60
|
+
// 而「拒绝标记」会让用户在面板上点了没反应 —— 那是更坏的一种静默。
|
|
61
|
+
const next = ['---', ...lines, '---', '', content].join(eol)
|
|
62
|
+
return { ok: true, content: next, changed: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const body = m[1].split(/\r?\n/).filter((line) => !VALIDITY_LINE_RE.test(line))
|
|
66
|
+
const block = ['---', ...body, ...lines, '---'].join(eol)
|
|
67
|
+
const next = content.slice(0, m.index) + block + content.slice(m.index + m[0].length)
|
|
68
|
+
return { ok: true, content: next, changed: next !== content }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 删掉 `invalid_at` / `superseded_by` 两行(撤销失效)。
|
|
73
|
+
*
|
|
74
|
+
* 只删这两行 —— 「撤销」不是「把文件恢复成某个版本」,其它任何改动都不动。
|
|
75
|
+
* 两行本来就不存在时返回 `changed:false`(幂等)。
|
|
76
|
+
*/
|
|
77
|
+
export function removeValidityLines(content) {
|
|
78
|
+
if (typeof content !== 'string') return { ok: false, code: 'invalid', error: '记忆内容必须是字符串' }
|
|
79
|
+
const m = content.match(FRONTMATTER_RE)
|
|
80
|
+
if (!m) return { ok: true, content, changed: false }
|
|
81
|
+
const eol = eolOf(content)
|
|
82
|
+
const body = m[1].split(/\r?\n/)
|
|
83
|
+
const kept = body.filter((line) => !VALIDITY_LINE_RE.test(line))
|
|
84
|
+
if (kept.length === body.length) return { ok: true, content, changed: false }
|
|
85
|
+
const block = ['---', ...kept, '---'].join(eol)
|
|
86
|
+
const next = content.slice(0, m.index) + block + content.slice(m.index + m[0].length)
|
|
87
|
+
return { ok: true, content: next, changed: next !== content }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 从宿主的 `fs` 服务取版本(拿不到就是 undefined —— 那就**不写**,不拿「现在」冒充)。 */
|
|
91
|
+
function versionOf(info) {
|
|
92
|
+
return info && info.version !== undefined && info.version !== null ? info.version : undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 这一次写入该带的逐次沙箱策略(与 autodream 同一口径:收窄到记忆目录,`read-only` 不翻案)。 */
|
|
96
|
+
function writeSandboxPolicy(ctx, memoryDir) {
|
|
97
|
+
try {
|
|
98
|
+
const sp = typeof ctx?.get === 'function' ? ctx.get('sandboxPolicy') : undefined
|
|
99
|
+
if (!sp || typeof sp.resolve !== 'function') return undefined
|
|
100
|
+
const policy = sp.resolve()
|
|
101
|
+
if (!policy || policy.mode !== 'workspace-write') return undefined
|
|
102
|
+
return { mode: 'workspace-write', workspaceRoot: memoryDir }
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 宿主写失败的 err.code → 裁决入口的错误码(面板据此给可诊断出路)。 */
|
|
109
|
+
function mapWriteError(err) {
|
|
110
|
+
const code = String(err?.code ?? '')
|
|
111
|
+
if (code === 'FS_STALE_VERSION') return 'stale_version'
|
|
112
|
+
if (code === 'FS_SANDBOX_DENIED') return 'write_denied'
|
|
113
|
+
if (code === 'ENOENT') return 'missing'
|
|
114
|
+
return 'io_error'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hostFs(ctx) {
|
|
118
|
+
try {
|
|
119
|
+
const fs = typeof ctx?.get === 'function' ? ctx.get('fs') : undefined
|
|
120
|
+
return fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function' ? fs : null
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 读出「准备改写的那份记忆」并拿到**读到内容那一刻**的版本。
|
|
128
|
+
*
|
|
129
|
+
* 版本取法照 autodream 的 R01 结论:写前复核一次版本没变,变了就报 `stale_version`
|
|
130
|
+
* (内容在 `stat → read` 之间被改过,我们手里这份已经过期)。
|
|
131
|
+
*/
|
|
132
|
+
async function readForWrite(ctx, memoryDir, file) {
|
|
133
|
+
const safe = safeName(file)
|
|
134
|
+
if (!safe) return { ok: false, code: 'invalid', error: `文件名非法:${String(file)}` }
|
|
135
|
+
const fs = hostFs(ctx)
|
|
136
|
+
if (!fs) return { ok: false, code: 'unsupported', error: '宿主没有 fs 服务,只能手工编辑记忆文件' }
|
|
137
|
+
const full = join(memoryDir, safe)
|
|
138
|
+
|
|
139
|
+
const gate = await inspectMemoryEntry(full)
|
|
140
|
+
if (!gate.ok) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
code: gate.code === 'missing' ? 'missing' : 'invalid',
|
|
144
|
+
error: gate.error,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let before
|
|
149
|
+
try {
|
|
150
|
+
const target = await fs.resolve(full)
|
|
151
|
+
before = await fs.stat(target)
|
|
152
|
+
if (!before) return { ok: false, code: 'missing', error: `记忆不存在:${safe}` }
|
|
153
|
+
// 版本守卫的**前提**:宿主必须给出一个版本。给不出就不能写 ——
|
|
154
|
+
// 「拿一个 undefined 当版本」在宿主那里可能等于「不校验」,那就是一次无守卫覆盖。
|
|
155
|
+
// 这与计划 §5.3 一致:拿不到版本就不写,让人手工编辑(面板会把这句话显示出来)。
|
|
156
|
+
if (versionOf(before) === undefined) {
|
|
157
|
+
return { ok: false, code: 'unsupported', error: '宿主没有给出这份记忆的版本号,无法安全写入(请手工编辑 frontmatter)' }
|
|
158
|
+
}
|
|
159
|
+
const content = await readVerified(full, gate, 'utf8')
|
|
160
|
+
const after = await fs.stat(target)
|
|
161
|
+
if (!after || versionOf(after) !== versionOf(before)) {
|
|
162
|
+
return { ok: false, code: 'stale_version', error: '读取期间文件被改过,请重试' }
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, fs, full, target, version: versionOf(before), content }
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err?.code === 'ENOENT') return { ok: false, code: 'missing', error: `记忆不存在:${safe}` }
|
|
167
|
+
return { ok: false, code: 'io_error', error: String(err?.message ?? err) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 标一条记忆失效:写 `invalid_at`(+ 可选 `superseded_by`),其余字节不动。
|
|
173
|
+
*
|
|
174
|
+
* @param {object} deps `{ctx, memoryDir}`
|
|
175
|
+
* @param {{file:string, invalidAt:string, supersededBy?:string}} input
|
|
176
|
+
* @returns {Promise<{ok:true, file:string, invalidAt:string, version:string, wrote:boolean}
|
|
177
|
+
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
178
|
+
*/
|
|
179
|
+
export async function invalidateMemory({ ctx, memoryDir }, input) {
|
|
180
|
+
const invalidAt = typeof input?.invalidAt === 'string' ? input.invalidAt.trim() : ''
|
|
181
|
+
// 写进用户文件的标记必须是我们能解析的格式:坏值会被 validity 层按「未标注」处理、
|
|
182
|
+
// 记忆照常注入 —— 用户会看到「标了却没用」,那是最难查的一类静默失效。
|
|
183
|
+
if (!parseValidityTime(invalidAt).ok) {
|
|
184
|
+
return { ok: false, code: 'invalid', error: `invalid_at 必须是 ISO 8601 带偏移的时间(收到 ${JSON.stringify(input?.invalidAt)})` }
|
|
185
|
+
}
|
|
186
|
+
const read = await readForWrite(ctx, memoryDir, input?.file)
|
|
187
|
+
if (!read.ok) return read
|
|
188
|
+
const built = upsertValidityLines(read.content, input)
|
|
189
|
+
if (!built.ok) return built
|
|
190
|
+
if (!built.changed) {
|
|
191
|
+
// 幂等:标记已经在那儿了。不写盘,但照样给收据(面板会显示「已失效」)。
|
|
192
|
+
return { ok: true, file: safeName(input.file), invalidAt: String(input.invalidAt), version: String(read.version ?? ''), wrote: false }
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const outcome = await read.fs.writeText(
|
|
196
|
+
read.target,
|
|
197
|
+
built.content,
|
|
198
|
+
{ kind: 'replaceIfVersion', version: read.version },
|
|
199
|
+
undefined,
|
|
200
|
+
writeSandboxPolicy(ctx, memoryDir),
|
|
201
|
+
)
|
|
202
|
+
const version = outcome && outcome.version !== undefined && outcome.version !== null ? String(outcome.version) : ''
|
|
203
|
+
return { ok: true, file: safeName(input.file), invalidAt: String(input.invalidAt), version, wrote: true }
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { ok: false, code: mapWriteError(err), error: String(err?.message ?? err) }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 撤销失效:删掉 `invalid_at` / `superseded_by` 两行,其余字节不动。
|
|
211
|
+
*
|
|
212
|
+
* @returns {Promise<{ok:true, file:string, cleared:boolean, version:string}
|
|
213
|
+
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
214
|
+
*/
|
|
215
|
+
export async function undoMemoryInvalidation({ ctx, memoryDir }, file) {
|
|
216
|
+
const read = await readForWrite(ctx, memoryDir, file)
|
|
217
|
+
if (!read.ok) return read
|
|
218
|
+
const built = removeValidityLines(read.content)
|
|
219
|
+
if (!built.ok) return built
|
|
220
|
+
if (!built.changed) {
|
|
221
|
+
return { ok: true, file: safeName(file), cleared: false, version: String(read.version ?? '') }
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const outcome = await read.fs.writeText(
|
|
225
|
+
read.target,
|
|
226
|
+
built.content,
|
|
227
|
+
{ kind: 'replaceIfVersion', version: read.version },
|
|
228
|
+
undefined,
|
|
229
|
+
writeSandboxPolicy(ctx, memoryDir),
|
|
230
|
+
)
|
|
231
|
+
const version = outcome && outcome.version !== undefined && outcome.version !== null ? String(outcome.version) : ''
|
|
232
|
+
return { ok: true, file: safeName(file), cleared: true, version }
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { ok: false, code: mapWriteError(err), error: String(err?.message ?? err) }
|
|
235
|
+
}
|
|
236
|
+
}
|
package/lib/memory/config.js
CHANGED
|
@@ -146,6 +146,22 @@ export const RECALL_DISCIPLINE = [
|
|
|
146
146
|
*/
|
|
147
147
|
export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* 2.0.0:有效性过滤(`invalid_at`)与矛盾抑制的总开关(设计稿 §11.2 Q8)。
|
|
151
|
+
*
|
|
152
|
+
* 为什么要有:这是本版唯一会**让注入内容变少**的行为变更。用户遇到「某条记忆怎么不来了」
|
|
153
|
+
* 时,最便宜的处置应该是关掉它、而不是降级整个包(降级还会丢掉本版其它修复)。
|
|
154
|
+
*
|
|
155
|
+
* 只认**明确的关闭值**(`0` / `false` / `off`),其余一律当作开启 —— 「设了但拼错」
|
|
156
|
+
* 不该悄悄把行为改回旧版;反过来,空值 / 未设 与 1.9.x 的差别只是多解析了三个键。
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ **每次调用现读** `process.env`:测试要在子进程里设它,模块加载时读死就测不了。
|
|
159
|
+
*/
|
|
160
|
+
export function validityEnabled() {
|
|
161
|
+
const raw = String(process.env.SAGE_MEM_VALIDITY ?? '').trim().toLowerCase()
|
|
162
|
+
return !(raw === '0' || raw === 'false' || raw === 'off')
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
/**
|
|
150
166
|
* 读使用者自己声明的额外保护名单:`<状态根>/reserved.json`,形如 `["a.md","b.md"]`。
|
|
151
167
|
*
|