Files
Rohit Ghumare 6cc9b9f0fe fix: env hydration, indexing, consolidation lifecycle, connector activation, hardening (#1136)
* fix: env hydration, indexing, consolidation, connectors, hardening

- config: hydrate ~/.agentmemory/.env into process.env at boot so all modules see it
- search: shared indexRecords() so export-import and replay populate BM25 and vector (#1072)
- snapshot: wire the periodic timer (#1006), clamp non-positive intervals, add a reentrancy guard
- schema: CJK-aware jaccard dedup plus exact-match fallback for short memories
- embeddings: shared resolveDimensions() so openrouter stops hardcoding 1536 (#1002)
- viewer: buffer request bodies before decoding to fix multibyte corruption (#930)
- providers: retry 429/503 with Retry-After under a total-elapsed budget cap
- consolidation: fire on session stop (#1087), gate keyless installs, debounce the per-turn stop hook, drop the client-side double-fire
- evict: bound stale-session recovery to one consolidation pass
- api/patterns: bound session fan-out (#1100)
- connect: write a memory-usage guideline into each hook-less agent's native rules file (12 agents, doc-verified paths, --no-guidelines opt-out)
- graph: import graphify's graph.json via mem::graph::import-graphify + POST /agentmemory/graph/import-graphify; shared persistGraphDelta with endpoint remap so merged nodes never leave dangling or duplicate edges
- fs-watcher: stat roots before fs.watch so missing roots fail deterministically on Node 24+
- test: regression tests for every fix

* fix: address review findings on import, debounce, and connect paths

- guidelines: refuse to touch files with a lone or reversed marker pair
- export-import/replay: indexing after committed writes is best-effort,
  logged instead of failing the import; flatten the nested runChunked so
  replace-mode deletes stay bounded to one chunk
- graph: persist the snapshot when merge-only batches mutate cached
  topNodes/topEdges entries
- graph-import: async fs, typeof validation on path/cwd; REST handler
  whitelists the payload and 400s non-string values
- fetch: cancel discarded response bodies before retrying
- events: serialize the consolidation cooldown check so concurrent stops
  cannot both pass the read-check-write window
- evict: gate recovered-session consolidation on isConsolidationEnabled
  and mirror the stop path's force flag
- search: rebuild indexes per session chunk to bound peak memory
- test: regression coverage for each (malformed markers, concurrent
  stops, snapshot persistence, AMBIGUOUS/default mappings, env isolation)
2026-08-02 11:16:30 +01:00

125 lines
4.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { KV, STREAM, generateId, jaccardSimilarity } from '../src/state/schema.js'
describe('KV', () => {
it('has correct session scope', () => {
expect(KV.sessions).toBe('mem:sessions')
})
it('generates observation scope with session ID', () => {
expect(KV.observations('ses_123')).toBe('mem:obs:ses_123')
})
it('has correct summaries scope', () => {
expect(KV.summaries).toBe('mem:summaries')
})
})
describe('STREAM', () => {
it('has correct name', () => {
expect(STREAM.name).toBe('mem-live')
})
it('group returns session ID', () => {
expect(STREAM.group('ses_123')).toBe('ses_123')
})
})
describe('generateId', () => {
it('includes prefix', () => {
expect(generateId('obs')).toMatch(/^obs_/)
})
it('generates unique IDs', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateId('test')))
expect(ids.size).toBe(100)
})
it('has sufficient length', () => {
const id = generateId('obs')
expect(id.length).toBeGreaterThan(15)
})
})
describe('jaccardSimilarity', () => {
it('returns 1 for identical ASCII strings', () => {
const s = 'always use express-jwt middleware for token validation'
expect(jaccardSimilarity(s, s)).toBe(1)
})
it('keeps ASCII word-level behavior', () => {
const a = 'always use express-jwt middleware for token validation'
const b = 'always use express-jwt middleware for request validation'
const score = jaccardSimilarity(a, b)
expect(score).toBeGreaterThan(0.5)
expect(score).toBeLessThan(1)
})
it('returns 0 for unrelated ASCII strings', () => {
expect(
jaccardSimilarity('the quick brown fox', 'lorem ipsum dolor sit'),
).toBe(0)
})
it('returns 0 (never 1) when both token sets are empty and inputs differ', () => {
// Two short ASCII strings whose tokens are all filtered out by the
// length gate must not be treated as identical.
expect(jaccardSimilarity('a b', 'x y')).toBe(0)
})
it('supersedes identical short memories that tokenize to nothing', () => {
// Regression: words <=2 chars are dropped, so "AI" / "go" / "a b"
// produce empty token sets. Re-saving the exact same short memory must
// still be detected as a duplicate (score 1) via an exact-equality
// fallback, instead of leaking duplicate latest records.
expect(jaccardSimilarity('AI', 'AI')).toBe(1)
expect(jaccardSimilarity('go', 'go')).toBe(1)
// Unrelated short strings must still score 0, not falsely supersede.
expect(jaccardSimilarity('AI', 'ML')).toBe(0)
expect(jaccardSimilarity('go', 'AI')).toBe(0)
})
it('treats whitespace-only differences in short text as identical', () => {
// The exact-equality fallback collapses runs of whitespace and trims,
// so cosmetic spacing differences on an otherwise-empty-token memory
// still dedupe.
expect(jaccardSimilarity('a b', 'a b')).toBe(1)
expect(jaccardSimilarity(' AI ', 'AI')).toBe(1)
})
it('gives high similarity for near-identical CJK sentences', () => {
const a = '用户认证中间件必须先去除请求头里的 Bearer 前缀然后再校验令牌'
const b = '用户认证中间件必须先去除请求头里的 Bearer 前缀然后校验令牌'
expect(jaccardSimilarity(a, b)).toBeGreaterThan(0.7)
})
it('gives low similarity for unrelated short CJK strings', () => {
// "北京" vs "上海" — the old empty-set shortcut returned 1 here and
// falsely superseded an unrelated memory. Must be well below the
// 0.7 supersede threshold.
expect(jaccardSimilarity('北京', '上海')).toBeLessThan(0.7)
expect(jaccardSimilarity('北京', '上海')).toBe(0)
})
it('detects a duplicate for identical CJK strings', () => {
expect(jaccardSimilarity('设置认证中间件', '设置认证中间件')).toBe(1)
})
it('handles Japanese kana without whitespace', () => {
const a = 'トークンを検証する前に接頭辞を取り除く'
const b = 'トークンを検証する前に接頭辞を削除する'
expect(jaccardSimilarity(a, b)).toBeGreaterThan(0.4)
expect(jaccardSimilarity('東京', '大阪')).toBe(0)
})
it('NFC-normalizes before comparing', () => {
// Composed U+00E9 vs decomposed 'e' + U+0301 combining accent for
// "caf\u00e9" must compare equal even though the two strings differ
// byte-for-byte before normalization.
const composed = 'caf\u00e9 latte order'
const decomposed = 'cafe\u0301 latte order'
expect(composed).not.toBe(decomposed)
expect(jaccardSimilarity(composed, decomposed)).toBe(1)
})
})