dsh-selfnote 0.13.1 → 0.13.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/README.md +23 -0
- package/lib/client.js +89 -4
- package/lib/index.js +289 -38
- package/lib/store.js +132 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -252,6 +252,29 @@ DSH 的会话文件是**逐条追写**的 zstd 序列(一次写入 = 一个 fr
|
|
|
252
252
|
- **采纳 / 退役都要人点**:出现次数再多也不会自动采纳;退役的事实不会因为新候选出现就自己复活。
|
|
253
253
|
- **留痕**:每次改状态记下时间、从哪改的(`via: settings`)、前后状态(`history`),事后可查。
|
|
254
254
|
|
|
255
|
+
### 候选为空时,怎么知道它到底有没有在听
|
|
256
|
+
|
|
257
|
+
`GET /api/selfnote/capture` 是这块的诊断口(要带 `x-selfnote` 头,值见 `~/.dsh/selfnote/auth-token`):
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"since": "2026-11-04T07:31:02.114Z",
|
|
262
|
+
"events": 160,
|
|
263
|
+
"types": { "user/message": 12, "job/registered": 40, "turn/ended": 108 },
|
|
264
|
+
"reasons": { "written": 1, "no-candidate": 11, "not-user-authored": 148 },
|
|
265
|
+
"candidates": 1,
|
|
266
|
+
"written": 1,
|
|
267
|
+
"lastType": "turn/ended",
|
|
268
|
+
"trace": [{ "at": "…", "sid": "session-", "type": "user/message", "seq": 91, "reason": "no-candidate" }]
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- `types` 是事件类型直方图:`user/message` 在涨,说明**你的话真的进来了**;不涨才是链路断了。
|
|
273
|
+
- `reasons` 是每一条的最后落点:`written` 写出了候选、`no-candidate` 过了人审但没命中触发词、`not-user-authored` 不是人打的、`no-session` 没有会话 id、`error` 采集抛错。
|
|
274
|
+
- `trace` 保留最近 40 条,带时间、会话前缀、seq 和落点。
|
|
275
|
+
|
|
276
|
+
候选区空着有两种情况,这里能分开:**没在听**(`types` 里没有 `user/message`)和**在听但没话可采**(`no-candidate` 在涨)。这两个计数是 0.13.2 才补的 —— 上一版有两个计数器声明了从来没人加一,读出来永远 0,排查时反而把结论带偏。
|
|
277
|
+
|
|
255
278
|
## 注入预算
|
|
256
279
|
|
|
257
280
|
每回合五段,合计 800 字(自述占最大一份):
|
package/lib/client.js
CHANGED
|
@@ -155,6 +155,11 @@ window.__ModuleLoader__.load({
|
|
|
155
155
|
var headingTuple = useState('')
|
|
156
156
|
var heading = headingTuple[0]
|
|
157
157
|
var setHeading = headingTuple[1]
|
|
158
|
+
// 新写的那条算哪一型:核心先装、稳定默认、临时只活当下
|
|
159
|
+
// 放在最后:上面那些 useState 的调用序号被测试依赖,别插在中间
|
|
160
|
+
var draftTierTuple = useState('stable')
|
|
161
|
+
var draftTier = draftTierTuple[0]
|
|
162
|
+
var setDraftTier = draftTierTuple[1]
|
|
158
163
|
|
|
159
164
|
function apply(data) {
|
|
160
165
|
setState(data)
|
|
@@ -299,11 +304,12 @@ window.__ModuleLoader__.load({
|
|
|
299
304
|
return
|
|
300
305
|
}
|
|
301
306
|
setBusy('note')
|
|
302
|
-
apiPost('/note', { text: text })
|
|
307
|
+
apiPost('/note', { text: text, tier: draftTier })
|
|
303
308
|
.then(function (data) {
|
|
304
309
|
apply(data)
|
|
305
310
|
setDraft('')
|
|
306
|
-
|
|
311
|
+
var add = data.noteAdd || {}
|
|
312
|
+
setNotice(add.warning ? { tone: 'warn', text: '写好了,但' + add.warning } : { tone: 'ok', text: '写好了。' })
|
|
307
313
|
})
|
|
308
314
|
.catch(function (error) {
|
|
309
315
|
setNotice({ tone: 'warn', text: '写入失败:' + error.message })
|
|
@@ -351,6 +357,22 @@ window.__ModuleLoader__.load({
|
|
|
351
357
|
})
|
|
352
358
|
}
|
|
353
359
|
|
|
360
|
+
// 改类型 / pin:正文一个字不动,只换这条排在哪一队
|
|
361
|
+
function setNoteTier(index, patch) {
|
|
362
|
+
setBusy('note-meta')
|
|
363
|
+
apiPost('/note/meta', { index: index, tier: patch.tier, pin: patch.pin })
|
|
364
|
+
.then(function (data) {
|
|
365
|
+
apply(data)
|
|
366
|
+
setNotice({ tone: 'ok', text: '改好了,下一回合按新的位置装。' })
|
|
367
|
+
})
|
|
368
|
+
.catch(function (error) {
|
|
369
|
+
setNotice({ tone: 'warn', text: '没改成:' + error.message })
|
|
370
|
+
})
|
|
371
|
+
.then(function () {
|
|
372
|
+
setBusy('')
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
354
376
|
function removeNote(index) {
|
|
355
377
|
if (pendingDelete !== index) {
|
|
356
378
|
setPendingDelete(index)
|
|
@@ -1049,12 +1071,31 @@ window.__ModuleLoader__.load({
|
|
|
1049
1071
|
h(
|
|
1050
1072
|
'div',
|
|
1051
1073
|
{ className: 'sn-actions' },
|
|
1074
|
+
h(
|
|
1075
|
+
'select',
|
|
1076
|
+
{
|
|
1077
|
+
className: 'sn-tier',
|
|
1078
|
+
value: draftTier,
|
|
1079
|
+
title: '核心=人格与长期身份(先装,最多占自述段的 60%);稳定=表达习惯与工作偏好;临时=当前阶段在做什么',
|
|
1080
|
+
onChange: function (e) { setDraftTier(e.target.value) },
|
|
1081
|
+
},
|
|
1082
|
+
h('option', { value: 'core' }, '核心身份'),
|
|
1083
|
+
h('option', { value: 'stable' }, '稳定偏好'),
|
|
1084
|
+
h('option', { value: 'temp' }, '临时自述'),
|
|
1085
|
+
),
|
|
1052
1086
|
h('button', { className: 'sn-btn', onClick: addNote, disabled: busy !== '' }, '写进去'),
|
|
1053
1087
|
h('span', { className: 'sn-grow' }),
|
|
1054
1088
|
h('button', { className: 'sn-btn sn-btn-danger', onClick: clearAll, disabled: busy !== '' }, confirming ? '真的清空' : '清空当前(进归档)'),
|
|
1055
1089
|
),
|
|
1056
1090
|
)
|
|
1057
1091
|
|
|
1092
|
+
var tierCounts = { core: 0, stable: 0, temp: 0, expired: 0 }
|
|
1093
|
+
notes.forEach(function (entry) {
|
|
1094
|
+
var key = entry.tier === 'core' || entry.tier === 'temp' ? entry.tier : 'stable'
|
|
1095
|
+
tierCounts[key] += 1
|
|
1096
|
+
if (entry.expired === true) tierCounts.expired += 1
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1058
1099
|
var notesList = h(
|
|
1059
1100
|
'div',
|
|
1060
1101
|
{ className: 'sn-card', key: 'notes' },
|
|
@@ -1062,7 +1103,13 @@ window.__ModuleLoader__.load({
|
|
|
1062
1103
|
h(
|
|
1063
1104
|
'div',
|
|
1064
1105
|
{ className: 'sn-hint' },
|
|
1065
|
-
'
|
|
1106
|
+
'按「核心 → 稳定(★ 先装)→ 临时」的顺序装,合计 ≤ ' + caps.notes + ' 字(核心最多占 60%)。放不下的整条跳过、不截半句,注入里会留一句「另有 N 条」当门牌。改完/删完下一回合生效。',
|
|
1107
|
+
),
|
|
1108
|
+
h(
|
|
1109
|
+
'div',
|
|
1110
|
+
{ className: 'sn-hint' },
|
|
1111
|
+
'核心 ' + tierCounts.core + ' 条 稳定 ' + tierCounts.stable + ' 条 临时 ' + tierCounts.temp + ' 条' +
|
|
1112
|
+
(tierCounts.expired > 0 ? '(其中 ' + tierCounts.expired + ' 条已过期,不再自动进场)' : ''),
|
|
1066
1113
|
),
|
|
1067
1114
|
reversed.length === 0
|
|
1068
1115
|
? h('div', { className: 'sn-empty' }, '还没有任何自述。模型攒到第一条时会出现在这里。')
|
|
@@ -1096,12 +1143,46 @@ window.__ModuleLoader__.load({
|
|
|
1096
1143
|
h('button', { className: 'sn-btn sn-btn-ghost', onClick: cancelEditNote, disabled: busy !== '' }, '取消'),
|
|
1097
1144
|
),
|
|
1098
1145
|
)
|
|
1099
|
-
: h(
|
|
1146
|
+
: h(
|
|
1147
|
+
'span',
|
|
1148
|
+
{ className: 'sn-text' },
|
|
1149
|
+
entry.text,
|
|
1150
|
+
entry.expired === true
|
|
1151
|
+
? h(
|
|
1152
|
+
'span',
|
|
1153
|
+
{ className: 'sn-expired', title: '临时自述过了保质期,不再自动进上下文;内容还在,改成「稳定」或重写一条就回来' },
|
|
1154
|
+
'已过期',
|
|
1155
|
+
)
|
|
1156
|
+
: null,
|
|
1157
|
+
),
|
|
1100
1158
|
isEditing
|
|
1101
1159
|
? null
|
|
1102
1160
|
: h(
|
|
1103
1161
|
'span',
|
|
1104
1162
|
{ className: 'sn-row-actions' },
|
|
1163
|
+
h(
|
|
1164
|
+
'select',
|
|
1165
|
+
{
|
|
1166
|
+
className: 'sn-tier',
|
|
1167
|
+
value: entry.tier || 'stable',
|
|
1168
|
+
title: '这条属于哪一型:核心先装(人格、长期身份)、稳定按 pin 排、临时最后',
|
|
1169
|
+
onChange: function (e) { setNoteTier(stored, { tier: e.target.value }) },
|
|
1170
|
+
disabled: busy !== '',
|
|
1171
|
+
},
|
|
1172
|
+
h('option', { value: 'core' }, '核心'),
|
|
1173
|
+
h('option', { value: 'stable' }, '稳定'),
|
|
1174
|
+
h('option', { value: 'temp' }, '临时'),
|
|
1175
|
+
),
|
|
1176
|
+
h(
|
|
1177
|
+
'button',
|
|
1178
|
+
{
|
|
1179
|
+
className: 'sn-btn sn-btn-ghost sn-pin' + (entry.pin === true ? ' sn-pin-on' : ''),
|
|
1180
|
+
title: entry.pin === true ? '已钉住:同一型里优先装进去(再点取消)' : '钉住:同一型里优先装进去',
|
|
1181
|
+
onClick: function () { setNoteTier(stored, { pin: entry.pin !== true }) },
|
|
1182
|
+
disabled: busy !== '',
|
|
1183
|
+
},
|
|
1184
|
+
entry.pin === true ? '★' : '☆',
|
|
1185
|
+
),
|
|
1105
1186
|
h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { startEditNote(stored, entry.text) }, disabled: busy !== '' }, '改'),
|
|
1106
1187
|
h('button', { className: 'sn-btn sn-btn-danger', onClick: function () { removeNote(stored) }, disabled: busy !== '' }, pendingDelete === stored ? '真的删' : '删'),
|
|
1107
1188
|
),
|
|
@@ -1575,6 +1656,10 @@ window.__ModuleLoader__.load({
|
|
|
1575
1656
|
'.sn-edit{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}',
|
|
1576
1657
|
'.sn-row-actions{display:flex;gap:6px;margin-left:auto;flex:none;opacity:.55;transition:opacity .12s ease}',
|
|
1577
1658
|
'.sn-item:hover .sn-row-actions,.sn-item:focus-within .sn-row-actions{opacity:1}',
|
|
1659
|
+
'.sn-tier{font-size:11px;font-family:inherit;padding:2px 4px;border-radius:6px;border:1px solid var(--dsw-alias-border-subtle,#d1d5db);background:var(--dsw-alias-bg-primary,#fff);color:inherit;cursor:pointer}',
|
|
1660
|
+
'.sn-pin{padding:2px 8px;line-height:1.6}',
|
|
1661
|
+
'.sn-pin-on{color:#b45309;border-color:#fcd34d}',
|
|
1662
|
+
'.sn-expired{font-size:11px;color:#9ca3af;border:1px solid #e5e7eb;border-radius:6px;padding:0 6px;margin-left:6px;white-space:nowrap}',
|
|
1578
1663
|
'.sn-fact-edit{display:flex;flex-direction:column;gap:6px}',
|
|
1579
1664
|
'.sn-empty{font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
|
|
1580
1665
|
'.sn-sources{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}',
|
package/lib/index.js
CHANGED
|
@@ -16,11 +16,14 @@ import {
|
|
|
16
16
|
clearNotes,
|
|
17
17
|
configPath,
|
|
18
18
|
deleteNote,
|
|
19
|
+
isExpiredNote,
|
|
19
20
|
notesPath,
|
|
21
|
+
normalizeTier,
|
|
20
22
|
readAuthToken,
|
|
21
23
|
readConfig,
|
|
22
24
|
readNotes,
|
|
23
25
|
renderNotes,
|
|
26
|
+
setNoteMeta,
|
|
24
27
|
trim,
|
|
25
28
|
selfnoteRoot,
|
|
26
29
|
stats,
|
|
@@ -30,6 +33,7 @@ import {
|
|
|
30
33
|
writeNotes,
|
|
31
34
|
DEFAULT_MAX_CHARS,
|
|
32
35
|
DEFAULT_MAX_ENTRIES,
|
|
36
|
+
DEFAULT_TEMP_TTL_DAYS,
|
|
33
37
|
MAX_ENTRY_CHARS,
|
|
34
38
|
} from './store.js'
|
|
35
39
|
|
|
@@ -81,16 +85,62 @@ export const name = 'selfnote'
|
|
|
81
85
|
/** 声明式依赖:systemPrompt 注入,tools 写入,webServer 提供设置页 API。 */
|
|
82
86
|
export const inject = ['systemPrompt', 'tools', 'webServer']
|
|
83
87
|
|
|
84
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* 自动采集的现场情况:只用于排查「事件到底有没有到、卡在哪一步」,不参与业务。
|
|
90
|
+
*
|
|
91
|
+
* 以前这里有两个 userAuthored / noSeq 计数器,声明了从来没人加一,读出来永远是 0 ——
|
|
92
|
+
* 排查时反而误导(看起来像「消息一条都没进来」)。现在换成两条真账:
|
|
93
|
+
* types(按事件类型计数)与 reasons(按落点计数),外加最近若干条轨迹。
|
|
94
|
+
*/
|
|
95
|
+
const CAPTURE_TRACE_MAX = 40
|
|
96
|
+
|
|
85
97
|
const captureDebug = {
|
|
98
|
+
since: null,
|
|
86
99
|
events: 0,
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
types: {},
|
|
101
|
+
reasons: {},
|
|
89
102
|
candidates: 0,
|
|
90
103
|
written: 0,
|
|
91
104
|
lastType: null,
|
|
92
105
|
lastKeys: null,
|
|
93
106
|
lastError: null,
|
|
107
|
+
trace: [],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bumpCounter(bucket, key) {
|
|
111
|
+
if (typeof key !== 'string' || key === '') return
|
|
112
|
+
bucket[key] = (bucket[key] ?? 0) + 1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 事件一进来先记账:类型直方图 + 最近几条。返回类型,供后面的落点记录复用。 */
|
|
116
|
+
function noteCaptureEvent(sessionId, event) {
|
|
117
|
+
captureDebug.events += 1
|
|
118
|
+
const type = typeof event?.type === 'string' && event.type !== '' ? event.type : 'unknown'
|
|
119
|
+
bumpCounter(captureDebug.types, type)
|
|
120
|
+
captureDebug.lastType = event?.type ?? null
|
|
121
|
+
captureDebug.lastKeys = event !== null && typeof event === 'object' ? Object.keys(event).slice(0, 12) : null
|
|
122
|
+
uploadTrace({
|
|
123
|
+
at: new Date().toISOString(),
|
|
124
|
+
sid: typeof sessionId === 'string' ? sessionId.slice(0, 8) : '',
|
|
125
|
+
type,
|
|
126
|
+
seq: Number.isSafeInteger(event?.seq) ? event.seq : null,
|
|
127
|
+
reason: null,
|
|
128
|
+
})
|
|
129
|
+
return type
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 这一轮采集最后落在哪:写进去了 / 没候选 / 不是人说的 / 没 seq …… */
|
|
133
|
+
function noteCaptureOutcome(reason) {
|
|
134
|
+
bumpCounter(captureDebug.reasons, reason)
|
|
135
|
+
const last = captureDebug.trace[captureDebug.trace.length - 1]
|
|
136
|
+
if (last !== undefined) last.reason = reason
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function uploadTrace(entry) {
|
|
140
|
+
captureDebug.trace.push(entry)
|
|
141
|
+
if (captureDebug.trace.length > CAPTURE_TRACE_MAX) {
|
|
142
|
+
captureDebug.trace.splice(0, captureDebug.trace.length - CAPTURE_TRACE_MAX)
|
|
143
|
+
}
|
|
94
144
|
}
|
|
95
145
|
|
|
96
146
|
const API_PREFIX = '/api/selfnote'
|
|
@@ -109,6 +159,8 @@ const DEFAULT_CONFIG = {
|
|
|
109
159
|
halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
|
|
110
160
|
/** 自定义「写自述的时机」;留空就用工具说明里的默认判据。 */
|
|
111
161
|
noteGuide: '',
|
|
162
|
+
/** 临时自述的保质期(天):过了就不自动进场,只留在库里。核心与稳定型不受影响。 */
|
|
163
|
+
tempTtlDays: DEFAULT_TEMP_TTL_DAYS,
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的事,是记我自己的。
|
|
@@ -122,6 +174,7 @@ const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的
|
|
|
122
174
|
写法:
|
|
123
175
|
- 第一人称,一句话到三句话;写「我」的做法和判断,不复述用户的原话。
|
|
124
176
|
- 一条写一件事。空话、套话、复述人格设定都不值得写。
|
|
177
|
+
- 写的时候用 tier 标明这是哪一型:core=核心身份(人格、长期身份、基本原则)、stable=稳定偏好(语言、表达习惯、工作偏好)、temp=临时自述(当前阶段的目标、近期变化)。不写按 stable 算。
|
|
125
178
|
- 只追加,不能改写历史。写满后最旧的条目自动进归档,不会丢,只是不再每回合注入。
|
|
126
179
|
- 这是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据别写。
|
|
127
180
|
- 记用户本人(名字、习惯、处境、在意的点)是有用的,但那些更适合进事实库:用 memory_stage。
|
|
@@ -142,6 +195,15 @@ const PARAMETERS = {
|
|
|
142
195
|
type: 'string',
|
|
143
196
|
description: 'action=write 时的正文,一句话到三句话,第一人称。最多 600 字,超出截断。',
|
|
144
197
|
},
|
|
198
|
+
tier: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
enum: ['core', 'stable', 'temp'],
|
|
201
|
+
description: '这条属于哪一型:core=核心身份(人格、长期身份、基本原则,先装);stable=稳定偏好(表达习惯、工作偏好,默认);temp=临时自述(当前阶段的目标、近期变化,最后装)。',
|
|
202
|
+
},
|
|
203
|
+
pin: {
|
|
204
|
+
type: 'boolean',
|
|
205
|
+
description: '钉住:同一型里优先装进上下文。只给「绝不能忘」的那一两条用,钉多了等于没钉。',
|
|
206
|
+
},
|
|
145
207
|
},
|
|
146
208
|
required: ['action'],
|
|
147
209
|
}
|
|
@@ -169,6 +231,9 @@ function currentConfig(inline) {
|
|
|
169
231
|
// 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
|
|
170
232
|
heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
|
|
171
233
|
noteGuide: typeof merged.noteGuide === 'string' ? merged.noteGuide.trim().slice(0, GUIDE_LIMIT) : '',
|
|
234
|
+
tempTtlDays: Number.isFinite(Number(merged.tempTtlDays))
|
|
235
|
+
? Math.max(1, Math.min(Math.round(Number(merged.tempTtlDays)), 90))
|
|
236
|
+
: DEFAULT_TEMP_TTL_DAYS,
|
|
172
237
|
}
|
|
173
238
|
}
|
|
174
239
|
|
|
@@ -187,6 +252,14 @@ function limitsOf(cfg) {
|
|
|
187
252
|
const BUDGET = { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
|
|
188
253
|
/** 五段合计,别在别处再手写一个数字。 */
|
|
189
254
|
export const TOTAL_BUDGET = BUDGET.notes + BUDGET.profile + BUDGET.env + BUDGET.facts + BUDGET.habits
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 自述段的内部分账。核心身份(人格、长期身份)先装,最多吃 60%:
|
|
258
|
+
* 人格优先,但不能一条人格把整个自述段占死,后面的人味就全没了。
|
|
259
|
+
*/
|
|
260
|
+
const CORE_SHARE = 0.6
|
|
261
|
+
/** 临时自述的保底比例:别让长期偏好把「当前阶段在做什么」全部挤掉。 */
|
|
262
|
+
const TEMP_SHARE = 0.25
|
|
190
263
|
/** 自定义「写自述的时机」那行的上限(字)。 */
|
|
191
264
|
export const GUIDE_LIMIT = 80
|
|
192
265
|
/** 注入抬头那一行的上限(字)。它是每回合固定开销,不设限等于让人用一句话吃掉预算。 */
|
|
@@ -310,6 +383,129 @@ function fit(items, limit) {
|
|
|
310
383
|
return fitDetailed(items, limit).text
|
|
311
384
|
}
|
|
312
385
|
|
|
386
|
+
/**
|
|
387
|
+
* 自述段的三型分账。顺序就是优先级:
|
|
388
|
+
* 核心(旧→新,人格底子先在场)→ 稳定(pin 先,再新→旧)→ 临时(新→旧)。
|
|
389
|
+
* 稳定段有上限(给临时留 1/4 保底),临时段拿走剩下的全部 —— 临时条目都很短,通常吃不完。
|
|
390
|
+
* 为什么要分:混着装的时候,一条 90 字的人格自述和五条「今晚提醒」抢同一个 200 字,
|
|
391
|
+
* 抢输的总是人格那条,而且没人看得出来谁被挤掉了。
|
|
392
|
+
* 临时型还会过期:过了 tempTtlDays 就不再自动进场(数据一个字没删,只是不进上下文)。
|
|
393
|
+
*/
|
|
394
|
+
function packNotes(entries, limit, ttl) {
|
|
395
|
+
const ttlDays = Number.isFinite(Number(ttl)) ? Number(ttl) : DEFAULT_TEMP_TTL_DAYS
|
|
396
|
+
const core = []
|
|
397
|
+
const stable = []
|
|
398
|
+
const temp = []
|
|
399
|
+
const expired = []
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
const tier = normalizeTier(entry?.tier)
|
|
402
|
+
if (tier === 'core') core.push(entry)
|
|
403
|
+
else if (tier === 'temp') {
|
|
404
|
+
if (isExpiredNote(entry, ttlDays)) expired.push(entry)
|
|
405
|
+
else temp.push(entry)
|
|
406
|
+
} else stable.push(entry)
|
|
407
|
+
}
|
|
408
|
+
const coreList = core.map((entry) => entry.text)
|
|
409
|
+
const stableList = stable
|
|
410
|
+
.slice()
|
|
411
|
+
.reverse()
|
|
412
|
+
.sort((a, b) => (b.pin === true ? 1 : 0) - (a.pin === true ? 1 : 0))
|
|
413
|
+
.map((entry) => entry.text)
|
|
414
|
+
const tempList = temp.slice().reverse().map((entry) => entry.text)
|
|
415
|
+
|
|
416
|
+
const coreLimit = Math.min(limit, Math.round(limit * CORE_SHARE))
|
|
417
|
+
const coreFit = fitDetailed(coreList, coreLimit)
|
|
418
|
+
let used = coreFit.text === '' ? 0 : coreFit.text.length + 1
|
|
419
|
+
const tail = Math.max(0, limit - used)
|
|
420
|
+
const tempFloor = tempList.length === 0 ? 0 : Math.min(tail, Math.max(24, Math.round(tail * TEMP_SHARE)))
|
|
421
|
+
const stableLimit = Math.max(0, tail - tempFloor)
|
|
422
|
+
const stableFit = fitDetailed(stableList, stableLimit)
|
|
423
|
+
used += stableFit.text === '' ? 0 : stableFit.text.length + 1
|
|
424
|
+
const tempLimit = Math.max(0, limit - used)
|
|
425
|
+
const tempFit = fitDetailed(tempList, tempLimit)
|
|
426
|
+
|
|
427
|
+
const parts = [coreFit.text, stableFit.text, tempFit.text].filter((part) => part !== '')
|
|
428
|
+
// 过期的也算「没进上下文」:它们还在库里,模型得靠门牌知道有这么几条,
|
|
429
|
+
// 所以并进 skipped 一起报数 —— 少报就是骗人。
|
|
430
|
+
const expiredTexts = expired.map((entry) => entry.text)
|
|
431
|
+
return {
|
|
432
|
+
text: parts.join('\n'),
|
|
433
|
+
kept: [...coreFit.kept, ...stableFit.kept, ...tempFit.kept],
|
|
434
|
+
skipped: [...coreFit.skipped, ...stableFit.skipped, ...tempFit.skipped, ...expiredTexts],
|
|
435
|
+
// 每型的账:设置页要能指着说「核心 3 条全进了,临时 2 条被挤掉」
|
|
436
|
+
tiers: {
|
|
437
|
+
core: { total: coreList.length, kept: coreFit.kept.length, skipped: coreFit.skipped.length, limit: coreLimit },
|
|
438
|
+
stable: { total: stableList.length, kept: stableFit.kept.length, skipped: stableFit.skipped.length, limit: stableLimit },
|
|
439
|
+
temp: {
|
|
440
|
+
total: tempList.length + expired.length,
|
|
441
|
+
kept: tempFit.kept.length,
|
|
442
|
+
skipped: tempFit.skipped.length,
|
|
443
|
+
expired: expired.length,
|
|
444
|
+
limit: tempLimit,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 自述段 + 门牌。
|
|
452
|
+
* 门牌必须收敛:它自己占字,加进去可能又挤掉一条,条数一变门牌上的数字就是错的。
|
|
453
|
+
* 所以两轮:先按全长排,用剩下的条数算门牌,再按「减掉门牌」的长度重排,直到条数不再变。
|
|
454
|
+
*/
|
|
455
|
+
function notesSegment(entries, limit, ttl) {
|
|
456
|
+
const packed = packNotes(entries, limit, ttl)
|
|
457
|
+
if (packed.skipped.length === 0) return { ...packed, pointer: '' }
|
|
458
|
+
let current = packed
|
|
459
|
+
let pointer = ''
|
|
460
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
461
|
+
const next = `另有 ${current.skipped.length} 条自述(self_note read)`
|
|
462
|
+
if (next.length + 1 >= limit) break
|
|
463
|
+
const refit = packNotes(entries, Math.max(0, limit - next.length - 1), ttl)
|
|
464
|
+
const converged = refit.skipped.length === current.skipped.length
|
|
465
|
+
pointer = next
|
|
466
|
+
current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
|
|
467
|
+
if (converged) break
|
|
468
|
+
}
|
|
469
|
+
if (pointer !== '') current.text = current.text.includes(pointer) ? current.text : `${current.text} ${pointer}`.trim()
|
|
470
|
+
return { ...current, pointer }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 结论段打包:要连 id 一起留下(归属统计靠它),所以不能直接用 fitDetailed。 */
|
|
474
|
+
function packFacts(ranked, limit) {
|
|
475
|
+
const kept = []
|
|
476
|
+
const skipped = []
|
|
477
|
+
let used = 0
|
|
478
|
+
for (const item of ranked) {
|
|
479
|
+
const text = String(item?.text ?? '').trim()
|
|
480
|
+
if (text === '') continue
|
|
481
|
+
if (used + text.length > limit) {
|
|
482
|
+
skipped.push(text)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
kept.push(item)
|
|
486
|
+
used += text.length + 1
|
|
487
|
+
}
|
|
488
|
+
return { text: kept.map((item) => item.text).join('\n'), kept, skipped, used }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 结论段 + 门牌:和自述同一套收敛逻辑,否则「另有 N 条」会少报。 */
|
|
492
|
+
function factsSegment(ranked, limit) {
|
|
493
|
+
const packed = packFacts(ranked, limit)
|
|
494
|
+
if (packed.skipped.length === 0) return { ...packed, pointer: '' }
|
|
495
|
+
let current = packed
|
|
496
|
+
let pointer = ''
|
|
497
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
498
|
+
const next = `另有 ${current.skipped.length} 条结论(memory_recall 查)`
|
|
499
|
+
if (next.length + 1 >= limit) break
|
|
500
|
+
const refit = packFacts(ranked, Math.max(0, limit - next.length - 1))
|
|
501
|
+
const converged = refit.skipped.length === current.skipped.length
|
|
502
|
+
pointer = next
|
|
503
|
+
current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
|
|
504
|
+
if (converged) break
|
|
505
|
+
}
|
|
506
|
+
return { ...current, pointer }
|
|
507
|
+
}
|
|
508
|
+
|
|
313
509
|
let injectCache = { key: '', text: '' }
|
|
314
510
|
|
|
315
511
|
/** 注入文本的缓存键:三份数据只要有一份变了,就重算。 */
|
|
@@ -325,8 +521,11 @@ function injectionKey(cfg) {
|
|
|
325
521
|
}
|
|
326
522
|
})
|
|
327
523
|
const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
|
|
524
|
+
// 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
|
|
525
|
+
// 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
|
|
526
|
+
const dayKey = 'd' + Math.floor(Date.now() / 86400000)
|
|
328
527
|
// 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
|
|
329
|
-
return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays
|
|
528
|
+
return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
|
|
330
529
|
}
|
|
331
530
|
|
|
332
531
|
/**
|
|
@@ -338,14 +537,18 @@ function assembleInjection(cfg, options = {}) {
|
|
|
338
537
|
const segments = []
|
|
339
538
|
const parts = []
|
|
340
539
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
BUDGET.notes,
|
|
347
|
-
|
|
348
|
-
|
|
540
|
+
// 自述:三型分账 + 门牌。装不下的那几条,模型必须知道存在,否则它会把「没看见」当成「没有」。
|
|
541
|
+
const noteFit = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays)
|
|
542
|
+
segments.push({
|
|
543
|
+
key: 'notes',
|
|
544
|
+
label: '自述',
|
|
545
|
+
limit: BUDGET.notes,
|
|
546
|
+
chars: noteFit.text.length,
|
|
547
|
+
kept: noteFit.kept,
|
|
548
|
+
skipped: noteFit.skipped,
|
|
549
|
+
tiers: noteFit.tiers,
|
|
550
|
+
pointer: noteFit.pointer ?? '',
|
|
551
|
+
})
|
|
349
552
|
if (noteFit.text !== '') parts.push(noteFit.text)
|
|
350
553
|
|
|
351
554
|
// 自定义「写自述的时机」:工具说明在注册时就定型了,改一句要重启;
|
|
@@ -407,10 +610,8 @@ function assembleInjection(cfg, options = {}) {
|
|
|
407
610
|
if (envLine !== '') parts.push(envLine)
|
|
408
611
|
|
|
409
612
|
const ranked = factsForInjection(cfg)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
BUDGET.facts,
|
|
413
|
-
)
|
|
613
|
+
// 结论段也带门牌:装不下的不只是「没用」,是「查得到但现在不在场」
|
|
614
|
+
const factFit = factsSegment(ranked, BUDGET.facts)
|
|
414
615
|
// 只有真的进了上下文才算「用到」;被检索到但没进上下文的不涨热度。
|
|
415
616
|
// 画像段也是真贴进提示词的一行 —— 被画像吸收的条目同样要记账,
|
|
416
617
|
// 否则「关于他:…」里天天在用的那句会一直显示「0 次 / 从没进过」。
|
|
@@ -420,10 +621,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
420
621
|
const placements = {}
|
|
421
622
|
for (const id of profile.sources ?? []) placements[id] = 'profile'
|
|
422
623
|
for (const id of env.ids) placements[id] = 'env'
|
|
423
|
-
|
|
424
|
-
for (const item of ranked) {
|
|
425
|
-
if (factChars + item.text.length > BUDGET.facts) continue
|
|
426
|
-
factChars += item.text.length + 1
|
|
624
|
+
for (const item of factFit.kept) {
|
|
427
625
|
factIds.push(item.id)
|
|
428
626
|
placements[item.id] = 'facts'
|
|
429
627
|
}
|
|
@@ -433,8 +631,9 @@ function assembleInjection(cfg, options = {}) {
|
|
|
433
631
|
label: '结论',
|
|
434
632
|
limit: BUDGET.facts,
|
|
435
633
|
chars: factFit.text.length,
|
|
436
|
-
kept: factFit.kept,
|
|
634
|
+
kept: factFit.kept.map((item) => item.text),
|
|
437
635
|
skipped: factFit.skipped,
|
|
636
|
+
pointer: factFit.pointer ?? '',
|
|
438
637
|
absorbed: factFit.text === '' ? (profile.sources ?? []).length : 0,
|
|
439
638
|
})
|
|
440
639
|
if (factFit.text !== '') parts.push(factFit.text)
|
|
@@ -1189,7 +1388,9 @@ function stateOfInner(inline) {
|
|
|
1189
1388
|
entries: s.entries,
|
|
1190
1389
|
chars: s.chars,
|
|
1191
1390
|
archiveFiles: s.archiveFiles,
|
|
1192
|
-
|
|
1391
|
+
// 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
|
|
1392
|
+
notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
|
|
1393
|
+
tempTtlDays: cfg.tempTtlDays,
|
|
1193
1394
|
noteGuide: cfg.noteGuide,
|
|
1194
1395
|
notesLastWrite: s.lastWrite,
|
|
1195
1396
|
notesFile: s.notesFile,
|
|
@@ -1272,14 +1473,11 @@ function currentProfileText(cfg) {
|
|
|
1272
1473
|
function budgetOf(inline) {
|
|
1273
1474
|
const cfg = currentConfig(inline)
|
|
1274
1475
|
if (!cfg.enabled) return { total: 0, limit: TOTAL_BUDGET, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
|
|
1275
|
-
|
|
1476
|
+
// 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上(门牌也要算进去)
|
|
1477
|
+
const notes = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays).text
|
|
1276
1478
|
const profile = currentProfile(cfg)
|
|
1277
|
-
// 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上
|
|
1278
1479
|
const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
|
|
1279
|
-
const facts =
|
|
1280
|
-
factsForInjection(cfg).map((item) => item.text),
|
|
1281
|
-
BUDGET.facts,
|
|
1282
|
-
)
|
|
1480
|
+
const facts = factsSegment(factsForInjection(cfg), BUDGET.facts).text
|
|
1283
1481
|
const habits = fit(readHabits().filter((habit) => habit.status === 'active').map((habit) => '· ' + habit.text), BUDGET.habits)
|
|
1284
1482
|
return {
|
|
1285
1483
|
enabled: true,
|
|
@@ -1295,11 +1493,34 @@ function budgetOf(inline) {
|
|
|
1295
1493
|
}
|
|
1296
1494
|
}
|
|
1297
1495
|
|
|
1496
|
+
const TIER_WORD = { core: '核心身份', stable: '稳定偏好', temp: '临时自述' }
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* 写完的那条到底进不进得了上下文 —— 提前说清楚。
|
|
1500
|
+
* 超过整段预算的条目,保存会成功、注入会静默跳过,人只会觉得「我写了怎么没生效」。
|
|
1501
|
+
*/
|
|
1502
|
+
function noteCapacityWarning(tier, text, inline) {
|
|
1503
|
+
const cfg = currentConfig(inline)
|
|
1504
|
+
if (cfg.enabled === false) return '注入已关闭,这条暂时不会进上下文。'
|
|
1505
|
+
const key = normalizeTier(tier)
|
|
1506
|
+
const body = String(text ?? '')
|
|
1507
|
+
const caps = { core: Math.round(BUDGET.notes * CORE_SHARE), stable: BUDGET.notes, temp: BUDGET.notes }
|
|
1508
|
+
if (body.length > caps[key]) {
|
|
1509
|
+
return `这条 ${body.length} 字,超过${TIER_WORD[key]}段的上限(约 ${caps[key]} 字),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
|
|
1510
|
+
}
|
|
1511
|
+
const info = packNotes(readNotes(), BUDGET.notes, cfg.tempTtlDays).tiers[key]
|
|
1512
|
+
if (info.skipped > 0) return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
|
|
1513
|
+
return ''
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1298
1516
|
function renderText(args, value) {
|
|
1299
1517
|
if (value?.error) return `⚠️ ${value.error}`
|
|
1300
1518
|
if (value.action === 'write') {
|
|
1301
1519
|
const tail = value.archived > 0 ? `,最旧的 ${value.archived} 条已归档到 ${value.archivedFile}` : ''
|
|
1302
|
-
|
|
1520
|
+
const kind = TIER_WORD[value.tier ?? 'stable'] ?? TIER_WORD.stable
|
|
1521
|
+
const pin = value.pin === true ? '、已 pin' : ''
|
|
1522
|
+
const warn = value.warning ? `\n注意:${value.warning}` : ''
|
|
1523
|
+
return `已写下(${kind}${pin})。现在生效 ${value.entries} 条 / ${value.chars} 字${tail}。${warn}`
|
|
1303
1524
|
}
|
|
1304
1525
|
if (value.action === 'read') {
|
|
1305
1526
|
if (value.entries === 0) return '还没有任何自述。'
|
|
@@ -1769,15 +1990,18 @@ export function apply(ctx, config) {
|
|
|
1769
1990
|
if (action === 'write') {
|
|
1770
1991
|
const text = String(args?.text ?? '').trim()
|
|
1771
1992
|
if (text === '') return { action, error: 'text 不能为空' }
|
|
1772
|
-
const result = appendNote(text, limits)
|
|
1993
|
+
const result = appendNote(text, limits, { tier: args?.tier, pin: args?.pin })
|
|
1773
1994
|
const after = stats()
|
|
1774
1995
|
return {
|
|
1775
1996
|
action,
|
|
1776
1997
|
text,
|
|
1998
|
+
tier: result.tier,
|
|
1999
|
+
pin: result.pin,
|
|
1777
2000
|
entries: after.entries,
|
|
1778
2001
|
chars: after.chars,
|
|
1779
2002
|
archived: result.archived,
|
|
1780
2003
|
archivedFile: result.archivedFile || '',
|
|
2004
|
+
warning: noteCapacityWarning(result.tier, text, inline),
|
|
1781
2005
|
}
|
|
1782
2006
|
}
|
|
1783
2007
|
if (action === 'read') {
|
|
@@ -2008,6 +2232,13 @@ export function apply(ctx, config) {
|
|
|
2008
2232
|
}
|
|
2009
2233
|
next.profileMaxChars = Math.round(value)
|
|
2010
2234
|
}
|
|
2235
|
+
if (body.tempTtlDays !== undefined) {
|
|
2236
|
+
const value = Number(body.tempTtlDays)
|
|
2237
|
+
if (!Number.isFinite(value) || value < 1 || value > 90) {
|
|
2238
|
+
return sendJson(res, 400, { ok: false, error: 'tempTtlDays 要在 1–90 天之间' })
|
|
2239
|
+
}
|
|
2240
|
+
next.tempTtlDays = Math.round(value)
|
|
2241
|
+
}
|
|
2011
2242
|
writeConfig(next)
|
|
2012
2243
|
return sendJson(res, 200, stateOf(inline))
|
|
2013
2244
|
}
|
|
@@ -2015,15 +2246,30 @@ export function apply(ctx, config) {
|
|
|
2015
2246
|
const body = await readBody(req)
|
|
2016
2247
|
const text = typeof body?.text === 'string' ? body.text.trim() : ''
|
|
2017
2248
|
if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
|
|
2018
|
-
appendNote(text, limitsOf(currentConfig(inline)))
|
|
2019
|
-
|
|
2249
|
+
const result = appendNote(text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
|
|
2250
|
+
auditAction('note-add', { tier: result.tier, pin: result.pin, chars: text.length }, req)
|
|
2251
|
+
return sendJson(res, 200, {
|
|
2252
|
+
...stateOf(inline),
|
|
2253
|
+
noteAdd: { tier: result.tier, pin: result.pin, warning: noteCapacityWarning(result.tier, text, inline) },
|
|
2254
|
+
})
|
|
2255
|
+
}
|
|
2256
|
+
// 只改类型 / pin:把一条人格自述标成「核心身份」,就是这个接口
|
|
2257
|
+
if (req.method === 'POST' && route === '/note/meta') {
|
|
2258
|
+
const body = await readBody(req)
|
|
2259
|
+
try {
|
|
2260
|
+
const result = setNoteMeta(body?.index, { tier: body?.tier, pin: body?.pin })
|
|
2261
|
+
auditAction('note-meta', { index: body?.index, tier: result.tier, pin: result.pin }, req)
|
|
2262
|
+
return sendJson(res, 200, { ...stateOf(inline), noteMeta: result })
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
return sendJson(res, 400, { ok: false, error: error?.message ?? String(error) })
|
|
2265
|
+
}
|
|
2020
2266
|
}
|
|
2021
2267
|
// 改一条 / 删一条。0.11.x 之前只有「追加」和「整份清空」:
|
|
2022
2268
|
// 注入里出现一句话说错了,只能整份清掉重写 —— 这就是「看得见、改不掉」。
|
|
2023
2269
|
if (req.method === 'POST' && route === '/note/update') {
|
|
2024
2270
|
const body = await readBody(req)
|
|
2025
2271
|
try {
|
|
2026
|
-
const result = updateNote(body?.index, body?.text, limitsOf(currentConfig(inline)))
|
|
2272
|
+
const result = updateNote(body?.index, body?.text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
|
|
2027
2273
|
auditAction('note-update', { index: body?.index, archived: result.archived }, req)
|
|
2028
2274
|
return sendJson(res, 200, { ...stateOf(inline), noteUpdate: result })
|
|
2029
2275
|
} catch (error) {
|
|
@@ -2209,12 +2455,14 @@ export function apply(ctx, config) {
|
|
|
2209
2455
|
/* ---- 自动采集(P1-B):只发现,不确认 ---- */
|
|
2210
2456
|
// session/event 是 fire-and-forget:这里绝不能把异常抛回宿主,也不能阻塞对话
|
|
2211
2457
|
if (typeof ctx?.on === 'function') {
|
|
2458
|
+
captureDebug.since = new Date().toISOString()
|
|
2212
2459
|
const offCapture = ctx.on('session/event', (session, event) => {
|
|
2213
|
-
|
|
2214
|
-
captureDebug.lastType = event?.type ?? null
|
|
2215
|
-
captureDebug.lastKeys = event !== null && typeof event === 'object' ? Object.keys(event).slice(0, 12) : null
|
|
2460
|
+
const type = noteCaptureEvent(session?.id, event)
|
|
2216
2461
|
const sessionId = session?.id
|
|
2217
|
-
if (typeof sessionId !== 'string' || sessionId === '')
|
|
2462
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
2463
|
+
noteCaptureOutcome('no-session')
|
|
2464
|
+
return
|
|
2465
|
+
}
|
|
2218
2466
|
// 压缩发生了就记一笔:下一轮把这个会话用过的记忆再贴一遍
|
|
2219
2467
|
noteCompaction(session, event)
|
|
2220
2468
|
// 同一会话按到达顺序串行,避免 seq=11 先完成把游标推到 11、把 seq=10 挤掉
|
|
@@ -2229,17 +2477,20 @@ export function apply(ctx, config) {
|
|
|
2229
2477
|
)
|
|
2230
2478
|
.then((result) => {
|
|
2231
2479
|
captureDebug.candidates += result?.plan?.created?.length ?? 0
|
|
2232
|
-
|
|
2480
|
+
const written = result?.handled === true
|
|
2481
|
+
if (written) {
|
|
2233
2482
|
captureDebug.written += 1
|
|
2234
2483
|
const plan = result.plan ?? {}
|
|
2235
2484
|
ctx.logger?.info?.(
|
|
2236
2485
|
`selfnote: 采集到 ${plan.created?.length ?? 0} 条候选(合并 ${plan.merged?.length ?? 0},冲突 ${plan.conflicts?.length ?? 0})`,
|
|
2237
2486
|
)
|
|
2238
2487
|
}
|
|
2488
|
+
noteCaptureOutcome(written ? 'written' : String(result?.reason ?? type + ':unknown'))
|
|
2239
2489
|
})
|
|
2240
2490
|
.catch((error) => {
|
|
2241
2491
|
// 采集坏掉不许影响对话
|
|
2242
2492
|
captureDebug.lastError = String(error?.message ?? error)
|
|
2493
|
+
noteCaptureOutcome('error')
|
|
2243
2494
|
ctx.logger?.warn?.('selfnote: 自动采集失败 ' + String(error?.message ?? error))
|
|
2244
2495
|
})
|
|
2245
2496
|
})
|
package/lib/store.js
CHANGED
|
@@ -21,6 +21,49 @@ export const DEFAULT_MAX_ENTRIES = 40
|
|
|
21
21
|
/** 单条自述的硬上限,防止一次写入撑爆。 */
|
|
22
22
|
export const MAX_ENTRY_CHARS = 600
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 自述分三型。混在一起装的时候,人格和「今晚推个提醒」抢同一份预算,
|
|
26
|
+
* 抢输的永远是人味那一条 —— 所以分开排:
|
|
27
|
+
* core 核心身份(人格、长期身份、基本原则),先装,独立配额
|
|
28
|
+
* stable 稳定偏好(语言、表达习惯、工作偏好),按 pin → 时间
|
|
29
|
+
* temp 临时自述(当前阶段的目标、近期变化),最后装,时间倒序
|
|
30
|
+
*/
|
|
31
|
+
export const NOTE_TIERS = ['core', 'stable', 'temp']
|
|
32
|
+
/** 类型在 notes.md 里写成中文:人打开文件看得懂、改得动,不用查代码。 */
|
|
33
|
+
export const TIER_TAG = { core: '核心', stable: '稳定', temp: '临时' }
|
|
34
|
+
const TAG_TO_TIER = { 核心: 'core', 稳定: 'stable', 临时: 'temp' }
|
|
35
|
+
/** 没打标记的按「稳定」算:存量条目零迁移,行为跟改版前一模一样。 */
|
|
36
|
+
export const DEFAULT_TIER = 'stable'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 临时自述的保质期(天)。过了就不再每回合自动在场 —— 只留在库里,等人自己翻,
|
|
40
|
+
* 这样「这周在弄什么」不会一直跟人格抢预算。核心与稳定型永不到期。
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_TEMP_TTL_DAYS = 14
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 一条自述到期没有。
|
|
46
|
+
* date 只有「年月日」(没有时刻),按当天零点算;正好第 N 天还在保质期内,
|
|
47
|
+
* 免得写出「今天刚写、明天就过期」这种一日游。
|
|
48
|
+
*/
|
|
49
|
+
export function isExpiredNote(entry, ttlDays = DEFAULT_TEMP_TTL_DAYS, now = Date.now()) {
|
|
50
|
+
if (normalizeTier(entry?.tier) !== 'temp') return false
|
|
51
|
+
const raw = typeof entry?.date === 'string' ? entry.date.trim() : ''
|
|
52
|
+
const at = Date.parse(`${raw}T00:00:00`)
|
|
53
|
+
if (!Number.isFinite(at)) return false
|
|
54
|
+
const days = Number.isFinite(Number(ttlDays)) ? Math.max(1, Math.round(Number(ttlDays))) : DEFAULT_TEMP_TTL_DAYS
|
|
55
|
+
return now - at > days * 86400000
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 把外部传进来的 tier 收成合法值,认不出就是默认型(不抛错 —— 别为了个标记把写自述弄失败)。 */
|
|
59
|
+
export function normalizeTier(value) {
|
|
60
|
+
if (typeof value !== 'string') return DEFAULT_TIER
|
|
61
|
+
const raw = value.trim().toLowerCase()
|
|
62
|
+
if (NOTE_TIERS.includes(raw)) return raw
|
|
63
|
+
// 也认中文写法,方便人直接对着接口手搓
|
|
64
|
+
return TAG_TO_TIER[raw] ?? TAG_TO_TIER[value.trim()] ?? DEFAULT_TIER
|
|
65
|
+
}
|
|
66
|
+
|
|
24
67
|
function dshHome() {
|
|
25
68
|
const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
|
|
26
69
|
return fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
|
|
@@ -49,17 +92,38 @@ export function today() {
|
|
|
49
92
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
|
50
93
|
}
|
|
51
94
|
|
|
52
|
-
/**
|
|
95
|
+
/** 正文归一:回车换行压成空格、去掉首尾空白、截到硬上限。 */
|
|
96
|
+
function cleanEntryText(text) {
|
|
97
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 把 notes.md 解析成 [{ date, text, tier, pin }],忽略空行与注释。
|
|
102
|
+
* 行格式:`- 2026-11-04 [核心*] 正文` —— 日期可省、标记可省、`*` 表示 pin。
|
|
103
|
+
* 标记认不出就原样留在正文里:宁可多显示一个怪括号,也不能吞掉人写的内容。
|
|
104
|
+
*/
|
|
53
105
|
export function parseNotes(raw) {
|
|
54
106
|
const out = []
|
|
55
107
|
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
56
108
|
const trimmed = line.trim()
|
|
57
109
|
if (trimmed === '' || trimmed.startsWith('#')) continue
|
|
58
|
-
const m = trimmed.match(/^[-*]\s+(\d{4}-\d{2}-\d{2})?\s*(.*)$/)
|
|
110
|
+
const m = trimmed.match(/^[-*]\s+(\d{4}-\d{2}-\d{2})?\s*(?:\[([^\]]{1,8})\]\s*)?(.*)$/)
|
|
59
111
|
if (!m) continue
|
|
60
|
-
|
|
112
|
+
let text = (m[3] ?? '').trim()
|
|
113
|
+
let tier = DEFAULT_TIER
|
|
114
|
+
let pin = false
|
|
115
|
+
const tag = (m[2] ?? '').trim()
|
|
116
|
+
if (tag !== '') {
|
|
117
|
+
const hit = tag.match(/^(核心|稳定|临时)\s*(\*)?$/)
|
|
118
|
+
if (hit) {
|
|
119
|
+
tier = TAG_TO_TIER[hit[1]]
|
|
120
|
+
pin = hit[2] === '*'
|
|
121
|
+
} else {
|
|
122
|
+
text = `[${tag}] ${text}`.trim()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
61
125
|
if (text === '') continue
|
|
62
|
-
out.push({ date: m[1] ?? '', text })
|
|
126
|
+
out.push({ date: m[1] ?? '', text, tier, pin })
|
|
63
127
|
}
|
|
64
128
|
return out
|
|
65
129
|
}
|
|
@@ -71,9 +135,20 @@ export function readNotes() {
|
|
|
71
135
|
return parseNotes(readFileSync(file, 'utf8'))
|
|
72
136
|
}
|
|
73
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 标记只在「非默认」时才写出来:稳定型且没 pin 的条目不带前缀,
|
|
140
|
+
* 存量 notes.md 不会因为升级平白多出一堆 `[稳定]`。
|
|
141
|
+
*/
|
|
142
|
+
function tagOf(entry) {
|
|
143
|
+
const tier = normalizeTier(entry?.tier)
|
|
144
|
+
const pin = entry?.pin === true
|
|
145
|
+
if (tier === DEFAULT_TIER && !pin) return ''
|
|
146
|
+
return `[${TIER_TAG[tier]}${pin ? '*' : ''}] `
|
|
147
|
+
}
|
|
148
|
+
|
|
74
149
|
/** 渲染成注入/展示用的文本(最新在后)。 */
|
|
75
150
|
export function renderNotes(entries) {
|
|
76
|
-
return entries.map((entry) => `- ${entry.date ? `${entry.date} ` : ''}${entry.text}`).join('\n')
|
|
151
|
+
return entries.map((entry) => `- ${entry.date ? `${entry.date} ` : ''}${tagOf(entry)}${entry.text}`).join('\n')
|
|
77
152
|
}
|
|
78
153
|
|
|
79
154
|
/** 写回当前生效的自述(整份重写,内容不超过上限)。 */
|
|
@@ -114,18 +189,19 @@ export function archive(entries) {
|
|
|
114
189
|
}
|
|
115
190
|
|
|
116
191
|
/** 追加一条自述并裁剪归档。返回 { kept, archived, notesFile }。 */
|
|
117
|
-
export function appendNote(text, limits) {
|
|
118
|
-
const clean =
|
|
192
|
+
export function appendNote(text, limits, meta = {}) {
|
|
193
|
+
const clean = cleanEntryText(text)
|
|
119
194
|
if (clean === '') throw new Error('自述内容不能为空')
|
|
120
195
|
const entries = readNotes()
|
|
121
|
-
|
|
196
|
+
const entry = { date: today(), text: clean, tier: normalizeTier(meta.tier), pin: meta.pin === true }
|
|
197
|
+
entries.push(entry)
|
|
122
198
|
const { kept, overflow } = trim(entries, limits)
|
|
123
199
|
// 先归档、再改 notes.md:反过来的话,归档一抛错(磁盘满、被占用),
|
|
124
200
|
// 被挤出预算的那几条既已从 notes.md 消失、又没进归档,等于静默丢掉。
|
|
125
201
|
// 万一 notes.md 写失败,最坏是归档里有重复,还能人工删 —— 丢内容没法补。
|
|
126
202
|
const archivedFile = archive(overflow)
|
|
127
203
|
writeNotes(kept)
|
|
128
|
-
return { kept: kept.length, archived: overflow.length, archivedFile, notesFile: notesPath() }
|
|
204
|
+
return { kept: kept.length, archived: overflow.length, archivedFile, notesFile: notesPath(), tier: entry.tier, pin: entry.pin }
|
|
129
205
|
}
|
|
130
206
|
|
|
131
207
|
/**
|
|
@@ -133,21 +209,53 @@ export function appendNote(text, limits) {
|
|
|
133
209
|
* 日期保留原样:改的是措辞,不是「今天又写了一条」。
|
|
134
210
|
* 改长了照样按容量裁剪,被挤出去的进归档 —— 和追加走同一条规则。
|
|
135
211
|
*/
|
|
136
|
-
export function updateNote(index, text, limits) {
|
|
212
|
+
export function updateNote(index, text, limits, meta = {}) {
|
|
137
213
|
const entries = readNotes()
|
|
138
214
|
const at = Number(index)
|
|
139
215
|
if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
|
|
140
216
|
// 列表可能是几秒前拉的:给一句能照着做的话,而不是「非法参数」
|
|
141
217
|
throw new Error('没有这一条了(可能刚被改过),刷新一下再试')
|
|
142
218
|
}
|
|
143
|
-
const clean =
|
|
219
|
+
const clean = cleanEntryText(text)
|
|
144
220
|
if (clean === '') throw new Error('自述内容不能为空')
|
|
145
|
-
|
|
146
|
-
|
|
221
|
+
const prev = entries[at]
|
|
222
|
+
const next = {
|
|
223
|
+
date: prev.date || today(),
|
|
224
|
+
text: clean,
|
|
225
|
+
// meta 没传 = 保留原来那个型,别偷偷把一条核心身份降级成临时
|
|
226
|
+
tier: meta.tier === undefined ? normalizeTier(prev.tier) : normalizeTier(meta.tier),
|
|
227
|
+
pin: meta.pin === undefined ? prev.pin === true : meta.pin === true,
|
|
228
|
+
}
|
|
229
|
+
if (next.text === prev.text && next.tier === normalizeTier(prev.tier) && next.pin === (prev.pin === true)) {
|
|
230
|
+
return { kept: entries.length, archived: 0, unchanged: true, notesFile: notesPath() }
|
|
231
|
+
}
|
|
232
|
+
entries[at] = next
|
|
147
233
|
const { kept, overflow } = trim(entries, limits)
|
|
148
234
|
const archivedFile = archive(overflow)
|
|
149
235
|
writeNotes(kept)
|
|
150
|
-
return { kept: kept.length, archived: overflow.length, archivedFile, unchanged: false, notesFile: notesPath() }
|
|
236
|
+
return { kept: kept.length, archived: overflow.length, archivedFile, unchanged: false, notesFile: notesPath(), tier: next.tier, pin: next.pin }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 只改一条的类型 / pin,不动正文。设置页上「把这条设成核心身份」就是这个接口。
|
|
241
|
+
* 不走 trim:标记只多几个字,为这点长度把一条旧自述挤进归档不值当。
|
|
242
|
+
*/
|
|
243
|
+
export function setNoteMeta(index, meta = {}) {
|
|
244
|
+
const entries = readNotes()
|
|
245
|
+
const at = Number(index)
|
|
246
|
+
if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
|
|
247
|
+
throw new Error('没有这一条了(可能刚被改过),刷新一下再试')
|
|
248
|
+
}
|
|
249
|
+
const prev = entries[at]
|
|
250
|
+
const next = {
|
|
251
|
+
date: prev.date,
|
|
252
|
+
text: prev.text,
|
|
253
|
+
tier: meta.tier === undefined ? normalizeTier(prev.tier) : normalizeTier(meta.tier),
|
|
254
|
+
pin: meta.pin === undefined ? prev.pin === true : meta.pin === true,
|
|
255
|
+
}
|
|
256
|
+
entries[at] = next
|
|
257
|
+
writeNotes(entries)
|
|
258
|
+
return { kept: entries.length, tier: next.tier, pin: next.pin, notesFile: notesPath() }
|
|
151
259
|
}
|
|
152
260
|
|
|
153
261
|
/** 删一条:先归档再摘除,删掉的东西仍然找得回来(archive/YYYY-MM.md)。 */
|
|
@@ -163,6 +271,13 @@ export function deleteNote(index) {
|
|
|
163
271
|
return { kept: entries.length, removed: dropped, archivedFile, notesFile: notesPath() }
|
|
164
272
|
}
|
|
165
273
|
|
|
274
|
+
/** 三型各几条。 */
|
|
275
|
+
export function countTiers(entries) {
|
|
276
|
+
const out = { core: 0, stable: 0, temp: 0 }
|
|
277
|
+
for (const entry of entries) out[normalizeTier(entry.tier)] += 1
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
166
281
|
/** 统计:生效条数、字符数、归档文件数。 */
|
|
167
282
|
export function stats() {
|
|
168
283
|
const entries = readNotes()
|
|
@@ -176,6 +291,9 @@ export function stats() {
|
|
|
176
291
|
return {
|
|
177
292
|
entries: entries.length,
|
|
178
293
|
chars: renderNotes(entries).length,
|
|
294
|
+
// 三型各几条:设置页要能解释「为什么我这条没进上下文」
|
|
295
|
+
tiers: countTiers(entries),
|
|
296
|
+
pinned: entries.filter((entry) => entry.pin === true).length,
|
|
179
297
|
archiveFiles,
|
|
180
298
|
notesFile: notesPath(),
|
|
181
299
|
// 上次真正写进 notes.md 的时刻:条目里只存日期(YYYY-MM-DD),看不出「几小时前」,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-selfnote",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-narrative memory for DeepSeek Harness: the model writes notes to itself, which persist across sessions and are injected back. | 人格库:模型写给自己看的话,跨会话保留并每回合注入。",
|
|
6
6
|
"keywords": [
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"assets"
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/fair.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/client-state.mjs && node tests/client.mjs",
|
|
30
|
+
"test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/fair.mjs && node tests/capture-debug.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/notes-tier.mjs && node tests/client-state.mjs && node tests/client.mjs",
|
|
31
31
|
"test:accept": "node tests/acceptance-runtime.mjs"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|