@mindot/will 0.1.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/LICENSE +202 -0
- package/README.md +777 -0
- package/dist/index.d.ts +8144 -0
- package/dist/index.js +26231 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
- package/src/cognition/agency/access.grants.ts +59 -0
- package/src/cognition/agency/competence.codec.ts +89 -0
- package/src/cognition/agency/engines/action.selector.ts +462 -0
- package/src/cognition/agency/engines/affordance.synthesizer.ts +384 -0
- package/src/cognition/agency/engines/deliberation.engine.ts +241 -0
- package/src/cognition/agency/engines/instruction.intake.ts +54 -0
- package/src/cognition/agency/engines/motor.schema.executor.ts +532 -0
- package/src/cognition/agency/engines/reafference.engine.ts +241 -0
- package/src/cognition/agency/execution.primitives.ts +118 -0
- package/src/cognition/agency/index.ts +58 -0
- package/src/cognition/agency/proactive.communicator.ts +274 -0
- package/src/cognition/agency/reconcile.learning.ts +82 -0
- package/src/cognition/agency/schemas/external.ts +59 -0
- package/src/cognition/agency/schemas/innate.ts +111 -0
- package/src/cognition/agency/schemas/repertoire.ts +246 -0
- package/src/cognition/agency/selection.scoring.ts +191 -0
- package/src/cognition/agency/types.ts +165 -0
- package/src/cognition/bus.ts +391 -0
- package/src/cognition/completion.inbox.ts +86 -0
- package/src/cognition/config.mirror.entities.ts +475 -0
- package/src/cognition/conversation.memory.ts +81 -0
- package/src/cognition/event.log.ts +116 -0
- package/src/cognition/event.schemas.ts +568 -0
- package/src/cognition/faculties/aesthetic.evaluator.ts +313 -0
- package/src/cognition/faculties/affective.blender.ts +562 -0
- package/src/cognition/faculties/attachment.evaluator.ts +432 -0
- package/src/cognition/faculties/attention.allocator.ts +422 -0
- package/src/cognition/faculties/autobiographical.narrator.ts +300 -0
- package/src/cognition/faculties/bias.detector.ts +363 -0
- package/src/cognition/faculties/circadian.oscillator.ts +294 -0
- package/src/cognition/faculties/confidence.calibrator.ts +324 -0
- package/src/cognition/faculties/dream.simulator.ts +251 -0
- package/src/cognition/faculties/empathy.simulator.ts +253 -0
- package/src/cognition/faculties/energy.regulator.ts +320 -0
- package/src/cognition/faculties/episodic.consolidator.ts +728 -0
- package/src/cognition/faculties/executive.engine/commands.ts +459 -0
- package/src/cognition/faculties/executive.engine/config.ts +46 -0
- package/src/cognition/faculties/executive.engine/context.ts +605 -0
- package/src/cognition/faculties/executive.engine/deferred.effects.ts +104 -0
- package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +60 -0
- package/src/cognition/faculties/executive.engine/effort.gate.ts +114 -0
- package/src/cognition/faculties/executive.engine/engine.ts +1028 -0
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +90 -0
- package/src/cognition/faculties/executive.engine/facet.supervisor.ts +271 -0
- package/src/cognition/faculties/executive.engine/facet.ts +628 -0
- package/src/cognition/faculties/executive.engine/gating.ts +222 -0
- package/src/cognition/faculties/executive.engine/index.ts +6 -0
- package/src/cognition/faculties/executive.engine/messages.ts +102 -0
- package/src/cognition/faculties/executive.engine/parser.ts +380 -0
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +1053 -0
- package/src/cognition/faculties/executive.engine/types.ts +323 -0
- package/src/cognition/faculties/exteroception.ts +338 -0
- package/src/cognition/faculties/forgetting.curve.ts +202 -0
- package/src/cognition/faculties/frustration.evaluator.ts +280 -0
- package/src/cognition/faculties/goal.manager.ts +1008 -0
- package/src/cognition/faculties/inhibition.controller.ts +351 -0
- package/src/cognition/faculties/interoception.ts +417 -0
- package/src/cognition/faculties/introspection.engine.ts +246 -0
- package/src/cognition/faculties/known.entity.tracker.ts +439 -0
- package/src/cognition/faculties/loss.evaluator.ts +224 -0
- package/src/cognition/faculties/moral.evaluator.ts +437 -0
- package/src/cognition/faculties/novelty.detector.ts +254 -0
- package/src/cognition/faculties/persona.consolidator.ts +845 -0
- package/src/cognition/faculties/planning.engine/engine.ts +859 -0
- package/src/cognition/faculties/planning.engine/plan.frontier.ts +83 -0
- package/src/cognition/faculties/planning.engine/plan.store.ts +174 -0
- package/src/cognition/faculties/planning.engine/plan.supervision.ts +494 -0
- package/src/cognition/faculties/planning.engine/types.ts +143 -0
- package/src/cognition/faculties/reputation.tracker.ts +294 -0
- package/src/cognition/faculties/reward.evaluator.ts +304 -0
- package/src/cognition/faculties/self.model.updater.ts +677 -0
- package/src/cognition/faculties/semantic.engine/clustering.ts +673 -0
- package/src/cognition/faculties/semantic.engine/index.ts +6 -0
- package/src/cognition/faculties/semantic.engine/integrator.ts +750 -0
- package/src/cognition/faculties/semantic.engine/types.ts +53 -0
- package/src/cognition/faculties/sleep.pressure.regulator.ts +262 -0
- package/src/cognition/faculties/social.perception.ts +333 -0
- package/src/cognition/faculties/spaced.repetition.ts +605 -0
- package/src/cognition/faculties/stress.regulator.ts +375 -0
- package/src/cognition/faculties/task.switcher.ts +239 -0
- package/src/cognition/faculties/theory.of.mind.ts +333 -0
- package/src/cognition/faculties/threat.evaluator.ts +312 -0
- package/src/cognition/faculties/working.memory.ts +406 -0
- package/src/cognition/generative.model.ts +344 -0
- package/src/cognition/heartbeat.ts +70 -0
- package/src/cognition/index.ts +291 -0
- package/src/cognition/instruction.handler.ts +146 -0
- package/src/cognition/memory/index.ts +35 -0
- package/src/cognition/memory/vector.adapter.ts +316 -0
- package/src/cognition/memory/vector.content.ts +47 -0
- package/src/cognition/memory/vector.embedder.ts +212 -0
- package/src/cognition/memory/vector.index.ts +459 -0
- package/src/cognition/memory/vector.types.ts +65 -0
- package/src/cognition/orchestrator.ts +142 -0
- package/src/cognition/persona.prior.ts +265 -0
- package/src/cognition/schema.registry.ts +145 -0
- package/src/cognition/senses/audition.engine/engine.ts +976 -0
- package/src/cognition/senses/audition.engine/salience.ts +59 -0
- package/src/cognition/senses/base.sense.engine.ts +117 -0
- package/src/cognition/senses/gustation.engine.ts +24 -0
- package/src/cognition/senses/index.ts +172 -0
- package/src/cognition/senses/olfaction.engine.ts +24 -0
- package/src/cognition/senses/somatosensation.engine.ts +23 -0
- package/src/cognition/senses/vision.engine.ts +23 -0
- package/src/cognition/types.ts +63 -0
- package/src/cognition/utilities/token.tracker.ts +492 -0
- package/src/core/abstracts.ts +60 -0
- package/src/core/async.engine.ts +539 -0
- package/src/core/clock.ts +196 -0
- package/src/core/completion.recorder.ts +122 -0
- package/src/core/conflict.detector.ts +140 -0
- package/src/core/distributed.ts +626 -0
- package/src/core/event.bus.ts +292 -0
- package/src/core/inbound.recorder.ts +100 -0
- package/src/core/index.ts +167 -0
- package/src/core/logger.ts +85 -0
- package/src/core/metrics.ts +107 -0
- package/src/core/orchestrator.ts +625 -0
- package/src/core/replay.ts +589 -0
- package/src/core/scenario.ts +120 -0
- package/src/core/serialization.ts +448 -0
- package/src/core/simulation.ts +197 -0
- package/src/core/snapshot.manager.ts +293 -0
- package/src/core/state.manager.ts +283 -0
- package/src/core/types.ts +274 -0
- package/src/core/utils.ts +82 -0
- package/src/core/wall.clock.ts +23 -0
- package/src/extensions/livestream.ext.ts +570 -0
- package/src/extensions/time.ext.ts +339 -0
- package/src/index.ts +91 -0
- package/src/llm/gate.ts +140 -0
- package/src/llm/index.ts +716 -0
- package/src/llm/summarizer.ts +170 -0
- package/src/llm/wire.contracts.ts +67 -0
- package/src/pma/eval.ts +651 -0
- package/src/pma/index.ts +1082 -0
- package/src/profiles/built-in.ts +7 -0
- package/src/profiles/companion.ts +28 -0
- package/src/profiles/company-brain.ts +54 -0
- package/src/profiles/customer-service.ts +30 -0
- package/src/profiles/game-npc.ts +20 -0
- package/src/profiles/index.ts +42 -0
- package/src/profiles/smart-home.ts +34 -0
- package/src/runners/coherence.runner.ts +49 -0
- package/src/runners/outreach.runner.ts +158 -0
- package/src/runners/social.runner.ts +182 -0
- package/src/runners/thin-shim.runner.ts +195 -0
- package/src/sdk/will.ts +334 -0
- package/src/stem/assembly.audit.ts +88 -0
- package/src/stem/distribution.ts +203 -0
- package/src/stem/guards/identity.coherence.ts +152 -0
- package/src/stem/guards/identity.guard.ts +227 -0
- package/src/stem/index.ts +1145 -0
- package/src/stem/mind.ts +1090 -0
- package/src/stem/tracts/ack.reconciler.ts +55 -0
- package/src/stem/tracts/biography.writer.ts +190 -0
- package/src/stem/tracts/effector.controller.ts +143 -0
- package/src/stem/tracts/health.reporter.ts +113 -0
- package/src/stem/tracts/inbound.queue.ts +54 -0
- package/src/stem/tracts/outbox.controller.ts +167 -0
- package/src/stem/tracts/outbox.writer.ts +133 -0
- package/src/stem/tracts/pma.controller.ts +109 -0
- package/src/stem/tracts/replay.controller.ts +177 -0
- package/src/stem/tracts/sensory.controller.ts +168 -0
- package/src/stem/tracts/session.logger.ts +157 -0
- package/src/stem/tracts/transport/index.ts +38 -0
- package/src/stem/tracts/transport/loopback.transport.ts +86 -0
- package/src/stem/tracts/transport/socketio.transport.ts +200 -0
- package/src/stem/tracts/transport/stream.transport.ts +152 -0
- package/src/stem/tracts/transport/types.ts +193 -0
- package/src/stem/tracts/transport.controller.ts +380 -0
- package/src/types.ts +116 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/cognition/memory/vector.adapter.ts
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* VectorMemoryAdapter — primary interface for cognitive engines.
|
|
7
|
+
*
|
|
8
|
+
* Provides semantic similarity search over episodic memory.
|
|
9
|
+
* Accepts any VectorIndex implementation (HNSW, Qdrant, pgvector, Pinecone)
|
|
10
|
+
* and any EmbeddingProvider (OpenAI, local, mock).
|
|
11
|
+
*
|
|
12
|
+
* Follows the StorageAdapter pattern from replay.ts — receives storage
|
|
13
|
+
* in constructor for persistence of the index itself.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { logger } from '#core/logger'
|
|
17
|
+
import { wallClock } from '#core/wall.clock'
|
|
18
|
+
import type { StorageAdapter } from '#core/abstracts'
|
|
19
|
+
import type { EpisodicMemory } from '#faculties/episodic.consolidator'
|
|
20
|
+
import type { EmbeddingProvider } from '#memory/vector.embedder'
|
|
21
|
+
import type { VectorRecord, VectorQueryFilter, VectorQueryResult, VectorMemoryConfig } from '#memory/vector.types'
|
|
22
|
+
import type { VectorIndex } from '#memory/vector.index'
|
|
23
|
+
import { BunStorageAdapter } from '#core/abstracts'
|
|
24
|
+
import { HNSWIndex } from '#memory/vector.index'
|
|
25
|
+
import { episodeContentToText } from '#memory/vector.content'
|
|
26
|
+
|
|
27
|
+
export interface VectorMemoryAdapter {
|
|
28
|
+
/** Index an episodic memory (called during consolidation) */
|
|
29
|
+
index( episode: EpisodicMemory, content: unknown ): Promise<void>
|
|
30
|
+
|
|
31
|
+
/** Index multiple episodes in batch */
|
|
32
|
+
indexBatch( episodes: Array<{ episode: EpisodicMemory; content: unknown }> ): Promise<void>
|
|
33
|
+
|
|
34
|
+
/** Search for semantically similar episodes — returns ID + similarity, caller resolves from store */
|
|
35
|
+
search( query: unknown, filter?: VectorQueryFilter ): Promise<VectorQueryResult[]>
|
|
36
|
+
|
|
37
|
+
/** Search with embedding vector directly */
|
|
38
|
+
searchWithVector( embedding: number[], filter?: VectorQueryFilter ): Promise<VectorQueryResult[]>
|
|
39
|
+
|
|
40
|
+
/** Delete an episode from the index (when pruned from _store) */
|
|
41
|
+
delete( episodeId: string ): Promise<void>
|
|
42
|
+
|
|
43
|
+
/** Rebuild entire index from store (called on snapshot restore when no persisted index exists) */
|
|
44
|
+
rebuildFromStore( store: EpisodicMemory[] ): Promise<void>
|
|
45
|
+
|
|
46
|
+
/** Persist index to storage */
|
|
47
|
+
persist(): Promise<void>
|
|
48
|
+
|
|
49
|
+
/** Load index from storage */
|
|
50
|
+
load(): Promise<void>
|
|
51
|
+
|
|
52
|
+
/** Get current index size */
|
|
53
|
+
readonly size: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class DefaultVectorMemoryAdapter implements VectorMemoryAdapter {
|
|
57
|
+
private _index: VectorIndex
|
|
58
|
+
private _embedder: EmbeddingProvider
|
|
59
|
+
private _storage: StorageAdapter
|
|
60
|
+
private _persistPath: string
|
|
61
|
+
private _metaPath: string
|
|
62
|
+
private _maxIndexedEpisodes: number
|
|
63
|
+
private _indexedIds: Set<string> = new Set()
|
|
64
|
+
private _dirty: boolean = false
|
|
65
|
+
private _persistDebounceTimer: NodeJS.Timeout | null = null
|
|
66
|
+
private _minSimilarity: number
|
|
67
|
+
/** Per-id access recency (insert + search hit) for LRU-style eviction. A plain
|
|
68
|
+
* monotonic counter — not persisted; rebuilt from insertion order on load. */
|
|
69
|
+
private _accessTick: Map<string, number> = new Map()
|
|
70
|
+
private _accessClock: number = 0
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
embedder: EmbeddingProvider,
|
|
74
|
+
config: VectorMemoryConfig & { persistPath?: string } = {},
|
|
75
|
+
storage: StorageAdapter = new BunStorageAdapter(),
|
|
76
|
+
indexImpl: VectorIndex = new HNSWIndex( config )
|
|
77
|
+
){
|
|
78
|
+
this._embedder = embedder
|
|
79
|
+
this._storage = storage
|
|
80
|
+
this._index = indexImpl
|
|
81
|
+
this._persistPath = config.persistPath ?? './data/vector_index'
|
|
82
|
+
this._metaPath = `${this._persistPath}.meta`
|
|
83
|
+
this._maxIndexedEpisodes = config.maxIndexedEpisodes ?? 10000
|
|
84
|
+
// 0.35 suits real sentence embeddings (e.g. text-embedding-3-small), where
|
|
85
|
+
// genuinely related-but-reworded memories commonly score 0.3–0.5 cosine. The
|
|
86
|
+
// old 0.65 floor was high enough that semantic recall returned nothing for
|
|
87
|
+
// most relevant memories and quietly fell back to recency. Tunable per Will.
|
|
88
|
+
this._minSimilarity = config.minSimilarity ?? 0.35
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Record that `id` was just inserted or recalled, so eviction keeps the
|
|
92
|
+
* memories the Will actually uses (LRU) and drops the genuinely cold ones. */
|
|
93
|
+
private _touch( id: string ): void {
|
|
94
|
+
this._accessTick.set( id, ++this._accessClock )
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get size(): number {
|
|
98
|
+
return this._index.size
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async index( episode: EpisodicMemory, content: unknown ): Promise<void> {
|
|
102
|
+
if( this._indexedIds.has( episode.id ) ) return
|
|
103
|
+
|
|
104
|
+
if( this._index.size >= this._maxIndexedEpisodes )
|
|
105
|
+
await this._evictColdest()
|
|
106
|
+
|
|
107
|
+
const embedding = await this._embedder.embed( episodeContentToText( content ), 'index' )
|
|
108
|
+
|
|
109
|
+
const record: VectorRecord = {
|
|
110
|
+
id: episode.id,
|
|
111
|
+
vector: embedding,
|
|
112
|
+
embeddingModel: this._embedder.modelName,
|
|
113
|
+
createdAt: wallClock(), // determinism-ok: secondary index telemetry, rebuilt from _store, never in replay state
|
|
114
|
+
metadata: {
|
|
115
|
+
tick: episode.timestamp,
|
|
116
|
+
sourceType: episode.sourceType,
|
|
117
|
+
emotionalValence: episode.affectiveContext.valence,
|
|
118
|
+
tags: episode.tags
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await this._index.insert( record )
|
|
123
|
+
this._indexedIds.add( episode.id )
|
|
124
|
+
this._touch( episode.id )
|
|
125
|
+
this._dirty = true
|
|
126
|
+
this._schedulePersist()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async indexBatch(
|
|
130
|
+
episodes: Array<{ episode: EpisodicMemory; content: unknown }>
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const newEpisodes = episodes.filter( e => !this._indexedIds.has( e.episode.id ) )
|
|
133
|
+
if( newEpisodes.length === 0 ) return
|
|
134
|
+
|
|
135
|
+
// `size > 0` guards against an infinite loop when the batch alone exceeds
|
|
136
|
+
// the cap — once the index is drained there is nothing left to evict.
|
|
137
|
+
while( this._index.size > 0 && this._index.size + newEpisodes.length > this._maxIndexedEpisodes )
|
|
138
|
+
await this._evictColdest()
|
|
139
|
+
|
|
140
|
+
const contents = newEpisodes.map( e => episodeContentToText( e.content ) )
|
|
141
|
+
const embeddings = await this._embedder.embedBatch( contents, 'index' )
|
|
142
|
+
|
|
143
|
+
for( let i = 0; i < newEpisodes.length; i++ ){
|
|
144
|
+
const { episode } = newEpisodes[i]!
|
|
145
|
+
const embedding = embeddings[i]!
|
|
146
|
+
|
|
147
|
+
const record: VectorRecord = {
|
|
148
|
+
id: episode.id,
|
|
149
|
+
vector: embedding,
|
|
150
|
+
embeddingModel: this._embedder.modelName,
|
|
151
|
+
createdAt: wallClock(), // determinism-ok: secondary index telemetry, rebuilt from _store, never in replay state
|
|
152
|
+
metadata: {
|
|
153
|
+
tick: episode.timestamp,
|
|
154
|
+
sourceType: episode.sourceType,
|
|
155
|
+
emotionalValence: episode.affectiveContext.valence,
|
|
156
|
+
tags: episode.tags
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await this._index.insert( record )
|
|
161
|
+
this._indexedIds.add( episode.id )
|
|
162
|
+
this._touch( episode.id )
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._dirty = true
|
|
166
|
+
this._schedulePersist()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async search( query: unknown, filter?: VectorQueryFilter ): Promise<VectorQueryResult[]> {
|
|
170
|
+
const embedding = await this._embedder.embed( episodeContentToText( query ), 'recall' )
|
|
171
|
+
return this.searchWithVector( embedding, filter )
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async searchWithVector( embedding: number[], filter?: VectorQueryFilter ): Promise<VectorQueryResult[]> {
|
|
175
|
+
// HNSW search is similarity-only (see VectorQueryFilter). Metadata-based
|
|
176
|
+
// narrowing, if any, is the caller's job post-search.
|
|
177
|
+
const results = await this._index.search( embedding, filter?.maxResults ?? 10, {
|
|
178
|
+
minSimilarity: filter?.minSimilarity ?? this._minSimilarity,
|
|
179
|
+
} )
|
|
180
|
+
// Recall warms the cache: bump access recency so frequently-recalled memories
|
|
181
|
+
// survive eviction (LRU) — the index keeps what the Will actually uses.
|
|
182
|
+
for( const r of results ) this._touch( r.episodeId )
|
|
183
|
+
return results
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async delete( episodeId: string ): Promise<void> {
|
|
187
|
+
if( await this._index.delete( episodeId ) ){
|
|
188
|
+
this._indexedIds.delete( episodeId )
|
|
189
|
+
this._accessTick.delete( episodeId )
|
|
190
|
+
this._dirty = true
|
|
191
|
+
this._schedulePersist()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async rebuildFromStore( store: EpisodicMemory[] ): Promise<void> {
|
|
196
|
+
await this._index.clear()
|
|
197
|
+
this._indexedIds.clear()
|
|
198
|
+
this._accessTick.clear()
|
|
199
|
+
this._accessClock = 0
|
|
200
|
+
|
|
201
|
+
const episodesWithContent = store.map( episode => ( {
|
|
202
|
+
episode,
|
|
203
|
+
content: episode.content
|
|
204
|
+
} ) )
|
|
205
|
+
|
|
206
|
+
await this.indexBatch( episodesWithContent )
|
|
207
|
+
this._dirty = true
|
|
208
|
+
await this.persist()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async persist(): Promise<void> {
|
|
212
|
+
if( !this._dirty ) return
|
|
213
|
+
|
|
214
|
+
if( this._index.serialize ){
|
|
215
|
+
const serialized = this._index.serialize()
|
|
216
|
+
await this._storage.write( this._persistPath, serialized )
|
|
217
|
+
// Stamp the embedding model + dimensions next to the index so a later
|
|
218
|
+
// load() can detect drift (see load()) and rebuild instead of querying
|
|
219
|
+
// vectors that live in a different model's space.
|
|
220
|
+
await this._storage.write(
|
|
221
|
+
this._metaPath,
|
|
222
|
+
JSON.stringify({ model: this._embedder.modelName, dimensions: this._embedder.dimensions })
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this._dirty = false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async load(): Promise<void> {
|
|
230
|
+
try {
|
|
231
|
+
const exists = await this._storage.exists( this._persistPath )
|
|
232
|
+
if( !exists ) return
|
|
233
|
+
|
|
234
|
+
// Guard against embedding-model / dimension drift. A persisted index holds
|
|
235
|
+
// vectors in the model's space at build time; querying it with a different
|
|
236
|
+
// model (or dimension count) silently corrupts similarity — mismatched
|
|
237
|
+
// lengths read past array ends → NaN, so recall quietly returns nothing.
|
|
238
|
+
// On a mismatch we skip the load and leave the index empty, which makes the
|
|
239
|
+
// restore path rebuild it from the store with the current embedder.
|
|
240
|
+
// A missing meta file (index persisted before this stamp existed) is treated
|
|
241
|
+
// as "unknown but trusted" — we proceed rather than force a mass re-embed.
|
|
242
|
+
if( await this._storage.exists( this._metaPath ) ){
|
|
243
|
+
try {
|
|
244
|
+
const meta = JSON.parse( await this._storage.read( this._metaPath ) ) as { model?: string; dimensions?: number }
|
|
245
|
+
if( meta.model !== this._embedder.modelName || meta.dimensions !== this._embedder.dimensions ){
|
|
246
|
+
logger.warn(
|
|
247
|
+
`[VectorMemoryAdapter] Persisted index was built with ${meta.model}/${meta.dimensions}d ` +
|
|
248
|
+
`but current embedder is ${this._embedder.modelName}/${this._embedder.dimensions}d — ` +
|
|
249
|
+
`discarding stale index (will rebuild from store).`
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch { /* unreadable meta — fall through to a best-effort load */ }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const bytes = await this._storage.readBytes( this._persistPath )
|
|
258
|
+
|
|
259
|
+
if( this._index.deserialize ){
|
|
260
|
+
await this._index.deserialize( bytes )
|
|
261
|
+
// Rebuild the dedup/eviction id-set from the loaded index. Without this,
|
|
262
|
+
// _indexedIds stays empty after a restore while the index is full, so
|
|
263
|
+
// _evictOldest (which iterates _indexedIds) becomes a no-op and the
|
|
264
|
+
// indexBatch eviction loop spins forever once the index is at its cap.
|
|
265
|
+
this._indexedIds.clear()
|
|
266
|
+
this._accessTick.clear()
|
|
267
|
+
this._accessClock = 0
|
|
268
|
+
if( this._index.keys )
|
|
269
|
+
for( const id of this._index.keys() ){
|
|
270
|
+
this._indexedIds.add( id )
|
|
271
|
+
// Seed access recency in insertion order so a freshly-loaded index
|
|
272
|
+
// evicts oldest-first until real recalls warm specific entries.
|
|
273
|
+
this._touch( id )
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch( err ){
|
|
278
|
+
logger.warn( `[VectorMemoryAdapter] Failed to load index:`, err )
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private async _evictColdest(): Promise<void> {
|
|
283
|
+
// Evict the least-recently-used entries (lowest access tick), not merely the
|
|
284
|
+
// oldest-inserted: a memory that is recalled often stays warm and survives,
|
|
285
|
+
// while genuinely cold ones go first — so the bounded index keeps what the
|
|
286
|
+
// Will actually uses. Never-touched entries fall back to insertion order
|
|
287
|
+
// (their access tick was seeded at insert/load). Drop ~10% of the cap (≥1)
|
|
288
|
+
// per call to amortise the rank over many inserts; each call strictly shrinks
|
|
289
|
+
// the index, so the indexBatch eviction loop is guaranteed to terminate.
|
|
290
|
+
const target = Math.max( 1, Math.floor( this._maxIndexedEpisodes * 0.1 ) )
|
|
291
|
+
|
|
292
|
+
const victims = Array.from( this._indexedIds )
|
|
293
|
+
.sort( ( a, b ) => ( this._accessTick.get( a ) ?? 0 ) - ( this._accessTick.get( b ) ?? 0 ) )
|
|
294
|
+
.slice( 0, target )
|
|
295
|
+
|
|
296
|
+
for( const id of victims ){
|
|
297
|
+
await this._index.delete( id )
|
|
298
|
+
this._indexedIds.delete( id )
|
|
299
|
+
this._accessTick.delete( id )
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if( victims.length > 0 ) this._dirty = true
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private _schedulePersist(): void {
|
|
306
|
+
if( this._persistDebounceTimer )
|
|
307
|
+
clearTimeout( this._persistDebounceTimer )
|
|
308
|
+
|
|
309
|
+
this._persistDebounceTimer = setTimeout( () => {
|
|
310
|
+
this.persist().catch( err => {
|
|
311
|
+
logger.error( `[VectorMemoryAdapter] Persist failed:`, err )
|
|
312
|
+
} )
|
|
313
|
+
this._persistDebounceTimer = null
|
|
314
|
+
}, 5000 )
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/cognition/memory/vector.content.ts
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Projects an episode's stored content into a clean text string for embedding.
|
|
7
|
+
*
|
|
8
|
+
* Episodes are consolidated from working-memory items, so `content` is usually
|
|
9
|
+
* the raw WM `entity.metadata` object — a mix of the actual semantic text
|
|
10
|
+
* (summary / userMessage / willReply) and structural noise (wmType, numeric
|
|
11
|
+
* activation, attendedCount, tags). Embedding the whole JSON blob dilutes the
|
|
12
|
+
* vector with that noise and weakens recall. This extracts the human-meaningful
|
|
13
|
+
* text, falling back to JSON only for shapes we don't recognise so it never
|
|
14
|
+
* throws and never silently drops data.
|
|
15
|
+
*
|
|
16
|
+
* Applied at every write path (index / indexBatch, hence rebuildFromStore) so
|
|
17
|
+
* indexed vectors are consistent regardless of how they were built. Query
|
|
18
|
+
* strings are already clean text and pass through unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function episodeContentToText( content: unknown ): string {
|
|
21
|
+
if( typeof content === 'string' ) return content
|
|
22
|
+
|
|
23
|
+
if( content && typeof content === 'object' ){
|
|
24
|
+
const m = content as Record<string, unknown>
|
|
25
|
+
|
|
26
|
+
// Conversation exchanges carry the actual dialogue — by far the strongest
|
|
27
|
+
// semantic signal — so prefer it over the pre-baked summary string.
|
|
28
|
+
const user = typeof m['userMessage'] === 'string' ? m['userMessage'] as string : ''
|
|
29
|
+
const reply = typeof m['willReply'] === 'string' ? m['willReply'] as string : ''
|
|
30
|
+
if( user || reply )
|
|
31
|
+
return [ user, reply ].filter( Boolean ).join( ' → ' )
|
|
32
|
+
|
|
33
|
+
// Most WM items carry a top-level human summary.
|
|
34
|
+
if( typeof m['summary'] === 'string' && m['summary'] ) return m['summary'] as string
|
|
35
|
+
|
|
36
|
+
// Descriptor items nest their payload (e.g. plan: { content: { summary } }).
|
|
37
|
+
const nested = m['content']
|
|
38
|
+
if( typeof nested === 'string' && nested ) return nested
|
|
39
|
+
if( nested && typeof nested === 'object' ){
|
|
40
|
+
const nm = nested as Record<string, unknown>
|
|
41
|
+
if( typeof nm['summary'] === 'string' && nm['summary'] ) return nm['summary'] as string
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Unknown shape — stable JSON so we still embed something deterministic.
|
|
46
|
+
try { return JSON.stringify( content ) ?? '' } catch { return String( content ) }
|
|
47
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/cognition/memory/vector.embedder.ts
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Embedding provider interface — abstracts different embedding models.
|
|
7
|
+
*
|
|
8
|
+
* Supports:
|
|
9
|
+
* - Local models (via Transformers.js or Ollama)
|
|
10
|
+
* - Cloud providers (OpenAI, Anthropic, Cohere)
|
|
11
|
+
* - Mock embedder for testing/deterministic replay
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TokenTracker } from '#cognition/utilities/token.tracker'
|
|
15
|
+
|
|
16
|
+
export interface EmbeddingProvider {
|
|
17
|
+
readonly modelName: string
|
|
18
|
+
readonly dimensions: number
|
|
19
|
+
|
|
20
|
+
/** Generate embedding for a single piece of content. `fn` tags the call for
|
|
21
|
+
* cost attribution: 'recall' (query) vs 'index' (write). */
|
|
22
|
+
embed( content: unknown, fn?: string ): Promise<number[]>
|
|
23
|
+
|
|
24
|
+
/** Generate embeddings for multiple items (batched for efficiency). */
|
|
25
|
+
embedBatch( contents: unknown[], fn?: string ): Promise<number[][]>
|
|
26
|
+
|
|
27
|
+
/** Check if two embeddings are semantically equivalent (for replay validation) */
|
|
28
|
+
areEquivalent( embedding1: number[], embedding2: number[], tolerance?: number ): boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* OpenAI-compatible embedder (works with OpenAI, Azure, LocalAI, Ollama)
|
|
33
|
+
*/
|
|
34
|
+
export class OpenAICompatibleEmbedder implements EmbeddingProvider {
|
|
35
|
+
readonly modelName: string
|
|
36
|
+
readonly dimensions: number
|
|
37
|
+
|
|
38
|
+
private _apiUrl: string
|
|
39
|
+
private _apiKey: string | null
|
|
40
|
+
private _maxConcurrency: number
|
|
41
|
+
private _timeoutMs: number
|
|
42
|
+
private _tokenTracker: TokenTracker | null
|
|
43
|
+
|
|
44
|
+
constructor( config: {
|
|
45
|
+
modelName: string
|
|
46
|
+
dimensions: number
|
|
47
|
+
apiUrl: string
|
|
48
|
+
apiKey?: string | null
|
|
49
|
+
/** Max embedding requests in flight at once for embedBatch(). Default 8. */
|
|
50
|
+
maxConcurrency?: number
|
|
51
|
+
/** @deprecated use maxConcurrency — kept as its fallback for back-compat. */
|
|
52
|
+
batchSize?: number
|
|
53
|
+
/** Per-request timeout in ms before the connection is aborted. Default 30s. */
|
|
54
|
+
timeoutMs?: number
|
|
55
|
+
/**
|
|
56
|
+
* Per-Will token tracker. When provided, each embedding call records its
|
|
57
|
+
* input-token usage under the 'embedding' category so memory-vector spend is
|
|
58
|
+
* visible alongside LLM spend instead of being a silent COGS leak.
|
|
59
|
+
*/
|
|
60
|
+
tokenTracker?: TokenTracker | null
|
|
61
|
+
} ){
|
|
62
|
+
this.modelName = config.modelName
|
|
63
|
+
this.dimensions = config.dimensions
|
|
64
|
+
this._apiUrl = config.apiUrl
|
|
65
|
+
this._apiKey = config.apiKey ?? null
|
|
66
|
+
this._maxConcurrency = Math.max( 1, config.maxConcurrency ?? config.batchSize ?? 8 )
|
|
67
|
+
this._timeoutMs = config.timeoutMs ?? 30_000
|
|
68
|
+
this._tokenTracker = config.tokenTracker ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async embed( content: unknown, fn: string = 'recall' ): Promise<number[]> {
|
|
72
|
+
let response: Response
|
|
73
|
+
try {
|
|
74
|
+
response = await fetch( `${this._apiUrl}/embeddings`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
...( this._apiKey ? { 'Authorization': `Bearer ${this._apiKey}` } : {} )
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
model: this.modelName,
|
|
82
|
+
input: typeof content === 'string' ? content : JSON.stringify( content )
|
|
83
|
+
}),
|
|
84
|
+
// Abort a hung connection instead of waiting forever (FN16).
|
|
85
|
+
signal: AbortSignal.timeout( this._timeoutMs ),
|
|
86
|
+
} )
|
|
87
|
+
}
|
|
88
|
+
catch( err ){
|
|
89
|
+
// AbortSignal.timeout rejects with a DOMException named 'TimeoutError'.
|
|
90
|
+
if( err instanceof Error && err.name === 'TimeoutError' )
|
|
91
|
+
throw new Error( `Embedding request timed out after ${this._timeoutMs}ms` )
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if( !response.ok )
|
|
96
|
+
throw new Error( `Embedding failed: ${response.status} ${response.statusText}` )
|
|
97
|
+
|
|
98
|
+
const data = await response.json() as {
|
|
99
|
+
data?: Array<{ embedding?: number[] }>
|
|
100
|
+
usage?: { prompt_tokens?: number; total_tokens?: number }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Guard the response shape: the old `data.data[0]!.embedding` threw an
|
|
104
|
+
// opaque TypeError on an empty/malformed body (FN16).
|
|
105
|
+
const embedding = data?.data?.[ 0 ]?.embedding
|
|
106
|
+
if( !Array.isArray( embedding ) || embedding.length === 0 )
|
|
107
|
+
throw new Error( `Embedding response was empty or malformed for model ${this.modelName}` )
|
|
108
|
+
|
|
109
|
+
// Meter embedding token usage (input-only — embeddings have no completion).
|
|
110
|
+
// Recorded under the 'embedding' category so per-Will dashboards can split
|
|
111
|
+
// memory-vector spend from LLM reasoning spend.
|
|
112
|
+
const usedTok = data?.usage?.total_tokens ?? data?.usage?.prompt_tokens ?? 0
|
|
113
|
+
if( usedTok > 0 ){
|
|
114
|
+
this._tokenTracker?.recordUsage({
|
|
115
|
+
model: this.modelName,
|
|
116
|
+
promptTokens: usedTok,
|
|
117
|
+
completionTokens: 0,
|
|
118
|
+
totalTokens: usedTok,
|
|
119
|
+
category: 'embedding',
|
|
120
|
+
attribute: 'memory',
|
|
121
|
+
function: fn, // 'recall' (query) | 'index' (write)
|
|
122
|
+
scope: this.modelName,
|
|
123
|
+
tick: 0,
|
|
124
|
+
latencyMs: 0,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return embedding
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async embedBatch( contents: unknown[], fn: string = 'index' ): Promise<number[][]> {
|
|
132
|
+
// Bounded fan-out: cap concurrent requests at _maxConcurrency instead of
|
|
133
|
+
// firing all of them at once (FN16), while preserving input order.
|
|
134
|
+
const results: number[][] = new Array( contents.length )
|
|
135
|
+
let next = 0
|
|
136
|
+
|
|
137
|
+
const worker = async (): Promise<void> => {
|
|
138
|
+
for( let i = next++; i < contents.length; i = next++ )
|
|
139
|
+
results[ i ] = await this.embed( contents[ i ], fn )
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await Promise.all(
|
|
143
|
+
Array.from( { length: Math.min( this._maxConcurrency, contents.length ) }, () => worker() )
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
areEquivalent( embedding1: number[], embedding2: number[], tolerance: number = 1e-6 ): boolean {
|
|
150
|
+
if( embedding1.length !== embedding2.length ) return false
|
|
151
|
+
|
|
152
|
+
for( let i = 0; i < embedding1.length; i++ ){
|
|
153
|
+
if( Math.abs( embedding1[i]! - embedding2[i]! ) > tolerance ) return false
|
|
154
|
+
}
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Deterministic mock embedder for testing and replay.
|
|
161
|
+
* Uses content hashing to produce stable embeddings.
|
|
162
|
+
*/
|
|
163
|
+
export class MockEmbedder implements EmbeddingProvider {
|
|
164
|
+
readonly modelName = 'mock'
|
|
165
|
+
readonly dimensions = 128
|
|
166
|
+
|
|
167
|
+
private _seed: number
|
|
168
|
+
|
|
169
|
+
constructor( seed: number = 42 ){
|
|
170
|
+
this._seed = seed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async embed( content: unknown, _fn: string = 'recall' ): Promise<number[]> {
|
|
174
|
+
const str = typeof content === 'string' ? content : JSON.stringify( content )
|
|
175
|
+
const hash = this._hashString( str )
|
|
176
|
+
const embedding: number[] = []
|
|
177
|
+
|
|
178
|
+
let state = hash
|
|
179
|
+
for( let i = 0; i < this.dimensions; i++ ){
|
|
180
|
+
state = this._next( state )
|
|
181
|
+
embedding.push( ( state % 200000 ) / 200000 - 1 )
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return embedding
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async embedBatch( contents: unknown[], fn: string = 'index' ): Promise<number[][]> {
|
|
188
|
+
return Promise.all( contents.map( c => this.embed( c, fn ) ) )
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
areEquivalent( embedding1: number[], embedding2: number[], tolerance: number = 1e-6 ): boolean {
|
|
192
|
+
if( embedding1.length !== embedding2.length ) return false
|
|
193
|
+
for( let i = 0; i < embedding1.length; i++ ){
|
|
194
|
+
if( Math.abs( embedding1[i]! - embedding2[i]! ) > tolerance ) return false
|
|
195
|
+
}
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private _hashString( str: string ): number {
|
|
200
|
+
let hash = 2166136261
|
|
201
|
+
for( let i = 0; i < str.length; i++ ){
|
|
202
|
+
hash ^= str.charCodeAt( i )
|
|
203
|
+
hash = Math.imul( hash, 16777619 )
|
|
204
|
+
}
|
|
205
|
+
return hash >>> 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private _next( state: number ): number {
|
|
209
|
+
state = Math.imul( state, 1103515245 ) + 12345
|
|
210
|
+
return state >>> 0
|
|
211
|
+
}
|
|
212
|
+
}
|