@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.
@@ -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
- const decayDays = context.decayDays ?? DEFAULTS.decayDays
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 days = Math.max(0, (now - (entry.lastAccessAt ?? entry.createdAt)) / 86400000)
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 === 'rule' ? 'rule' : 'fact',
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
- const live = results.filter(item => !item.archived)
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
- const top = results.slice(0, limit)
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
- // The same text under a new cluster id is a duplicate, not a finding.
886
- // Content decides identity here; the member ids only decide the cluster.
887
- const existing = store.entries.find(entry => entry.summary && entry.text === summaryText)
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
- const read = async (scope, key) => ({
1253
- live: await this.#readStore(scope, key),
1254
- cold: await this.#readCold(scope, key),
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',