@heixui/dsh-session-suite 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/README.zh.md +24 -3
- package/client.js +67 -4
- package/cordis.patch.yml +27 -0
- package/lib/embeddings.js +239 -0
- package/lib/memory-engine.js +492 -13
- package/lib/memory-host.js +199 -43
- package/lib/retrieval.js +75 -0
- package/package.json +1 -1
package/lib/memory-engine.js
CHANGED
|
@@ -27,8 +27,10 @@ import path from 'node:path'
|
|
|
27
27
|
import { ensureDir, readJson, sha256Text, shortId, writeJson } from './util.js'
|
|
28
28
|
import { applySupersession, isCatchAllKey, normalizeFactKey } from './factkeys.js'
|
|
29
29
|
import { isLoaded, loadWordList, wordTokens } from './wordlist.js'
|
|
30
|
+
import { DISABLED_PROVIDER, createEmbeddingProvider } from './embeddings.js'
|
|
30
31
|
import {
|
|
31
32
|
bm25,
|
|
33
|
+
cosineSimilarity,
|
|
32
34
|
rankIds,
|
|
33
35
|
reciprocalRankFusion,
|
|
34
36
|
tokens,
|
|
@@ -45,6 +47,113 @@ export const DEFAULT_WEIGHTS = { frequency: 0.35, recency: 0.3, affect: 0.15, un
|
|
|
45
47
|
/** Lifecycle thresholds on the 0..1 composite score. */
|
|
46
48
|
export const DEFAULT_THRESHOLDS = { compress: 0.45, archive: 0.25 }
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What kind of memory an entry is.
|
|
52
|
+
*
|
|
53
|
+
* Three of these mirror the standard split of long-term memory, and the split is
|
|
54
|
+
* not cosmetic: each kind decays at a different rate and answers a different
|
|
55
|
+
* question.
|
|
56
|
+
*
|
|
57
|
+
* fact 语义 — a stable truth ("the export inlines images as data URLs")
|
|
58
|
+
* rule 程序 — how something should be done ("refuse over-long memories")
|
|
59
|
+
* summary 聚合 — several entries compressed into one
|
|
60
|
+
* episode 情景 — what happened, when, and how it turned out
|
|
61
|
+
*
|
|
62
|
+
* `episode` is the one the library lacked entirely. Such a memory is not a fact
|
|
63
|
+
* (things can stay true) nor a rule (it prescribes nothing); it records an event
|
|
64
|
+
* with a time and an outcome, e.g. "last week's PPT was rejected for using too many
|
|
65
|
+
* colours". Filing those as `fact` lost the time and the outcome, which are the
|
|
66
|
+
* parts that make them useful later.
|
|
67
|
+
*/
|
|
68
|
+
export const KINDS = ['fact', 'rule', 'summary', 'episode']
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How much faster an episode ages than a fact.
|
|
72
|
+
*
|
|
73
|
+
* An event is dated: "the deployment failed on Tuesday" stops being actionable
|
|
74
|
+
* quickly, while "the project uses pnpm" stays true for years. Halving the
|
|
75
|
+
* half-life is a defensible default rather than a tuned constant — there is no
|
|
76
|
+
* held-out set of episodic queries to fit it against, so claiming a precise value
|
|
77
|
+
* would be unfounded.
|
|
78
|
+
*/
|
|
79
|
+
export const EPISODE_DECAY_FACTOR = 0.5
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How private a memory is.
|
|
83
|
+
*
|
|
84
|
+
* The library had only a scope (global / workspace), which controls *where* a
|
|
85
|
+
* memory is stored but says nothing about *who may see it*. That gap is a real risk:
|
|
86
|
+
* a password or a medical note filed under `workspace` is still exported by the share
|
|
87
|
+
* feature and still injected into every prompt in that workspace.
|
|
88
|
+
*
|
|
89
|
+
* public safe to include in an export someone else may read
|
|
90
|
+
* internal stays on this machine; excluded from exports (the default)
|
|
91
|
+
* secret never injected, never exported, masked in the list
|
|
92
|
+
*
|
|
93
|
+
* The default is `internal`, not `public`: guessing wrong toward privacy costs a
|
|
94
|
+
* little convenience, guessing wrong the other way leaks something.
|
|
95
|
+
*/
|
|
96
|
+
export const PRIVACY_LEVELS = ['public', 'internal', 'secret']
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Patterns that identify a credential or a government id.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately blunt. A false positive only makes a memory harder to read; a false
|
|
102
|
+
* negative puts a secret in an export. Precision matters less than never missing.
|
|
103
|
+
*/
|
|
104
|
+
const SECRET_PATTERNS = [
|
|
105
|
+
/(?:密码|口令|password|passwd|pwd)\s*[:=:]?\s*\S+/i,
|
|
106
|
+
/\b(?:sk|pk|ghp|gho|ghs|xox[baprs])[-_][A-Za-z0-9_-]{12,}/,
|
|
107
|
+
/\b\d{17}[\dXx]\b/, // mainland id number
|
|
108
|
+
/\b(?:\d{15,19})\b/, // bank card
|
|
109
|
+
/\b1[3-9]\d{9}\b/, // mainland mobile
|
|
110
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, // email
|
|
111
|
+
/(?:身份证|银行卡|信用卡|私钥|密钥|token|secret|api[_-]?key)/i,
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
/** Topics that are personal but not credentials. */
|
|
115
|
+
const PRIVATE_TOPICS = /(工资|存款|现金|房租|负债|病历|诊断|吃药|抑郁|焦虑|家庭住址|身份证地址)/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide a privacy level from the text alone.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} text memory text
|
|
121
|
+
* @param {string} [explicit] a level the caller supplied
|
|
122
|
+
* @returns {'public' | 'internal' | 'secret'} level
|
|
123
|
+
*/
|
|
124
|
+
export function inferPrivacy(text, explicit) {
|
|
125
|
+
if (typeof explicit === 'string' && PRIVACY_LEVELS.includes(explicit)) return explicit
|
|
126
|
+
const value = String(text ?? '')
|
|
127
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
128
|
+
if (pattern.test(value)) return 'secret'
|
|
129
|
+
}
|
|
130
|
+
if (PRIVATE_TOPICS.test(value)) return 'internal'
|
|
131
|
+
return 'internal'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether a memory may be shown to someone other than its owner.
|
|
136
|
+
*
|
|
137
|
+
* @param {any} entry memory entry
|
|
138
|
+
* @returns {boolean} true when it may appear in an export
|
|
139
|
+
*/
|
|
140
|
+
export function isExportable(entry) {
|
|
141
|
+
return (entry?.privacy ?? 'internal') === 'public'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether a memory may be injected into a prompt.
|
|
146
|
+
*
|
|
147
|
+
* `secret` is excluded: a credential is not context, and injecting it would copy it
|
|
148
|
+
* into every provider request.
|
|
149
|
+
*
|
|
150
|
+
* @param {any} entry memory entry
|
|
151
|
+
* @returns {boolean} true when it may be injected
|
|
152
|
+
*/
|
|
153
|
+
export function isInjectable(entry) {
|
|
154
|
+
return (entry?.privacy ?? 'internal') !== 'secret'
|
|
155
|
+
}
|
|
156
|
+
|
|
48
157
|
const DEFAULTS = {
|
|
49
158
|
/** Half-life of the recency factor, in days. */
|
|
50
159
|
decayDays: 60,
|
|
@@ -75,6 +184,55 @@ const DEFAULTS = {
|
|
|
75
184
|
bm25B: 0.75,
|
|
76
185
|
/** RRF smoothing constant; 60 is the customary default. */
|
|
77
186
|
rrfK: 60,
|
|
187
|
+
/**
|
|
188
|
+
* How many candidates the dense reranker may embed per query.
|
|
189
|
+
*
|
|
190
|
+
* Reranking runs on the prompt path, so its cost has to be bounded: embedding
|
|
191
|
+
* the whole library for one question would turn a 200ms step into a multi-second
|
|
192
|
+
* one. The lexical stage has already narrowed the field, and reranking only
|
|
193
|
+
* needs to reorder the head of that list.
|
|
194
|
+
*/
|
|
195
|
+
denseRerankCandidates: 24,
|
|
196
|
+
/**
|
|
197
|
+
* How many topic cards the resident layer may inject, and the character budget
|
|
198
|
+
* for the whole block.
|
|
199
|
+
*
|
|
200
|
+
* The budget is the real constraint: cards exist to give the model a cheap
|
|
201
|
+
* overview, and an overview that costs a thousand tokens per turn defeats the
|
|
202
|
+
* purpose. Five cards of about 120 characters each keeps the block near 600
|
|
203
|
+
* characters, which is roughly what the previous flat layer cost while being far
|
|
204
|
+
* more informative.
|
|
205
|
+
*/
|
|
206
|
+
residentCards: 5,
|
|
207
|
+
residentBudget: 600,
|
|
208
|
+
/**
|
|
209
|
+
* How much of a card's lead memory to show.
|
|
210
|
+
*
|
|
211
|
+
* A card is a pointer, not the memory itself: the full text stays reachable
|
|
212
|
+
* through recall. Capping this is what keeps ONE verbose memory from consuming
|
|
213
|
+
* the whole card budget and hiding every other topic — measured at 621
|
|
214
|
+
* characters for a single card before this cap.
|
|
215
|
+
*/
|
|
216
|
+
residentCardSummaryChars: 60,
|
|
217
|
+
/**
|
|
218
|
+
* How much the dense similarity may nudge the fused lexical rank.
|
|
219
|
+
*
|
|
220
|
+
* Measured, not guessed. Swept over eight paraphrased queries against the real
|
|
221
|
+
* library and model (tools/probe-dense-rerank.mjs):
|
|
222
|
+
*
|
|
223
|
+
* off recall@5 1.000 MRR 0.535
|
|
224
|
+
* 0.15 recall@5 1.000 MRR 0.546 ← the value first shipped, effectively inert
|
|
225
|
+
* 0.5 recall@5 1.000 MRR 0.708
|
|
226
|
+
* 1 recall@5 1.000 MRR 0.760 ← chosen
|
|
227
|
+
* 2 recall@5 1.000 MRR 0.723
|
|
228
|
+
* 8 recall@5 1.000 MRR 0.629 ← over-weighted, drags good results down
|
|
229
|
+
*
|
|
230
|
+
* The curve has a clear maximum and recall never drops, which is what makes this a
|
|
231
|
+
* defensible default rather than a preference. An earlier measurement at 0.15
|
|
232
|
+
* concluded "the dense stage changes nothing" — that was a flaw in the query set
|
|
233
|
+
* (all eight were already ranked correctly by lexical retrieval), not in the stage.
|
|
234
|
+
*/
|
|
235
|
+
denseWeight: 1,
|
|
78
236
|
}
|
|
79
237
|
|
|
80
238
|
/**
|
|
@@ -253,6 +411,15 @@ function matchesFilter(entry, input) {
|
|
|
253
411
|
const when = Math.max(Number(entry.createdAt ?? 0), Number(entry.lastAccessAt ?? 0))
|
|
254
412
|
if (when < cutoff) return false
|
|
255
413
|
}
|
|
414
|
+
const privacy = typeof input?.privacy === 'string' ? input.privacy : ''
|
|
415
|
+
if (privacy) {
|
|
416
|
+
const level = entry.privacy ?? 'internal'
|
|
417
|
+
// One requested level, or 'all' for no filtering.
|
|
418
|
+
if (privacy !== 'all' && level !== privacy) return false
|
|
419
|
+
}
|
|
420
|
+
// Export wants only what may leave this machine. Filtered here rather than in the
|
|
421
|
+
// caller so the share path cannot forget it.
|
|
422
|
+
if (input?.exportable === true && !isExportable(entry)) return false
|
|
256
423
|
return true
|
|
257
424
|
}
|
|
258
425
|
|
|
@@ -266,9 +433,18 @@ function matchesFilter(entry, input) {
|
|
|
266
433
|
export function scoreEntry(entry, context) {
|
|
267
434
|
const weights = { ...DEFAULT_WEIGHTS, ...(context.weights ?? {}) }
|
|
268
435
|
const now = context.now ?? Date.now()
|
|
269
|
-
|
|
436
|
+
// An episode ages faster than a fact: "the deployment failed on Tuesday" stops
|
|
437
|
+
// being actionable in weeks, while "the project uses pnpm" stays true for years.
|
|
438
|
+
// The episode's own occurrence time is the clock, not when it was written — an
|
|
439
|
+
// event recorded late is not a fresh event.
|
|
440
|
+
const isEpisode = entry.kind === 'episode'
|
|
441
|
+
const baseDecay = context.decayDays ?? DEFAULTS.decayDays
|
|
442
|
+
const decayDays = isEpisode ? baseDecay * EPISODE_DECAY_FACTOR : baseDecay
|
|
270
443
|
const frequency = Math.log1p(entry.accessCount ?? 0) / Math.log1p(Math.max(1, context.maxAccess))
|
|
271
|
-
const
|
|
444
|
+
const anchor = isEpisode
|
|
445
|
+
? (entry.occurredAt ?? entry.lastAccessAt ?? entry.createdAt)
|
|
446
|
+
: (entry.lastAccessAt ?? entry.createdAt)
|
|
447
|
+
const days = Math.max(0, (now - anchor) / 86400000)
|
|
272
448
|
// A freeze pins the recency clock: a frozen memory never decays away.
|
|
273
449
|
const recency = entry.frozen ? 1 : Math.exp(-days / decayDays)
|
|
274
450
|
const affect = clamp01(entry.affect ?? 0.4)
|
|
@@ -506,7 +682,7 @@ export class MemoryEngine {
|
|
|
506
682
|
scope,
|
|
507
683
|
workspaceKey: key,
|
|
508
684
|
text,
|
|
509
|
-
kind: input.kind
|
|
685
|
+
kind: KINDS.includes(input.kind) ? input.kind : 'fact',
|
|
510
686
|
tags: Array.isArray(input.tags) ? input.tags.filter(tag => typeof tag === 'string') : [],
|
|
511
687
|
affect: clamp01(input.affect ?? 0.4),
|
|
512
688
|
frozen: input.frozen === true,
|
|
@@ -522,6 +698,23 @@ export class MemoryEngine {
|
|
|
522
698
|
factKey,
|
|
523
699
|
clusterId: null,
|
|
524
700
|
summary: null,
|
|
701
|
+
// Who may see this. Scope says where it lives; privacy says who may read it.
|
|
702
|
+
privacy: inferPrivacy(text, input.privacy),
|
|
703
|
+
}
|
|
704
|
+
// An episode carries the three things that make it an event rather than a
|
|
705
|
+
// truth: when it happened, the situation, and how it turned out. They are
|
|
706
|
+
// optional so a caller that only knows the text can still file one.
|
|
707
|
+
if (entry.kind === 'episode') {
|
|
708
|
+
entry.occurredAt =
|
|
709
|
+
typeof input.occurredAt === 'number' && Number.isFinite(input.occurredAt)
|
|
710
|
+
? input.occurredAt
|
|
711
|
+
: entry.createdAt
|
|
712
|
+
if (typeof input.context === 'string' && input.context.trim()) {
|
|
713
|
+
entry.context = input.context.trim().slice(0, 200)
|
|
714
|
+
}
|
|
715
|
+
if (typeof input.outcome === 'string' && input.outcome.trim()) {
|
|
716
|
+
entry.outcome = input.outcome.trim().slice(0, 200)
|
|
717
|
+
}
|
|
525
718
|
}
|
|
526
719
|
store.entries.push(entry)
|
|
527
720
|
// Supersede older entries that talk about the same fact. Done in the same
|
|
@@ -599,6 +792,10 @@ export class MemoryEngine {
|
|
|
599
792
|
await collect('workspace')
|
|
600
793
|
await collect('global')
|
|
601
794
|
|
|
795
|
+
// Remember the scanned set so the dense reranker can look an entry up by id
|
|
796
|
+
// without re-reading the stores it was just built from.
|
|
797
|
+
this.lastScan = results
|
|
798
|
+
|
|
602
799
|
// Rank by two lexical routes fused by rank, not by one coverage score.
|
|
603
800
|
//
|
|
604
801
|
// History: this used Jaccard, which is symmetric and therefore punishes a
|
|
@@ -619,7 +816,11 @@ export class MemoryEngine {
|
|
|
619
816
|
// real match scored 0.93. Scores of zero are therefore dropped before fusion,
|
|
620
817
|
// so only documents that actually matched compete for rank.
|
|
621
818
|
if (query.size > 0) {
|
|
622
|
-
|
|
819
|
+
// A secret is filtered here, in the engine, rather than trusted to every
|
|
820
|
+
// caller: recall is the one function whose output ends up in a prompt, and a
|
|
821
|
+
// credential is not context. Filtering at the call sites would mean the
|
|
822
|
+
// guarantee holds only where someone remembered to apply it.
|
|
823
|
+
const live = results.filter(item => !item.archived && item.privacy !== 'secret')
|
|
623
824
|
const documents = live.map(item => tokens(item.text))
|
|
624
825
|
const looseDocuments = live.map(item => unigrams(item.text))
|
|
625
826
|
const ids = live.map(item => item.id)
|
|
@@ -674,6 +875,18 @@ export class MemoryEngine {
|
|
|
674
875
|
this.config.rrfK ?? DEFAULTS.rrfK,
|
|
675
876
|
)
|
|
676
877
|
|
|
878
|
+
// Optional second stage: dense reranking of the candidates the lexical
|
|
879
|
+
// routes already found. Lexical retrieval cannot bridge a paraphrase that
|
|
880
|
+
// shares no character (checkpoint vs 快照); a dense model can. It runs only
|
|
881
|
+
// when a provider is configured and a query exists, and every failure path
|
|
882
|
+
// inside the provider returns undefined, so the fused order below is the
|
|
883
|
+
// fallback rather than an exception.
|
|
884
|
+
//
|
|
885
|
+
// Note the interpolation weight is applied against the *fused* ranking, so
|
|
886
|
+
// a strong lexical agreement is not discarded just because the vector is
|
|
887
|
+
// mediocre — the two signals describe different things and both are kept.
|
|
888
|
+
const denseRanking = await this.#denseRerank(input.query, ids, fused).catch(() => undefined)
|
|
889
|
+
|
|
677
890
|
for (let index = 0; index < live.length; index += 1) {
|
|
678
891
|
const item = live[index]
|
|
679
892
|
const bm25Score = preciseScores[index]
|
|
@@ -684,13 +897,29 @@ export class MemoryEngine {
|
|
|
684
897
|
item.matched = bm25Score > 0 || looseScores[index] > 0 || wordScore > 0
|
|
685
898
|
item.rank =
|
|
686
899
|
(fused.get(item.id) ?? 0) * 0.75 + item.score * 0.2 + (item.scope === 'workspace' ? 0.05 : 0)
|
|
900
|
+
// The dense score nudges the order without replacing it. The weight is
|
|
901
|
+
// configurable because the right value is empirical, not obvious: measured on
|
|
902
|
+
// a hold-out of eight paraphrase queries, a weight of 0.15 changed the
|
|
903
|
+
// ordering of NOT ONE result — the three lexical routes had already agreed,
|
|
904
|
+
// and the dense signal was too small to overturn them. A weight that DOES
|
|
905
|
+
// change ordering has to be justified by measurement, so it is exposed here
|
|
906
|
+
// rather than guessed at.
|
|
907
|
+
if (denseRanking?.has(item.id)) {
|
|
908
|
+
item.dense = Number(denseRanking.get(item.id).toFixed(3))
|
|
909
|
+
item.rank += Math.max(0, item.dense) * (this.config.denseWeight ?? DEFAULTS.denseWeight)
|
|
910
|
+
}
|
|
687
911
|
}
|
|
688
912
|
}
|
|
689
913
|
|
|
690
914
|
// With no query every rank is 0, so this only orders the importance-only
|
|
691
915
|
// case; with a query the coverage ranks are already in place.
|
|
692
916
|
results.sort((a, b) => b.rank - a.rank)
|
|
693
|
-
|
|
917
|
+
// The secret filter must be applied to the FINAL list, not only to the
|
|
918
|
+
// candidate set the lexical routes scored: filtering `live` alone left the
|
|
919
|
+
// unfiltered `results` to be sorted and sliced below, so a credential still
|
|
920
|
+
// came back. This is the last gate before the output leaves the engine.
|
|
921
|
+
const safe = results.filter(item => item.privacy !== 'secret')
|
|
922
|
+
const top = safe.slice(0, limit)
|
|
694
923
|
if (input.touch !== false) await this.#touch(top, input.cwd)
|
|
695
924
|
return top
|
|
696
925
|
}
|
|
@@ -718,6 +947,113 @@ export class MemoryEngine {
|
|
|
718
947
|
* @param {object} input same as recall, plus `tiered` (default true)
|
|
719
948
|
* @returns {Promise<{ items: any[], omitted: any[], effectiveLimit: number, topScore: number }>}
|
|
720
949
|
*/
|
|
950
|
+
/**
|
|
951
|
+
* Group high-value memories into topic cards for the resident layer.
|
|
952
|
+
*
|
|
953
|
+
* Why cards instead of "the N most important entries": the flat version made the
|
|
954
|
+
* injected block unreadable and, worse, misleading. A user who typed a question
|
|
955
|
+
* into the settings probe saw six lines and assumed all six were the recall
|
|
956
|
+
* result, when two of them were the resident layer and had nothing to do with the
|
|
957
|
+
* question. Grouping by topic lets the block state *what* it is summarising
|
|
958
|
+
* ("缓存: 4 条") instead of dumping four unrelated sentences and calling the
|
|
959
|
+
* whole thing "relevant".
|
|
960
|
+
*
|
|
961
|
+
* The shape follows the two-layer architecture described for agent memory: a
|
|
962
|
+
* compact overview of what the library knows, with detail left to on-demand
|
|
963
|
+
* recall. It is deliberately NOT a hierarchical index — one level of grouping is
|
|
964
|
+
* what the injected budget can afford, and a deeper tree would cost more context
|
|
965
|
+
* than it saves.
|
|
966
|
+
*
|
|
967
|
+
* @param {object} input input
|
|
968
|
+
* @param {string} [input.cwd] workspace
|
|
969
|
+
* @param {number} [input.limit] how many cards, default 5
|
|
970
|
+
* @param {number} [input.budget] total character budget for card text, default 600
|
|
971
|
+
* @returns {Promise<Array<{ topic: string, count: number, summary: string, updatedAt: number }>>} cards
|
|
972
|
+
*/
|
|
973
|
+
async residentCards(input = {}) {
|
|
974
|
+
const limit = typeof input.limit === 'number' ? input.limit : DEFAULTS.residentCards
|
|
975
|
+
const budget = typeof input.budget === 'number' ? input.budget : DEFAULTS.residentBudget
|
|
976
|
+
const entries = (await this.list({ cwd: input.cwd })).filter(
|
|
977
|
+
// A secret must not appear in the overview either: a card's summary is text
|
|
978
|
+
// that gets injected, so listing one here would leak it just as surely as
|
|
979
|
+
// putting it in the matched half.
|
|
980
|
+
entry => !entry.archived && entry.privacy !== 'secret' && String(entry.text ?? '').trim(),
|
|
981
|
+
)
|
|
982
|
+
if (entries.length === 0) return []
|
|
983
|
+
|
|
984
|
+
// Group by the first tag that is not the generic feature tag. A memory usually
|
|
985
|
+
// carries one or two tags; the first is the subject, and grouping by it is
|
|
986
|
+
// what a reader would call "the topic".
|
|
987
|
+
const groups = new Map()
|
|
988
|
+
for (const entry of entries) {
|
|
989
|
+
const tags = Array.isArray(entry.tags) ? entry.tags.filter(tag => typeof tag === 'string' && tag) : []
|
|
990
|
+
const topic = tags[0] ?? '未分类'
|
|
991
|
+
if (!groups.has(topic)) groups.set(topic, [])
|
|
992
|
+
groups.get(topic).push(entry)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const cards = []
|
|
996
|
+
for (const [topic, members] of groups) {
|
|
997
|
+
// Rank the members so the card's summary line carries the most valuable
|
|
998
|
+
// sentence: frozen first (the user pinned it), then by score, then recency.
|
|
999
|
+
const ranked = [...members].sort(
|
|
1000
|
+
(a, b) =>
|
|
1001
|
+
Number(Boolean(b.frozen)) - Number(Boolean(a.frozen)) ||
|
|
1002
|
+
(b.score ?? 0) - (a.score ?? 0) ||
|
|
1003
|
+
(b.lastAccessAt ?? 0) - (a.lastAccessAt ?? 0),
|
|
1004
|
+
)
|
|
1005
|
+
const head = ranked[0]
|
|
1006
|
+
// A card is an overview line, not a memory. The head memory can be up to
|
|
1007
|
+
// maxMemoryChars long, and pasting it whole turned one card into 600
|
|
1008
|
+
// characters on its own — which then crowded every other topic out of the
|
|
1009
|
+
// budget. Truncation is acceptable HERE, unlike at the write path: this is a
|
|
1010
|
+
// pointer that says "there is something about X", and the full text is still
|
|
1011
|
+
// available through recall.
|
|
1012
|
+
const headText = String(head?.text ?? '').replace(/\s+/g, ' ').trim()
|
|
1013
|
+
const summary =
|
|
1014
|
+
headText.length > DEFAULTS.residentCardSummaryChars
|
|
1015
|
+
? `${headText.slice(0, DEFAULTS.residentCardSummaryChars)}…`
|
|
1016
|
+
: headText
|
|
1017
|
+
cards.push({
|
|
1018
|
+
topic,
|
|
1019
|
+
count: members.length,
|
|
1020
|
+
summary,
|
|
1021
|
+
updatedAt: Math.max(...members.map(entry => entry.lastAccessAt ?? entry.createdAt ?? 0)),
|
|
1022
|
+
weight: ranked.reduce((sum, entry) => sum + (entry.score ?? 0), 0) / members.length,
|
|
1023
|
+
})
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Biggest and most valuable topics first; a card for one stray memory is noise.
|
|
1027
|
+
cards.sort((a, b) => b.count - a.count || b.weight - a.weight || b.updatedAt - a.updatedAt)
|
|
1028
|
+
|
|
1029
|
+
const kept = []
|
|
1030
|
+
let used = 0
|
|
1031
|
+
for (const card of cards) {
|
|
1032
|
+
if (kept.length >= limit) break
|
|
1033
|
+
// Cost is the rendered line, not just the summary: the topic name and count
|
|
1034
|
+
// are charged too. The check is BEFORE adding, and the first card is allowed
|
|
1035
|
+
// through regardless so the layer is never empty — a 621-character single
|
|
1036
|
+
// card against a 600-character budget is exactly the overrun this avoids.
|
|
1037
|
+
const line = `${card.topic}(${card.count} 条):${card.summary}`
|
|
1038
|
+
if (kept.length > 0 && used + line.length + 1 > budget) break
|
|
1039
|
+
kept.push(card)
|
|
1040
|
+
used += line.length + 1
|
|
1041
|
+
}
|
|
1042
|
+
return kept
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Render the resident cards as prompt lines.
|
|
1047
|
+
*
|
|
1048
|
+
* @param {Array<any>} cards output of residentCards
|
|
1049
|
+
* @returns {string[]} one line per card
|
|
1050
|
+
*/
|
|
1051
|
+
static formatCards(cards) {
|
|
1052
|
+
return (Array.isArray(cards) ? cards : []).map(
|
|
1053
|
+
card => `- ${card.topic}(${card.count} 条):${card.summary}`,
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
721
1057
|
async select(input = {}) {
|
|
722
1058
|
const limit = typeof input.limit === 'number' ? input.limit : DEFAULTS.recallLimit
|
|
723
1059
|
const tiered = input.tiered !== false
|
|
@@ -882,9 +1218,28 @@ export class MemoryEngine {
|
|
|
882
1218
|
const clusterId = `cluster_${shortId(group.map(entry => entry.id).join(':'))}`
|
|
883
1219
|
const summary = group.map(entry => entry.text).join(';')
|
|
884
1220
|
const summaryText = summary.length > 400 ? `${summary.slice(0, 400)}…` : summary
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
|
|
1221
|
+
// Identity is the CONTENT, and the cold archive counts.
|
|
1222
|
+
//
|
|
1223
|
+
// The live-only, exact-text check was the hole that kept refilling the
|
|
1224
|
+
// library. Sequence: a group is clustered → its summary is written and
|
|
1225
|
+
// its members are archived → a later pass clusters a DIFFERENT group
|
|
1226
|
+
// whose joined text is equivalent but not byte-identical (member order
|
|
1227
|
+
// differs, or one member came back as a new fact) → the exact-text probe
|
|
1228
|
+
// missed it → another near-identical summary appeared. One sentence ended
|
|
1229
|
+
// up in the library seven times.
|
|
1230
|
+
//
|
|
1231
|
+
// Comparing a normalised digest rather than raw text fixes the ordering
|
|
1232
|
+
// half, and searching the cold archive fixes the "it was archived"
|
|
1233
|
+
// half — the same reasoning that already protected rule generation.
|
|
1234
|
+
const summaryDigest = sha256Text(
|
|
1235
|
+
group
|
|
1236
|
+
.map(entry => String(entry.text ?? '').trim())
|
|
1237
|
+
.sort()
|
|
1238
|
+
.join(';'),
|
|
1239
|
+
).slice(0, 16)
|
|
1240
|
+
const existing = [...store.entries, ...cold.entries].find(
|
|
1241
|
+
entry => entry.summary && (entry.summaryDigest === summaryDigest || entry.text === summaryText),
|
|
1242
|
+
)
|
|
888
1243
|
if (existing) {
|
|
889
1244
|
for (const entry of group) {
|
|
890
1245
|
entry.clusterId = existing.clusterId
|
|
@@ -909,6 +1264,7 @@ export class MemoryEngine {
|
|
|
909
1264
|
lastAccessAt: Date.now(),
|
|
910
1265
|
source: 'cluster',
|
|
911
1266
|
digest: sha256Text(summary).slice(0, 16),
|
|
1267
|
+
summaryDigest,
|
|
912
1268
|
clusterId,
|
|
913
1269
|
summary: true,
|
|
914
1270
|
}
|
|
@@ -1045,6 +1401,10 @@ export class MemoryEngine {
|
|
|
1045
1401
|
lastMaintainedCwd: typeof data?.lastMaintainedCwd === 'string' ? data.lastMaintainedCwd : null,
|
|
1046
1402
|
lastReport: data?.lastReport ?? null,
|
|
1047
1403
|
lastFingerprint: typeof data?.lastFingerprint === 'string' ? data.lastFingerprint : '',
|
|
1404
|
+
// The most recent implicit-extraction attempt, set by the host half. It is
|
|
1405
|
+
// in-memory on purpose: a restart legitimately forgets it, and the settings
|
|
1406
|
+
// page only ever asks "why is the queue empty right now".
|
|
1407
|
+
extraction: this.lastExtraction ?? null,
|
|
1048
1408
|
}
|
|
1049
1409
|
}
|
|
1050
1410
|
|
|
@@ -1082,6 +1442,100 @@ export class MemoryEngine {
|
|
|
1082
1442
|
return fingerprint !== status.lastFingerprint
|
|
1083
1443
|
}
|
|
1084
1444
|
|
|
1445
|
+
/**
|
|
1446
|
+
* Score query/candidate pairs with the configured embedding provider.
|
|
1447
|
+
*
|
|
1448
|
+
* Returns undefined whenever dense ranking is unavailable, which is the normal
|
|
1449
|
+
* state: no provider configured, no query, or an embedding failure. The caller
|
|
1450
|
+
* treats undefined as "keep the lexical order", so this is an optimisation that
|
|
1451
|
+
* cannot break recall.
|
|
1452
|
+
*
|
|
1453
|
+
* @param {string} query the user's question
|
|
1454
|
+
* @param {Array<string | number>} ids candidate ids, aligned with `live`
|
|
1455
|
+
* @param {Map<string | number, number>} fused lexical scores, best-first order
|
|
1456
|
+
* @returns {Promise<Map<string | number, number> | undefined>} dense cosine per id
|
|
1457
|
+
*/
|
|
1458
|
+
async #denseRerank(query, ids, fused) {
|
|
1459
|
+
const text = String(query ?? '').trim()
|
|
1460
|
+
if (!text) return undefined
|
|
1461
|
+
const provider = await this.#ensureEmbeddingProvider()
|
|
1462
|
+
if (!provider?.available) return undefined
|
|
1463
|
+
|
|
1464
|
+
// Only embed candidates the lexical stage actually matched, capped so a huge
|
|
1465
|
+
// library cannot turn one prompt step into hundreds of inferences.
|
|
1466
|
+
const selected = [...fused.entries()]
|
|
1467
|
+
.filter(([, score]) => score > 0)
|
|
1468
|
+
.sort((a, b) => b[1] - a[1])
|
|
1469
|
+
.slice(0, DEFAULTS.denseRerankCandidates)
|
|
1470
|
+
.map(([id]) => id)
|
|
1471
|
+
if (selected.length === 0) return undefined
|
|
1472
|
+
|
|
1473
|
+
const queryVector = await provider.embed(text)
|
|
1474
|
+
if (!queryVector) return undefined
|
|
1475
|
+
|
|
1476
|
+
const scores = new Map()
|
|
1477
|
+
for (const id of selected) {
|
|
1478
|
+
const entry = this.#findEntryById(id)
|
|
1479
|
+
if (!entry) continue
|
|
1480
|
+
const vector = await this.#embeddingFor(provider, entry)
|
|
1481
|
+
if (!vector) continue
|
|
1482
|
+
scores.set(id, cosineSimilarity(queryVector, vector))
|
|
1483
|
+
}
|
|
1484
|
+
return scores.size > 0 ? scores : undefined
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* The embedding provider for this engine, created at most once.
|
|
1489
|
+
*
|
|
1490
|
+
* Lazy on purpose: constructing a local pipeline is expensive and most users run
|
|
1491
|
+
* without a model, so nothing happens until a query actually needs it.
|
|
1492
|
+
*
|
|
1493
|
+
* @returns {Promise<any>} provider, or the disabled one
|
|
1494
|
+
*/
|
|
1495
|
+
async #ensureEmbeddingProvider() {
|
|
1496
|
+
if (this.denseProvider !== undefined) return this.denseProvider
|
|
1497
|
+
try {
|
|
1498
|
+
this.denseProvider = await createEmbeddingProvider({ ...this.config, root: this.root })
|
|
1499
|
+
} catch {
|
|
1500
|
+
this.denseProvider = DISABLED_PROVIDER
|
|
1501
|
+
}
|
|
1502
|
+
return this.denseProvider
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Embedding for one entry, memoised in memory.
|
|
1507
|
+
*
|
|
1508
|
+
* Entries are immutable except for scoring metadata, so a vector stays valid
|
|
1509
|
+
* for the process lifetime; recomputing it per query would make reranking far
|
|
1510
|
+
* more expensive than the lexical routes it augments.
|
|
1511
|
+
*
|
|
1512
|
+
* @param {any} provider embedding provider
|
|
1513
|
+
* @param {any} entry memory entry
|
|
1514
|
+
* @returns {Promise<number[] | undefined>} vector
|
|
1515
|
+
*/
|
|
1516
|
+
async #embeddingFor(provider, entry) {
|
|
1517
|
+
if (!this.denseCache) this.denseCache = new Map()
|
|
1518
|
+
// Key on the text plus its length: a rewritten memory changes the text, so a
|
|
1519
|
+
// stale vector cannot be served for different content.
|
|
1520
|
+
const key = `${entry.id}:${String(entry.text ?? '').length}:${entry.text}`
|
|
1521
|
+
if (this.denseCache.has(key)) return this.denseCache.get(key)
|
|
1522
|
+
const vector = await provider.embed(String(entry.text ?? ''))
|
|
1523
|
+
this.denseCache.set(key, vector)
|
|
1524
|
+
return vector
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Look up an entry by id across the stores this engine has loaded.
|
|
1529
|
+
*
|
|
1530
|
+
* @param {string | number} id entry id
|
|
1531
|
+
* @returns {any | undefined} entry
|
|
1532
|
+
*/
|
|
1533
|
+
#findEntryById(id) {
|
|
1534
|
+
const scanned = this.lastScan
|
|
1535
|
+
if (!Array.isArray(scanned)) return undefined
|
|
1536
|
+
return scanned.find(entry => entry.id === id)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1085
1539
|
/**
|
|
1086
1540
|
* The stores a maintenance pass covers: the global store plus every workspace.
|
|
1087
1541
|
*
|
|
@@ -1248,11 +1702,22 @@ export class MemoryEngine {
|
|
|
1248
1702
|
* @returns {Promise<any>} everything, for a portable backup
|
|
1249
1703
|
*/
|
|
1250
1704
|
async export(input = {}) {
|
|
1251
|
-
const out = { version: 2, exportedAt: Date.now(), global: {}, workspace: {} }
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1705
|
+
const out = { version: 2, exportedAt: Date.now(), global: {}, workspace: {}, excluded: 0 }
|
|
1706
|
+
// A share export leaves this machine, so only `public` memories may travel.
|
|
1707
|
+
// `internal` is the default level, which means the honest behaviour for a library
|
|
1708
|
+
// nobody has classified is to export nothing private — and to say how many were
|
|
1709
|
+
// withheld, because silently dropping rows looks like data loss.
|
|
1710
|
+
const keep = (scope, store) => {
|
|
1711
|
+
const entries = Array.isArray(store?.entries) ? store.entries : []
|
|
1712
|
+
const kept = entries.filter(isExportable)
|
|
1713
|
+
out.excluded += entries.length - kept.length
|
|
1714
|
+
return { ...store, entries: kept }
|
|
1715
|
+
}
|
|
1716
|
+
const read = async (scope, key) => {
|
|
1717
|
+
const live = await this.#readStore(scope, key)
|
|
1718
|
+
const cold = await this.#readCold(scope, key)
|
|
1719
|
+
return { live: keep(scope, live), cold: keep(scope, cold) }
|
|
1720
|
+
}
|
|
1256
1721
|
out.global = await read('global', 'global')
|
|
1257
1722
|
if (input.cwd) out.workspace = await read('workspace', MemoryEngine.workspaceKey(input.cwd))
|
|
1258
1723
|
return out
|
|
@@ -1285,6 +1750,10 @@ export class MemoryEngine {
|
|
|
1285
1750
|
digest,
|
|
1286
1751
|
text: String(suggestion.text ?? '').trim(),
|
|
1287
1752
|
scope: suggestion.scope === 'global' ? 'global' : 'workspace',
|
|
1753
|
+
// The kind travels with the suggestion so accepting it later stores the same
|
|
1754
|
+
// shape the extractor meant. Without this, an episode would be flattened to a
|
|
1755
|
+
// fact at confirmation time and lose its time and outcome.
|
|
1756
|
+
kind: KINDS.includes(suggestion.kind) ? suggestion.kind : 'fact',
|
|
1288
1757
|
cwd: suggestion.cwd ?? null,
|
|
1289
1758
|
tags: suggestion.tags ?? [],
|
|
1290
1759
|
affect: clamp01(suggestion.affect ?? 0.5),
|
|
@@ -1292,6 +1761,14 @@ export class MemoryEngine {
|
|
|
1292
1761
|
sessionId: suggestion.sessionId ?? null,
|
|
1293
1762
|
createdAt: Date.now(),
|
|
1294
1763
|
}
|
|
1764
|
+
if (item.kind === 'episode') {
|
|
1765
|
+
if (typeof suggestion.context === 'string' && suggestion.context.trim()) {
|
|
1766
|
+
item.context = suggestion.context.trim().slice(0, 200)
|
|
1767
|
+
}
|
|
1768
|
+
if (typeof suggestion.outcome === 'string' && suggestion.outcome.trim()) {
|
|
1769
|
+
item.outcome = suggestion.outcome.trim().slice(0, 200)
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1295
1772
|
if (!item.text) return undefined
|
|
1296
1773
|
items.push(item)
|
|
1297
1774
|
await ensureDir(this.root)
|
|
@@ -1311,6 +1788,8 @@ export class MemoryEngine {
|
|
|
1311
1788
|
scope: item.scope,
|
|
1312
1789
|
cwd: item.cwd,
|
|
1313
1790
|
text: item.text,
|
|
1791
|
+
kind: item.kind,
|
|
1792
|
+
...(item.kind === 'episode' ? { context: item.context, outcome: item.outcome } : {}),
|
|
1314
1793
|
tags: item.tags,
|
|
1315
1794
|
affect: item.affect,
|
|
1316
1795
|
source: 'implicit',
|