dsh-memento 0.2.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/lib/budget.mjs ADDED
@@ -0,0 +1,110 @@
1
+ // lib/budget.mjs — 预算计算(零 DSH 依赖,纯函数)。
2
+ //
3
+ // 每轨每层硬字符预算:user 轨默认 2000/层,agent 轨默认 4000/层(Config
4
+ // budgets 可覆盖;中文场景按需调大并在 PR 说明理由)。计数单位是 JS 字符串
5
+ // 长度(UTF-16 code unit):一个汉字计 1,与用户直觉一致且可预测。
6
+ // 写满必须报错(BUDGET_EXCEEDED),绝不静默截断、绝不自动压缩。
7
+
8
+ import { TRACKS, SCOPES } from './constants.mjs'
9
+
10
+ /**
11
+ * (track, scope) 的当前字符用量。
12
+ * @param {Array<{track: string, scope: string, text: string}>} entries - 该组条目。
13
+ * @param {string} track - 轨道。
14
+ * @param {string} scope - 作用域。
15
+ * @returns {number} 文本长度之和。
16
+ */
17
+ export function entryUsage(entries, track, scope) {
18
+ let used = 0
19
+ for (const entry of entries) {
20
+ if (entry.track === track && entry.scope === scope) used += entry.text.length
21
+ }
22
+ return used
23
+ }
24
+
25
+ /**
26
+ * 全量用量行:每个 track×scope 一行。
27
+ * @param {Array<{track: string, scope: string, text: string}>} entries - 全部条目。
28
+ * @returns {Array<{track: 'user'|'agent', scope: 'user-global'|'workspace', used: number}>} 按轨道/作用域顺序排列。
29
+ */
30
+ export function usageRows(entries) {
31
+ const rows = []
32
+ for (const track of TRACKS) {
33
+ for (const scope of SCOPES) {
34
+ rows.push({ track, scope, used: entryUsage(entries, track, scope) })
35
+ }
36
+ }
37
+ return rows
38
+ }
39
+
40
+ /**
41
+ * 预算检查:当前用量 + 增量是否超限。
42
+ * @param {number} used - 当前用量。
43
+ * @param {number} limit - 硬上限。
44
+ * @param {number} addition - 本次新增字符数(可为负,表示 replace 后的净变化)。
45
+ * @returns {{ok: true} | {ok: false, used: number, limit: number, needed: number}} 纯结果。
46
+ */
47
+ export function checkBudget(used, limit, addition) {
48
+ const needed = used + addition
49
+ if (needed > limit) return { ok: false, used, limit, needed }
50
+ return { ok: true }
51
+ }
52
+
53
+ /**
54
+ * 完整预算报表:每行携带上限。
55
+ * @param {Array<{track: string, scope: string, text: string}>} entries - 全部条目。
56
+ * @param {{user: {userGlobal: number, workspace: number}, agent: {userGlobal: number, workspace: number}}} budgets - 形状 {user: {userGlobal, workspace}, agent: {userGlobal, workspace}}。
57
+ * @returns {Array<{track: 'user'|'agent', scope: 'user-global'|'workspace', used: number, limit: number}>}。
58
+ */
59
+ export function budgetReport(entries, budgets) {
60
+ const limits = budgetLimits(budgets)
61
+ return usageRows(entries).map((row) => ({ ...row, limit: limits[row.track][row.scope] }))
62
+ }
63
+
64
+ /**
65
+ * 把 Config.budgets 规范化为 {track: {scope: limit}} 双键表。
66
+ * @param {{user: {userGlobal: number, workspace: number}, agent: {userGlobal: number, workspace: number}}} budgets - Config.budgets(含 userGlobal/workspace 键)。
67
+ * @returns {{user: {'user-global': number, workspace: number}, agent: {'user-global': number, workspace: number}}}。
68
+ */
69
+ export function budgetLimits(budgets) {
70
+ return {
71
+ user: {
72
+ 'user-global': budgets.user.userGlobal,
73
+ workspace: budgets.user.workspace,
74
+ },
75
+ agent: {
76
+ 'user-global': budgets.agent.userGlobal,
77
+ workspace: budgets.agent.workspace,
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 校验 budgets 配置形状:所有上限必须是正整数;缺失/非法在加载期响亮失败。
84
+ * @param {unknown} budgets - 原始配置。
85
+ * @returns {{ok: true, limits: object} | {ok: false, message: string}}。
86
+ */
87
+ export function validateBudgets(budgets) {
88
+ if (budgets === null || typeof budgets !== 'object') {
89
+ return { ok: false, message: 'budgets must be an object with user/agent tracks and userGlobal/workspace layers' }
90
+ }
91
+ /** @type {Record<string, object>} */
92
+ const limits = {}
93
+ for (const track of TRACKS) {
94
+ const trackConfig = /** @type {{userGlobal?: unknown, workspace?: unknown} | null | undefined} */ (/** @type {Record<string, unknown>} */ (budgets)[track])
95
+ if (trackConfig === null || typeof trackConfig !== 'object') {
96
+ return { ok: false, message: `budgets.${track} must be an object` }
97
+ }
98
+ const layerLimits = /** @type {Record<string, number>} */ ({})
99
+ for (const scope of SCOPES) {
100
+ const key = scope === 'user-global' ? 'userGlobal' : scope
101
+ const limit = /** @type {Record<string, unknown>} */ (trackConfig)[key]
102
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
103
+ return { ok: false, message: `budgets.${track}.${key} must be a positive integer` }
104
+ }
105
+ layerLimits[scope] = limit
106
+ }
107
+ limits[/** @type {string} */ (track)] = layerLimits
108
+ }
109
+ return { ok: true, limits }
110
+ }
@@ -0,0 +1,58 @@
1
+ // lib/constants.mjs — dsh-memento 词汇表与协议常量(零 DSH 依赖)。
2
+ //
3
+ // 本文件只放协议级常量(词汇、错误码、存储格式版本)。部署可调参数一律走
4
+ // index.mjs 的 Schemastery Config,绝不在此写死 tunable。
5
+
6
+ /** 记忆轨道(双轨分家):user=用户画像,agent=环境事实/约定/教训。 */
7
+ export const TRACKS = /** @type {readonly ['user', 'agent']} */ (['user', 'agent'])
8
+
9
+ /** 分层作用域:user-global 跨工作区,workspace 按会话 cwd。 */
10
+ export const SCOPES = /** @type {readonly ['user-global', 'workspace']} */ (['user-global', 'workspace'])
11
+
12
+ /** 写策略(Config.writePolicy):ask=用户审批,auto=放行但记录审批来源,off=拒绝。 */
13
+ export const WRITE_POLICIES = ['ask', 'auto', 'off']
14
+
15
+ /** 审批请求里标识本插件记忆写动作的工具名(审批 answerer 据此认领请求)。 */
16
+ export const TOOL_NAME = 'memory'
17
+
18
+ /** 审批 reason 前缀(含方括号):answerer 只认领带本前缀的 memory 写请求,防止误伤同工具名的其它请求。 */
19
+ export const REQUEST_MARKER = '[dsh-memento]'
20
+
21
+ /** 记忆库 schema 版本(单调递增;旧版逐级迁移,新版响亮拒绝)。v2 = proposals 提案表;v3 = agent_key + 召回排序列。 */
22
+ export const SCHEMA_VERSION = 3
23
+
24
+ /** 数据库文件名(位于 $DSH_HOME/dsh-memento/ 下)。 */
25
+ export const DEFAULT_DB_NAME = 'memory.db'
26
+
27
+ /** 条目默认来源标注(seed 写 source: 'claude' 时除外)。 */
28
+ export const DEFAULT_SOURCE = 'dsh-memento'
29
+
30
+ /** Provider 层查询返回硬上限:显式 limit 的钳制天花板(防模型/面板拉爆上下文)。 */
31
+ export const MAX_QUERY_LIMIT = 1000
32
+
33
+ /** 面板审计路由的返回上限:只读抽屉的审计尾展示天花板(协议常量,非部署 tunable)。 */
34
+ export const PANEL_AUDIT_CEILING = 200
35
+
36
+ /** 结构化错误码:工具与面板据此分支,模型据此决定"整合后重试"还是"放弃"。 */
37
+ export const ERROR_CODES = {
38
+ DISABLED: 'MEMORY_DISABLED',
39
+ INVALID_INPUT: 'INVALID_INPUT',
40
+ NO_AGENT: 'WRITE_REQUIRES_AGENT',
41
+ BUDGET_EXCEEDED: 'BUDGET_EXCEEDED',
42
+ ENTRY_NOT_FOUND: 'ENTRY_NOT_FOUND',
43
+ AMBIGUOUS_MATCH: 'AMBIGUOUS_MATCH',
44
+ WRITE_DENIED: 'WRITE_DENIED',
45
+ PROPOSAL_NOT_FOUND: 'PROPOSAL_NOT_FOUND',
46
+ STORE_CORRUPT: 'STORE_CORRUPT',
47
+ STORE_UNSUPPORTED_VERSION: 'STORE_UNSUPPORTED_VERSION',
48
+ MISSING_DSH_HOME: 'MISSING_DSH_HOME',
49
+ }
50
+
51
+ /** 会话事件名(SessionEventMap 声明合并的词汇表;运行时按已知类型自适应派发)。 */
52
+ export const SESSION_EVENTS = {
53
+ added: 'memory/added',
54
+ updated: 'memory/updated',
55
+ removed: 'memory/removed',
56
+ recalled: 'memory/recalled',
57
+ snapshot: 'memory/snapshot',
58
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // lib/errors.mjs — 结构化错误(零 DSH 依赖)。
2
+ //
3
+ // 所有领域失败都以 MemoryError(或其子类)抛出:携带稳定 code 与 JSON 安全
4
+ // details,供工具层转成规范 JSON 结果(ok:false + error 对象)。基础设施失败
5
+ // (store 损坏等)也走本家族,绝不以字符串错误静默吞掉。
6
+
7
+ import { ERROR_CODES } from './constants.mjs'
8
+
9
+ /**
10
+ * dsh-memento 领域错误基类:稳定 code + 可公开的 details。
11
+ * @param {string} code - ERROR_CODES 成员之一。
12
+ * @param {string} message - 人类可读描述(中文,面向用户与模型)。
13
+ * @param {object} [details] - 附加事实(usage/limit/candidates 等,必须 JSON 安全)。
14
+ */
15
+ export class MemoryError extends Error {
16
+ constructor(/** @type {string} */ code, /** @type {string} */ message, /** @type {{[key: string]: unknown}} */ details = {}) {
17
+ super(message)
18
+ this.name = 'MemoryError'
19
+ this.code = code
20
+ this.details = details
21
+ }
22
+
23
+ /** @returns {{code: string, message: string} & Record<string, unknown>} 工具结果里的规范错误对象。 */
24
+ toPublic() {
25
+ return { code: this.code, message: this.message, ...this.details }
26
+ }
27
+ }
28
+
29
+ /** 非法输入(空文本、越界轨道/作用域、缺失必填字段)。 */
30
+ export class InvalidInputError extends MemoryError {
31
+ /** @param {string} message - 描述哪一项非法。 */
32
+ constructor(message) {
33
+ super(ERROR_CODES.INVALID_INPUT, message)
34
+ this.name = 'InvalidInputError'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 预算超限:写入会使 (track, scope) 字符用量超过硬上限。
40
+ * @param {object} d - {track, scope, used, limit, needed}。
41
+ */
42
+ export class BudgetExceededError extends MemoryError {
43
+ constructor(/** @type {{track: string, scope: string, used: number, limit: number, needed: number}} */ d) {
44
+ super(
45
+ ERROR_CODES.BUDGET_EXCEEDED,
46
+ `memory budget exceeded: ${d.track}/${d.scope} at ${d.used}/${d.limit} chars, this write needs ${d.needed} chars; consolidate or remove entries, then retry`,
47
+ d,
48
+ )
49
+ this.name = 'BudgetExceededError'
50
+ }
51
+ }
52
+
53
+ /** 子串未命中任何条目。 */
54
+ export class EntryNotFoundError extends MemoryError {
55
+ /** @param {{track: string, scope: string, match: string}} d - {track, scope, match}。 */
56
+ constructor(d) {
57
+ super(
58
+ ERROR_CODES.ENTRY_NOT_FOUND,
59
+ `no entry in ${d.track}/${d.scope} contains ${JSON.stringify(d.match)}; nothing was changed`,
60
+ d,
61
+ )
62
+ this.name = 'EntryNotFoundError'
63
+ }
64
+ }
65
+
66
+ /** 子串命中多条:要求模型给更具体的唯一子串。 */
67
+ export class AmbiguousMatchError extends MemoryError {
68
+ /**
69
+ * @param {{track: string, scope: string, match: string, candidates: number, sample: string[]}} d - {track, scope, match, candidates, sample};sample 是截断后的候选文本。
70
+ */
71
+ constructor(d) {
72
+ super(
73
+ ERROR_CODES.AMBIGUOUS_MATCH,
74
+ `match ${JSON.stringify(d.match)} is ambiguous in ${d.track}/${d.scope}: ${d.candidates} entries contain it; use a longer, unique substring`,
75
+ d,
76
+ )
77
+ this.name = 'AmbiguousMatchError'
78
+ }
79
+ }
80
+
81
+ /** 审批未放行(rejected/cancelled/unavailable),或写策略为 off。 */
82
+ export class WriteDeniedError extends MemoryError {
83
+ /**
84
+ * @param {string} outcome - ApprovalOutcome 之一。
85
+ * @param {string} [detail] - 附加原因(如审批服务抛错),拼进消息。
86
+ */
87
+ constructor(outcome, detail) {
88
+ super(
89
+ ERROR_CODES.WRITE_DENIED,
90
+ `memory write not approved (approval outcome: ${outcome}${detail === undefined ? '' : `; ${detail}`}); nothing was written`,
91
+ { outcome },
92
+ )
93
+ this.name = 'WriteDeniedError'
94
+ }
95
+ }
96
+
97
+ /** 插件被禁用(enabled:false)时调用写/读服务。 */
98
+ export class DisabledError extends MemoryError {
99
+ constructor() {
100
+ super(ERROR_CODES.DISABLED, 'dsh-memento is disabled by configuration; no memory service is available')
101
+ this.name = 'DisabledError'
102
+ }
103
+ }
104
+
105
+ /** 提案不存在或已裁决(approve/dismiss 目标必须是 pending 提案)。 */
106
+ export class ProposalNotFoundError extends MemoryError {
107
+ /**
108
+ * @param {string} id - 提案 id。
109
+ * @param {string} [detail] - 附加原因(如已裁决的状态)。
110
+ */
111
+ constructor(id, detail) {
112
+ super(
113
+ ERROR_CODES.PROPOSAL_NOT_FOUND,
114
+ `proposal ${JSON.stringify(id)} is not a pending proposal${detail === undefined ? '' : ` (${detail})`}; nothing was changed`,
115
+ { id },
116
+ )
117
+ this.name = 'ProposalNotFoundError'
118
+ }
119
+ }
120
+
121
+ /** 写路径缺少 agent(无法路由审批):一律失败封闭,绝不静默放行。 */
122
+ export class NoAgentError extends MemoryError {
123
+ constructor() {
124
+ super(
125
+ ERROR_CODES.NO_AGENT,
126
+ 'memory write requires an owning agent session to route approval through; refuse rather than write unapproved',
127
+ )
128
+ this.name = 'NoAgentError'
129
+ }
130
+ }
131
+
132
+ /** 记忆库损坏/版本过新:加载期或最早可解析点响亮失败。 */
133
+ export class StoreError extends MemoryError {
134
+ /**
135
+ * @param {string} code - STORE_CORRUPT 或 STORE_UNSUPPORTED_VERSION。
136
+ * @param {string} message - 具体原因。
137
+ * @param {{[key: string]: unknown}} [details] - {path} 等。
138
+ */
139
+ constructor(code, message, /** @type {{[key: string]: unknown}} */ details = {}) {
140
+ super(code, message, details)
141
+ this.name = 'StoreError'
142
+ }
143
+ }
@@ -0,0 +1,54 @@
1
+ // lib/extract.mjs — 会话事件文本抽取(零 DSH 依赖)。
2
+ //
3
+ // memory_recall 的历史片段显示用:从已知第一方事件形状里提取可读文本。
4
+ // 只用于搜索结果片段(显示截断允许);记忆条目的存储与审计绝不经过本函数。
5
+
6
+ /**
7
+ * 从会话事件里提取可读文本(记忆召回的历史片段用)。
8
+ * @param {unknown} event - SessionEvent 形状({type, data});防御性解析,形状不信任。
9
+ * @returns {string} 新行拼接的可读文本;不认识的事件返回空串。
10
+ */
11
+ export function extractEventText(event) {
12
+ if (event === null || typeof event !== 'object') return ''
13
+ const data = /** @type {{data?: unknown}} */ (event).data
14
+ if (data === null || typeof data !== 'object') {
15
+ return typeof data === 'string' ? data : ''
16
+ }
17
+ const record = /** @type {{content?: unknown, message?: unknown, name?: unknown, arguments?: unknown, todos?: unknown, summary?: unknown, text?: unknown}} */ (data)
18
+ // user/message 与 assistant/message:content 文本块
19
+ if (Array.isArray(record.content)) {
20
+ return contentText(/** @type {unknown[]} */ (record.content))
21
+ }
22
+ // compaction/summary:summary 文本块
23
+ if (Array.isArray(record.summary)) {
24
+ return contentText(/** @type {unknown[]} */ (record.summary))
25
+ }
26
+ // tool/result(及携带 message 的其它事件)
27
+ if (record.message !== null && typeof record.message === 'object') {
28
+ const message = /** @type {{content?: unknown, text?: unknown}} */ (record.message)
29
+ if (Array.isArray(message.content)) return contentText(/** @type {unknown[]} */ (message.content))
30
+ if (typeof message.text === 'string') return message.text
31
+ }
32
+ // tool/call:工具名 + 原始参数
33
+ if (typeof record.name === 'string' && typeof record.arguments === 'string') {
34
+ return `${record.name} ${record.arguments}`
35
+ }
36
+ // todo/write:任务清单
37
+ if (Array.isArray(record.todos)) {
38
+ return /** @type {Array<{content: string}>} */ (record.todos).map((item) => item.content).join('\n')
39
+ }
40
+ // request/header 等:渲染文本字段
41
+ if (typeof record.text === 'string') return record.text
42
+ return ''
43
+ }
44
+
45
+ /** content 文本块拼接(跳过非文本块)。 */
46
+ function contentText(/** @type {unknown[]} */ content) {
47
+ const parts = []
48
+ for (const part of content) {
49
+ if (part !== null && typeof part === 'object' && typeof /** @type {{text?: unknown}} */ (part).text === 'string' && /** @type {{text: string}} */ (part).text.length > 0) {
50
+ parts.push(/** @type {{text: string}} */ (part).text)
51
+ }
52
+ }
53
+ return parts.join('\n')
54
+ }
package/lib/gate.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // lib/gate.mjs — 审批门策略(零 DSH 依赖,纯逻辑)。
2
+ //
3
+ // 写策略(Config.writePolicy,模型不可见、不可改):
4
+ // - ask:委托审批链上其余 answerer(Web UI 人类审批 / ACP 一次性决策),无
5
+ // answerer 时审批服务按失败封闭给出 unavailable → 写被拒。
6
+ // - auto:本插件 answerer 直接放行(allowed-once);审批来源仍随 approval/asked
7
+ // + approval/decided 审计对与审计表记录。
8
+ // - off:本插件 answerer 直接拒绝(rejected),写报结构化错误。
9
+ // 注意:会话级 ApprovalPolicy='never'(用户全局姿态)在审批服务内部、answerer
10
+ // 之前裁决,任何 answerer(含 prepend 注册的本插件)都无法绕过——这是 DSH
11
+ // 审批 seam 的硬不变量,本插件设计上遵从它。
12
+
13
+ import { TOOL_NAME, REQUEST_MARKER, WRITE_POLICIES } from './constants.mjs'
14
+ import { InvalidInputError } from './errors.mjs'
15
+
16
+ /**
17
+ * 判断一条审批请求是否为本插件发出的记忆写请求。
18
+ * 认领条件:toolName === 'memory' 且 reason 带 [dsh-memento] 前缀——防止误伤
19
+ * 其它插件以同名工具身份发出的审批请求。
20
+ * @param {unknown} req - ApprovalRequest 形状(防御性解析)。
21
+ * @returns {boolean} 是否本插件的记忆写请求。
22
+ */
23
+ export function isMemoryWriteRequest(req) {
24
+ if (req === null || typeof req !== 'object') return false
25
+ const candidate = /** @type {{toolName?: unknown, reason?: unknown}} */ (req)
26
+ return candidate.toolName === TOOL_NAME
27
+ && typeof candidate.reason === 'string'
28
+ && candidate.reason.startsWith(REQUEST_MARKER)
29
+ }
30
+
31
+ /**
32
+ * 构造审批 reason:第一行人类可读摘要,随后是完整载荷文本。
33
+ * reason 随 approval/asked 进入会话日志(已知事件类型、可持久化),
34
+ * S2 的"变更可自会话日志重建"由此成立。
35
+ * @param {{action: string, track: string, scope: string, text: string, count?: number, source?: string}} input - {action, track, scope, text, count?, source?};count 用于 seed 批量,source 供粒度策略裁决。
36
+ * @returns {string} 审批 reason。
37
+ */
38
+ export function buildWriteReason({ action, track, scope, text, count, source }) {
39
+ const batch = count === undefined ? '' : ` (${count} entries)`
40
+ const sourceTag = source === undefined ? '' : ` [source:${source}]`
41
+ return `${REQUEST_MARKER} ${action}${batch} ${track}/${scope}${sourceTag}\n${text}`
42
+ }
43
+
44
+ /**
45
+ * 从审批 reason 解析回结构化载荷(审计/重建用)。
46
+ * @param {string} reason - buildWriteReason 的产物。
47
+ * @returns {{action: string, track: string, scope: string, text: string, count?: number, source?: string} | null}
48
+ * 无法解析返回 null(调用方据此响亮报错)。
49
+ */
50
+ export function parseWriteReason(reason) {
51
+ const match = /^\[dsh-memento\] ([a-z]+)(?: \((\d+) entries\))? ([a-z]+)\/([a-z-]+)(?: \[source:([^\]]+)\])?\n([\s\S]*)$/.exec(reason)
52
+ if (match === null) return null
53
+ return {
54
+ action: match[1],
55
+ ...(match[2] === undefined ? {} : { count: Number(match[2]) }),
56
+ track: match[3],
57
+ scope: match[4],
58
+ ...(match[5] === undefined ? {} : { source: match[5] }),
59
+ text: match[6],
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 粒度写策略解析:`source:<source>` 精确键 > `track/scope` 键 > 全局 writePolicy。
65
+ * 未命中回退全局——旧 Config 形状(只有 writePolicy 字符串)向后兼容。
66
+ * @param {Record<string, string>} writePolicies - Config.writePolicies(可空)。
67
+ * @param {string} fallback - 全局 writePolicy。
68
+ * @param {string} track - 轨道。
69
+ * @param {string} scope - 作用域。
70
+ * @param {string} [source] - 来源标注。
71
+ * @returns {string} 生效策略。
72
+ */
73
+ export function resolveWritePolicy(writePolicies, fallback, track, scope, source) {
74
+ if (source !== undefined) {
75
+ const bySource = writePolicies[`source:${source}`]
76
+ if (bySource !== undefined) return bySource
77
+ }
78
+ const byScope = writePolicies[`${track}/${scope}`]
79
+ if (byScope !== undefined) return byScope
80
+ return fallback
81
+ }
82
+
83
+ /**
84
+ * 校验粒度策略表:值必须是 ask/auto/off,键必须是 `track/scope` 或 `source:<name>`。
85
+ * 非法键/值在加载期响亮失败(绝不静默忽略错误配置)。
86
+ * @param {unknown} writePolicies - 原始配置值。
87
+ * @returns {Record<string, string>} 合法策略表。
88
+ */
89
+ export function validateWritePolicies(writePolicies) {
90
+ if (writePolicies === null || typeof writePolicies !== 'object') {
91
+ throw new InvalidInputError('writePolicies must be an object with keys like "user/workspace" or "source:claude"')
92
+ }
93
+ const table = /** @type {Record<string, string>} */ (writePolicies)
94
+ for (const [key, value] of Object.entries(table)) {
95
+ const validKey = /^(user|agent)\/(user-global|workspace)$/.test(key) || /^source:[a-z0-9-]+$/.test(key)
96
+ if (!validKey) {
97
+ throw new InvalidInputError(`invalid writePolicies key ${JSON.stringify(key)} (expected "track/scope" or "source:<name>")`)
98
+ }
99
+ if (!WRITE_POLICIES.includes(value)) {
100
+ throw new InvalidInputError(`invalid writePolicies value for ${JSON.stringify(key)}: ${JSON.stringify(value)} (one of ${WRITE_POLICIES.join('|')})`)
101
+ }
102
+ }
103
+ return table
104
+ }
105
+
106
+ /**
107
+ * 对记忆写请求应用写策略(answerer 本体)。
108
+ * @param {string} policy - writePolicy。
109
+ * @param {unknown} req - ApprovalRequest(已通过 isMemoryWriteRequest 认领)。
110
+ * @param {() => Promise<string>} next - waterfall 续链(ask 时委托人类 answerer)。
111
+ * @returns {Promise<string>} ApprovalOutcome 之一。
112
+ */
113
+ export async function applyWritePolicy(policy, req, next) {
114
+ if (!WRITE_POLICIES.includes(policy)) {
115
+ throw new InvalidInputError(`invalid writePolicy ${JSON.stringify(policy)} (one of ${WRITE_POLICIES.join('|')})`)
116
+ }
117
+ if (policy === 'auto') return 'allowed-once'
118
+ if (policy === 'off') return 'rejected'
119
+ return next()
120
+ }
121
+
122
+ /**
123
+ * 校验并归一化写策略(加载期响亮失败)。
124
+ * @param {unknown} policy - 原始配置值。
125
+ * @returns {string} 合法策略原值。 */
126
+ export function normalizeWritePolicy(policy) {
127
+ if (!WRITE_POLICIES.includes(/** @type {string} */ (policy))) {
128
+ throw new InvalidInputError(`invalid writePolicy ${JSON.stringify(policy)} (one of ${WRITE_POLICIES.join('|')})`)
129
+ }
130
+ return /** @type {string} */ (policy)
131
+ }
package/lib/match.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // lib/match.mjs — 唯一子串匹配(零 DSH 依赖)。
2
+ //
3
+ // replace/remove 的定位语义:在同 (track, scope) 的条目文本里做大小写不敏感
4
+ // 子串匹配(ASCII 折叠;CJK 无大小写不受影响)。零命中 → not-found;多命中 →
5
+ // ambiguous(带候选清单,要求模型给更具体的唯一子串);恰一命中 → ok。
6
+ // 不做 FTS/模糊匹配(小语料;FTS5 trigram 无法索引单字 CJK 字符,见 ARCHITECTURE)。
7
+
8
+ /**
9
+ * 在候选条目里按唯一子串定位(大小写不敏感)。
10
+ * @param {Array<{id: string, text: string}>} entries - 已按 track+scope 过滤的候选条目。
11
+ * @param {string} match - 目标子串(调用方保证非空)。
12
+ * @returns {{kind: 'ok', entry: {id: string, text: string}}
13
+ * | {kind: 'not-found'}
14
+ * | {kind: 'ambiguous', candidates: Array<{id: string, text: string}>}} 纯函数结果,不抛错。
15
+ */
16
+ export function findUniqueMatch(entries, match) {
17
+ const lower = match.toLowerCase()
18
+ const hits = entries.filter((entry) => entry.text.toLowerCase().includes(lower))
19
+ if (hits.length === 0) return { kind: 'not-found' }
20
+ if (hits.length > 1) return { kind: 'ambiguous', candidates: hits }
21
+ return { kind: 'ok', entry: hits[0] }
22
+ }
23
+
24
+ /**
25
+ * 把 findUniqueMatch 的结果转成领域错误(store 层使用)。
26
+ * @param {{kind: string, entry?: {id: string, text: string}, candidates?: Array<{id: string, text: string}>}} result - findUniqueMatch 结果。
27
+ * @param {object} ctx - {track, scope, match},供错误 details 使用。
28
+ * @param {{EntryNotFoundError: new (ctx: object) => Error, AmbiguousMatchError: new (ctx: object) => Error}} errors - 错误类(注入以避免循环依赖)。
29
+ * @returns {{id: string, text: string} | undefined} 命中的条目,或抛 EntryNotFoundError / AmbiguousMatchError。
30
+ */
31
+ export function requireUniqueMatch(result, ctx, errors) {
32
+ if (result.kind === 'ok') return result.entry
33
+ if (result.kind === 'ambiguous') {
34
+ const sample = /** @type {Array<{text: string}>} */ (result.candidates).map((candidate) =>
35
+ candidate.text.length > 200 ? `${candidate.text.slice(0, 200)}…` : candidate.text)
36
+ throw new errors.AmbiguousMatchError({
37
+ ...ctx,
38
+ candidates: /** @type {Array<{id: string, text: string}>} */ (result.candidates).length,
39
+ sample,
40
+ })
41
+ }
42
+ throw new errors.EntryNotFoundError(ctx)
43
+ }
@@ -0,0 +1,115 @@
1
+ // lib/snapshot.mjs — 冻结快照渲染(零 DSH 依赖,纯函数)。
2
+ //
3
+ // 会话启动时把当前记忆渲染成带用量头的冻结块,经 systemPrompt 段注入
4
+ // (system-prompt/assemble 提供 agent,section order = Config.snapshotOrder,
5
+ // 默认 -50:harness identity(-100) 之后、persona(0) 之前)。会话内记忆变更
6
+ // 只落盘+落审计,不更新已注入快照(冻结语义 = 前缀缓存稳定)。
7
+
8
+ import { TRACKS, SCOPES } from './constants.mjs'
9
+ import { budgetReport } from './budget.mjs'
10
+ import { GROUP_TITLES, SNAPSHOT_HEADER, PROPOSAL_HEADER, pick } from './strings.mjs'
11
+
12
+ /**
13
+ * @typedef {object} SnapshotEntry - 快照渲染看到的条目面。
14
+ * @property {string} id
15
+ * @property {string} track
16
+ * @property {string} scope
17
+ * @property {string} workspaceKey
18
+ * @property {string} agentKey
19
+ * @property {string} text
20
+ * @property {number} createdAt
21
+ */
22
+
23
+ /**
24
+ * 过滤某个会话可见的条目:agentKey 为 ''(共享层)或匹配会话 agentKey;scope
25
+ * user-global 全见;workspace 只匹配该会话的 workspaceKey(会话 cwd 的规范化绝对值)。
26
+ * @param {SnapshotEntry[]} entries - 全部条目。
27
+ * @param {string} workspaceKey - 会话 cwd 的规范化绝对值。
28
+ * @param {string} [agentKey] - 会话 agentPreset 的规范化键;'' = 共享层。
29
+ * @returns {SnapshotEntry[]} 可见条目。
30
+ */
31
+ export function visibleEntries(entries, workspaceKey, agentKey = '') {
32
+ return entries.filter((entry) =>
33
+ (entry.agentKey === '' || entry.agentKey === agentKey)
34
+ && (entry.scope === 'user-global' || (entry.scope === 'workspace' && entry.workspaceKey === workspaceKey)))
35
+ }
36
+
37
+ /**
38
+ * 把条目按 (track, scope) 分组并组内按创建时间排序。
39
+ * @param {SnapshotEntry[]} entries - 可见条目。
40
+ * @returns {Map<string, SnapshotEntry[]>} key = 'track/scope',值按 createdAt,id 升序。
41
+ */
42
+ export function groupEntries(entries) {
43
+ const groups = new Map()
44
+ for (const entry of entries) {
45
+ const key = `${entry.track}/${entry.scope}`
46
+ const list = groups.get(key)
47
+ if (list === undefined) groups.set(key, [entry])
48
+ else list.push(entry)
49
+ }
50
+ for (const list of groups.values()) {
51
+ list.sort((/** @type {SnapshotEntry} */ a, /** @type {SnapshotEntry} */ b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
52
+ }
53
+ return groups
54
+ }
55
+
56
+ /**
57
+ * @typedef {object} SnapshotProposal - 快照渲染看到的提案面。
58
+ * @property {string} id
59
+ * @property {string} track
60
+ * @property {string} scope
61
+ * @property {string} workspaceKey
62
+ * @property {string} agentKey
63
+ * @property {string} text
64
+ */
65
+
66
+ /**
67
+ * 过滤某个会话可见的提案:与条目可见性同一语义(agentKey 共享/匹配 +
68
+ * scope 规则)。
69
+ * @param {SnapshotProposal[]} proposals - 全部 pending 提案。
70
+ * @param {string} workspaceKey - 会话 cwd 的规范化绝对值。
71
+ * @param {string} [agentKey] - 会话 agentPreset 的规范化键;'' = 共享层。
72
+ * @returns {SnapshotProposal[]} 可见提案。
73
+ */
74
+ export function visibleProposals(proposals, workspaceKey, agentKey = '') {
75
+ return proposals.filter((proposal) =>
76
+ (proposal.agentKey === '' || proposal.agentKey === agentKey)
77
+ && (proposal.scope === 'user-global' || (proposal.scope === 'workspace' && proposal.workspaceKey === workspaceKey)))
78
+ }
79
+
80
+ /**
81
+ * 渲染冻结快照全文(含每分组用量头)。无任何条目时返回空串——空段不进提示词,
82
+ * 空记忆零 token 成本。带 pending 提案时追加提案块(模型可见 ⟺ 随快照文本
83
+ * 进入 request/header.system,S2 可重建)。
84
+ * @param {SnapshotEntry[]} entries - 会话可见条目。
85
+ * @param {{user: {userGlobal: number, workspace: number}, agent: {userGlobal: number, workspace: number}}} budgets - Config.budgets。
86
+ * @param {SnapshotProposal[]} [proposals] - 会话可见 pending 提案。
87
+ * @param {string} [language] - 'en' | 'zh'(默认 en)。
88
+ * @returns {string} 快照文本。
89
+ */
90
+ export function renderSnapshot(entries, budgets, proposals = [], language = 'en') {
91
+ const groups = groupEntries(entries)
92
+ const report = budgetReport(entries, budgets)
93
+ const titles = /** @type {Record<string, string>} */ (pick(GROUP_TITLES, language))
94
+ const header = /** @type {string} */ (pick(SNAPSHOT_HEADER, language))
95
+ const proposalHeader = /** @type {string} */ (pick(PROPOSAL_HEADER, language))
96
+ const usageSuffix = language === 'zh' ? '已用字符' : 'chars used'
97
+ const sections = []
98
+ for (const track of TRACKS) {
99
+ for (const scope of SCOPES) {
100
+ const key = `${track}/${scope}`
101
+ const list = groups.get(key)
102
+ if (list === undefined || list.length === 0) continue
103
+ const row = report.find((candidate) => candidate.track === track && candidate.scope === scope)
104
+ sections.push(`## ${titles[key]} — ${row.used}/${row.limit} ${usageSuffix}\n${list.map((entry) => `- ${entry.text}`).join('\n')}`)
105
+ }
106
+ }
107
+ if (proposals.length > 0) {
108
+ sections.push([
109
+ `## ${proposalHeader}`,
110
+ proposals.map((proposal) => `- [${proposal.id}] ${proposal.track}/${proposal.scope}: ${proposal.text}`).join('\n'),
111
+ ].join('\n'))
112
+ }
113
+ if (sections.length === 0) return ''
114
+ return [header, ...sections].join('\n\n')
115
+ }