dsh-selfnote 0.13.2 → 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/lib/client.js +89 -4
- package/lib/index.js +230 -30
- package/lib/store.js +132 -14
- package/package.json +2 -2
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
|
|
|
@@ -155,6 +159,8 @@ const DEFAULT_CONFIG = {
|
|
|
155
159
|
halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
|
|
156
160
|
/** 自定义「写自述的时机」;留空就用工具说明里的默认判据。 */
|
|
157
161
|
noteGuide: '',
|
|
162
|
+
/** 临时自述的保质期(天):过了就不自动进场,只留在库里。核心与稳定型不受影响。 */
|
|
163
|
+
tempTtlDays: DEFAULT_TEMP_TTL_DAYS,
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的事,是记我自己的。
|
|
@@ -168,6 +174,7 @@ const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的
|
|
|
168
174
|
写法:
|
|
169
175
|
- 第一人称,一句话到三句话;写「我」的做法和判断,不复述用户的原话。
|
|
170
176
|
- 一条写一件事。空话、套话、复述人格设定都不值得写。
|
|
177
|
+
- 写的时候用 tier 标明这是哪一型:core=核心身份(人格、长期身份、基本原则)、stable=稳定偏好(语言、表达习惯、工作偏好)、temp=临时自述(当前阶段的目标、近期变化)。不写按 stable 算。
|
|
171
178
|
- 只追加,不能改写历史。写满后最旧的条目自动进归档,不会丢,只是不再每回合注入。
|
|
172
179
|
- 这是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据别写。
|
|
173
180
|
- 记用户本人(名字、习惯、处境、在意的点)是有用的,但那些更适合进事实库:用 memory_stage。
|
|
@@ -188,6 +195,15 @@ const PARAMETERS = {
|
|
|
188
195
|
type: 'string',
|
|
189
196
|
description: 'action=write 时的正文,一句话到三句话,第一人称。最多 600 字,超出截断。',
|
|
190
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
|
+
},
|
|
191
207
|
},
|
|
192
208
|
required: ['action'],
|
|
193
209
|
}
|
|
@@ -215,6 +231,9 @@ function currentConfig(inline) {
|
|
|
215
231
|
// 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
|
|
216
232
|
heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
|
|
217
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,
|
|
218
237
|
}
|
|
219
238
|
}
|
|
220
239
|
|
|
@@ -233,6 +252,14 @@ function limitsOf(cfg) {
|
|
|
233
252
|
const BUDGET = { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
|
|
234
253
|
/** 五段合计,别在别处再手写一个数字。 */
|
|
235
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
|
|
236
263
|
/** 自定义「写自述的时机」那行的上限(字)。 */
|
|
237
264
|
export const GUIDE_LIMIT = 80
|
|
238
265
|
/** 注入抬头那一行的上限(字)。它是每回合固定开销,不设限等于让人用一句话吃掉预算。 */
|
|
@@ -356,6 +383,129 @@ function fit(items, limit) {
|
|
|
356
383
|
return fitDetailed(items, limit).text
|
|
357
384
|
}
|
|
358
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
|
+
|
|
359
509
|
let injectCache = { key: '', text: '' }
|
|
360
510
|
|
|
361
511
|
/** 注入文本的缓存键:三份数据只要有一份变了,就重算。 */
|
|
@@ -371,8 +521,11 @@ function injectionKey(cfg) {
|
|
|
371
521
|
}
|
|
372
522
|
})
|
|
373
523
|
const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
|
|
524
|
+
// 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
|
|
525
|
+
// 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
|
|
526
|
+
const dayKey = 'd' + Math.floor(Date.now() / 86400000)
|
|
374
527
|
// 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
|
|
375
|
-
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
|
|
376
529
|
}
|
|
377
530
|
|
|
378
531
|
/**
|
|
@@ -384,14 +537,18 @@ function assembleInjection(cfg, options = {}) {
|
|
|
384
537
|
const segments = []
|
|
385
538
|
const parts = []
|
|
386
539
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
BUDGET.notes,
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
})
|
|
395
552
|
if (noteFit.text !== '') parts.push(noteFit.text)
|
|
396
553
|
|
|
397
554
|
// 自定义「写自述的时机」:工具说明在注册时就定型了,改一句要重启;
|
|
@@ -453,10 +610,8 @@ function assembleInjection(cfg, options = {}) {
|
|
|
453
610
|
if (envLine !== '') parts.push(envLine)
|
|
454
611
|
|
|
455
612
|
const ranked = factsForInjection(cfg)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
BUDGET.facts,
|
|
459
|
-
)
|
|
613
|
+
// 结论段也带门牌:装不下的不只是「没用」,是「查得到但现在不在场」
|
|
614
|
+
const factFit = factsSegment(ranked, BUDGET.facts)
|
|
460
615
|
// 只有真的进了上下文才算「用到」;被检索到但没进上下文的不涨热度。
|
|
461
616
|
// 画像段也是真贴进提示词的一行 —— 被画像吸收的条目同样要记账,
|
|
462
617
|
// 否则「关于他:…」里天天在用的那句会一直显示「0 次 / 从没进过」。
|
|
@@ -466,10 +621,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
466
621
|
const placements = {}
|
|
467
622
|
for (const id of profile.sources ?? []) placements[id] = 'profile'
|
|
468
623
|
for (const id of env.ids) placements[id] = 'env'
|
|
469
|
-
|
|
470
|
-
for (const item of ranked) {
|
|
471
|
-
if (factChars + item.text.length > BUDGET.facts) continue
|
|
472
|
-
factChars += item.text.length + 1
|
|
624
|
+
for (const item of factFit.kept) {
|
|
473
625
|
factIds.push(item.id)
|
|
474
626
|
placements[item.id] = 'facts'
|
|
475
627
|
}
|
|
@@ -479,8 +631,9 @@ function assembleInjection(cfg, options = {}) {
|
|
|
479
631
|
label: '结论',
|
|
480
632
|
limit: BUDGET.facts,
|
|
481
633
|
chars: factFit.text.length,
|
|
482
|
-
kept: factFit.kept,
|
|
634
|
+
kept: factFit.kept.map((item) => item.text),
|
|
483
635
|
skipped: factFit.skipped,
|
|
636
|
+
pointer: factFit.pointer ?? '',
|
|
484
637
|
absorbed: factFit.text === '' ? (profile.sources ?? []).length : 0,
|
|
485
638
|
})
|
|
486
639
|
if (factFit.text !== '') parts.push(factFit.text)
|
|
@@ -1235,7 +1388,9 @@ function stateOfInner(inline) {
|
|
|
1235
1388
|
entries: s.entries,
|
|
1236
1389
|
chars: s.chars,
|
|
1237
1390
|
archiveFiles: s.archiveFiles,
|
|
1238
|
-
|
|
1391
|
+
// 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
|
|
1392
|
+
notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
|
|
1393
|
+
tempTtlDays: cfg.tempTtlDays,
|
|
1239
1394
|
noteGuide: cfg.noteGuide,
|
|
1240
1395
|
notesLastWrite: s.lastWrite,
|
|
1241
1396
|
notesFile: s.notesFile,
|
|
@@ -1318,14 +1473,11 @@ function currentProfileText(cfg) {
|
|
|
1318
1473
|
function budgetOf(inline) {
|
|
1319
1474
|
const cfg = currentConfig(inline)
|
|
1320
1475
|
if (!cfg.enabled) return { total: 0, limit: TOTAL_BUDGET, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
|
|
1321
|
-
|
|
1476
|
+
// 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上(门牌也要算进去)
|
|
1477
|
+
const notes = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays).text
|
|
1322
1478
|
const profile = currentProfile(cfg)
|
|
1323
|
-
// 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上
|
|
1324
1479
|
const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
|
|
1325
|
-
const facts =
|
|
1326
|
-
factsForInjection(cfg).map((item) => item.text),
|
|
1327
|
-
BUDGET.facts,
|
|
1328
|
-
)
|
|
1480
|
+
const facts = factsSegment(factsForInjection(cfg), BUDGET.facts).text
|
|
1329
1481
|
const habits = fit(readHabits().filter((habit) => habit.status === 'active').map((habit) => '· ' + habit.text), BUDGET.habits)
|
|
1330
1482
|
return {
|
|
1331
1483
|
enabled: true,
|
|
@@ -1341,11 +1493,34 @@ function budgetOf(inline) {
|
|
|
1341
1493
|
}
|
|
1342
1494
|
}
|
|
1343
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
|
+
|
|
1344
1516
|
function renderText(args, value) {
|
|
1345
1517
|
if (value?.error) return `⚠️ ${value.error}`
|
|
1346
1518
|
if (value.action === 'write') {
|
|
1347
1519
|
const tail = value.archived > 0 ? `,最旧的 ${value.archived} 条已归档到 ${value.archivedFile}` : ''
|
|
1348
|
-
|
|
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}`
|
|
1349
1524
|
}
|
|
1350
1525
|
if (value.action === 'read') {
|
|
1351
1526
|
if (value.entries === 0) return '还没有任何自述。'
|
|
@@ -1815,15 +1990,18 @@ export function apply(ctx, config) {
|
|
|
1815
1990
|
if (action === 'write') {
|
|
1816
1991
|
const text = String(args?.text ?? '').trim()
|
|
1817
1992
|
if (text === '') return { action, error: 'text 不能为空' }
|
|
1818
|
-
const result = appendNote(text, limits)
|
|
1993
|
+
const result = appendNote(text, limits, { tier: args?.tier, pin: args?.pin })
|
|
1819
1994
|
const after = stats()
|
|
1820
1995
|
return {
|
|
1821
1996
|
action,
|
|
1822
1997
|
text,
|
|
1998
|
+
tier: result.tier,
|
|
1999
|
+
pin: result.pin,
|
|
1823
2000
|
entries: after.entries,
|
|
1824
2001
|
chars: after.chars,
|
|
1825
2002
|
archived: result.archived,
|
|
1826
2003
|
archivedFile: result.archivedFile || '',
|
|
2004
|
+
warning: noteCapacityWarning(result.tier, text, inline),
|
|
1827
2005
|
}
|
|
1828
2006
|
}
|
|
1829
2007
|
if (action === 'read') {
|
|
@@ -2054,6 +2232,13 @@ export function apply(ctx, config) {
|
|
|
2054
2232
|
}
|
|
2055
2233
|
next.profileMaxChars = Math.round(value)
|
|
2056
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
|
+
}
|
|
2057
2242
|
writeConfig(next)
|
|
2058
2243
|
return sendJson(res, 200, stateOf(inline))
|
|
2059
2244
|
}
|
|
@@ -2061,15 +2246,30 @@ export function apply(ctx, config) {
|
|
|
2061
2246
|
const body = await readBody(req)
|
|
2062
2247
|
const text = typeof body?.text === 'string' ? body.text.trim() : ''
|
|
2063
2248
|
if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
|
|
2064
|
-
appendNote(text, limitsOf(currentConfig(inline)))
|
|
2065
|
-
|
|
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
|
+
}
|
|
2066
2266
|
}
|
|
2067
2267
|
// 改一条 / 删一条。0.11.x 之前只有「追加」和「整份清空」:
|
|
2068
2268
|
// 注入里出现一句话说错了,只能整份清掉重写 —— 这就是「看得见、改不掉」。
|
|
2069
2269
|
if (req.method === 'POST' && route === '/note/update') {
|
|
2070
2270
|
const body = await readBody(req)
|
|
2071
2271
|
try {
|
|
2072
|
-
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 })
|
|
2073
2273
|
auditAction('note-update', { index: body?.index, archived: result.archived }, req)
|
|
2074
2274
|
return sendJson(res, 200, { ...stateOf(inline), noteUpdate: result })
|
|
2075
2275
|
} catch (error) {
|
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/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/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": {
|