@vellumai/assistant 0.4.49 → 0.4.50
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/ARCHITECTURE.md +24 -33
- package/README.md +3 -3
- package/docs/architecture/memory.md +180 -119
- package/package.json +2 -2
- package/src/__tests__/agent-loop.test.ts +3 -1
- package/src/__tests__/anthropic-provider.test.ts +114 -23
- package/src/__tests__/approval-cascade.test.ts +1 -15
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
- package/src/__tests__/canonical-guardian-store.test.ts +95 -0
- package/src/__tests__/checker.test.ts +13 -0
- package/src/__tests__/config-schema.test.ts +1 -68
- package/src/__tests__/context-memory-e2e.test.ts +11 -100
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
- package/src/__tests__/credential-security-e2e.test.ts +1 -0
- package/src/__tests__/credential-vault-unit.test.ts +4 -0
- package/src/__tests__/credential-vault.test.ts +13 -1
- package/src/__tests__/cu-unified-flow.test.ts +532 -0
- package/src/__tests__/date-context.test.ts +93 -77
- package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
- package/src/__tests__/history-repair.test.ts +245 -0
- package/src/__tests__/host-cu-proxy.test.ts +165 -3
- package/src/__tests__/http-user-message-parity.test.ts +1 -0
- package/src/__tests__/invite-redemption-service.test.ts +65 -1
- package/src/__tests__/keychain-broker-client.test.ts +4 -4
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
- package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
- package/src/__tests__/memory-recall-quality.test.ts +244 -407
- package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
- package/src/__tests__/memory-regressions.test.ts +477 -2841
- package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
- package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
- package/src/__tests__/mime-builder.test.ts +28 -0
- package/src/__tests__/native-web-search.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +572 -5
- package/src/__tests__/oauth-store.test.ts +120 -6
- package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
- package/src/__tests__/registry.test.ts +0 -1
- package/src/__tests__/relay-server.test.ts +46 -1
- package/src/__tests__/schedule-tools.test.ts +32 -0
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +1 -0
- package/src/__tests__/secure-keys.test.ts +7 -2
- package/src/__tests__/send-endpoint-busy.test.ts +3 -0
- package/src/__tests__/session-abort-tool-results.test.ts +1 -14
- package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
- package/src/__tests__/session-agent-loop.test.ts +19 -15
- package/src/__tests__/session-confirmation-signals.test.ts +1 -15
- package/src/__tests__/session-error.test.ts +124 -2
- package/src/__tests__/session-history-web-search.test.ts +918 -0
- package/src/__tests__/session-pre-run-repair.test.ts +1 -14
- package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
- package/src/__tests__/session-queue.test.ts +37 -27
- package/src/__tests__/session-runtime-assembly.test.ts +54 -0
- package/src/__tests__/session-slash-known.test.ts +1 -15
- package/src/__tests__/session-slash-queue.test.ts +1 -15
- package/src/__tests__/session-slash-unknown.test.ts +1 -15
- package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
- package/src/__tests__/session-workspace-injection.test.ts +3 -37
- package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
- package/src/__tests__/skills-install-extract.test.ts +93 -0
- package/src/__tests__/skillssh-registry.test.ts +451 -0
- package/src/__tests__/trust-store.test.ts +15 -0
- package/src/__tests__/voice-invite-redemption.test.ts +32 -1
- package/src/agent/ax-tree-compaction.test.ts +51 -0
- package/src/agent/loop.ts +39 -12
- package/src/approvals/AGENTS.md +1 -1
- package/src/approvals/guardian-request-resolvers.ts +14 -2
- package/src/bundler/compiler-tools.ts +66 -2
- package/src/calls/call-domain.ts +132 -0
- package/src/calls/call-store.ts +6 -0
- package/src/calls/relay-server.ts +43 -5
- package/src/calls/relay-setup-router.ts +17 -1
- package/src/calls/twilio-config.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli/commands/doctor.ts +4 -3
- package/src/cli/commands/mcp.ts +46 -59
- package/src/cli/commands/memory.ts +16 -165
- package/src/cli/commands/oauth/apps.ts +31 -2
- package/src/cli/commands/oauth/connections.ts +431 -97
- package/src/cli/commands/oauth/providers.ts +15 -1
- package/src/cli/commands/sessions.ts +5 -2
- package/src/cli/commands/skills.ts +173 -1
- package/src/cli/http-client.ts +0 -20
- package/src/cli/main-screen.tsx +2 -2
- package/src/cli/program.ts +5 -6
- package/src/cli.ts +4 -10
- package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
- package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
- package/src/config/bundled-tool-registry.ts +2 -5
- package/src/config/schema.ts +1 -12
- package/src/config/schemas/memory-lifecycle.ts +0 -9
- package/src/config/schemas/memory-processing.ts +0 -180
- package/src/config/schemas/memory-retrieval.ts +32 -104
- package/src/config/schemas/memory.ts +0 -10
- package/src/config/types.ts +0 -4
- package/src/context/window-manager.ts +4 -1
- package/src/daemon/config-watcher.ts +61 -3
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/date-context.ts +114 -31
- package/src/daemon/handlers/sessions.ts +18 -13
- package/src/daemon/handlers/skills.ts +20 -1
- package/src/daemon/history-repair.ts +72 -8
- package/src/daemon/host-cu-proxy.ts +55 -26
- package/src/daemon/lifecycle.ts +31 -3
- package/src/daemon/mcp-reload-service.ts +2 -2
- package/src/daemon/message-types/computer-use.ts +1 -12
- package/src/daemon/message-types/memory.ts +4 -16
- package/src/daemon/message-types/messages.ts +1 -0
- package/src/daemon/message-types/sessions.ts +4 -0
- package/src/daemon/server.ts +12 -1
- package/src/daemon/session-agent-loop-handlers.ts +38 -0
- package/src/daemon/session-agent-loop.ts +334 -48
- package/src/daemon/session-error.ts +89 -6
- package/src/daemon/session-history.ts +17 -7
- package/src/daemon/session-media-retry.ts +6 -2
- package/src/daemon/session-memory.ts +69 -149
- package/src/daemon/session-process.ts +10 -1
- package/src/daemon/session-runtime-assembly.ts +49 -19
- package/src/daemon/session-surfaces.ts +4 -1
- package/src/daemon/session-tool-setup.ts +7 -1
- package/src/daemon/session.ts +12 -2
- package/src/instrument.ts +61 -1
- package/src/memory/admin.ts +2 -191
- package/src/memory/canonical-guardian-store.ts +38 -2
- package/src/memory/conversation-crud.ts +0 -33
- package/src/memory/conversation-queries.ts +22 -3
- package/src/memory/db-init.ts +28 -0
- package/src/memory/embedding-backend.ts +84 -8
- package/src/memory/embedding-types.ts +9 -1
- package/src/memory/indexer.ts +7 -46
- package/src/memory/items-extractor.ts +274 -76
- package/src/memory/job-handlers/backfill.ts +2 -127
- package/src/memory/job-handlers/cleanup.ts +2 -16
- package/src/memory/job-handlers/extraction.ts +2 -138
- package/src/memory/job-handlers/index-maintenance.ts +1 -6
- package/src/memory/job-handlers/summarization.ts +3 -148
- package/src/memory/job-utils.ts +21 -59
- package/src/memory/jobs-store.ts +1 -159
- package/src/memory/jobs-worker.ts +9 -52
- package/src/memory/migrations/104-core-indexes.ts +3 -3
- package/src/memory/migrations/149-oauth-tables.ts +2 -0
- package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
- package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
- package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
- package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
- package/src/memory/migrations/154-drop-fts.ts +20 -0
- package/src/memory/migrations/155-drop-conflicts.ts +7 -0
- package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
- package/src/memory/migrations/index.ts +7 -0
- package/src/memory/qdrant-client.ts +148 -51
- package/src/memory/raw-query.ts +1 -1
- package/src/memory/retriever.test.ts +294 -273
- package/src/memory/retriever.ts +421 -645
- package/src/memory/schema/calls.ts +2 -0
- package/src/memory/schema/memory-core.ts +3 -48
- package/src/memory/schema/oauth.ts +2 -0
- package/src/memory/search/formatting.ts +263 -176
- package/src/memory/search/lexical.ts +1 -254
- package/src/memory/search/ranking.ts +0 -455
- package/src/memory/search/semantic.ts +100 -14
- package/src/memory/search/staleness.ts +47 -0
- package/src/memory/search/tier-classifier.ts +21 -0
- package/src/memory/search/types.ts +15 -77
- package/src/memory/task-memory-cleanup.ts +4 -6
- package/src/messaging/providers/gmail/mime-builder.ts +17 -7
- package/src/oauth/byo-connection.test.ts +8 -1
- package/src/oauth/oauth-store.ts +113 -27
- package/src/oauth/seed-providers.ts +6 -0
- package/src/oauth/token-persistence.ts +11 -3
- package/src/permissions/defaults.ts +1 -0
- package/src/permissions/trust-store.ts +23 -1
- package/src/playbooks/playbook-compiler.ts +1 -1
- package/src/prompts/system-prompt.ts +18 -2
- package/src/providers/anthropic/client.ts +56 -126
- package/src/providers/types.ts +7 -1
- package/src/runtime/AGENTS.md +9 -0
- package/src/runtime/auth/route-policy.ts +6 -3
- package/src/runtime/guardian-reply-router.ts +24 -22
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/invite-redemption-service.ts +19 -1
- package/src/runtime/invite-service.ts +25 -0
- package/src/runtime/pending-interactions.ts +2 -2
- package/src/runtime/routes/brain-graph-routes.ts +10 -90
- package/src/runtime/routes/conversation-routes.ts +9 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
- package/src/runtime/routes/memory-item-routes.test.ts +754 -0
- package/src/runtime/routes/memory-item-routes.ts +503 -0
- package/src/runtime/routes/session-management-routes.ts +3 -3
- package/src/runtime/routes/settings-routes.ts +2 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -0
- package/src/runtime/routes/workspace-routes.ts +2 -1
- package/src/security/keychain-broker-client.ts +17 -4
- package/src/security/secure-keys.ts +25 -3
- package/src/security/token-manager.ts +36 -36
- package/src/skills/catalog-install.ts +74 -18
- package/src/skills/skillssh-registry.ts +503 -0
- package/src/tools/assets/search.ts +5 -1
- package/src/tools/computer-use/definitions.ts +0 -10
- package/src/tools/computer-use/registry.ts +1 -1
- package/src/tools/credentials/vault.ts +1 -3
- package/src/tools/memory/definitions.ts +4 -13
- package/src/tools/memory/handlers.test.ts +83 -103
- package/src/tools/memory/handlers.ts +50 -85
- package/src/tools/schedule/create.ts +8 -1
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/skills/load.ts +25 -2
- package/src/__tests__/clarification-resolver.test.ts +0 -193
- package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
- package/src/__tests__/conflict-policy.test.ts +0 -269
- package/src/__tests__/conflict-store.test.ts +0 -372
- package/src/__tests__/contradiction-checker.test.ts +0 -361
- package/src/__tests__/entity-extractor.test.ts +0 -211
- package/src/__tests__/entity-search.test.ts +0 -1117
- package/src/__tests__/profile-compiler.test.ts +0 -392
- package/src/__tests__/session-conflict-gate.test.ts +0 -1228
- package/src/__tests__/session-profile-injection.test.ts +0 -557
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
- package/src/daemon/session-conflict-gate.ts +0 -167
- package/src/daemon/session-dynamic-profile.ts +0 -77
- package/src/memory/clarification-resolver.ts +0 -417
- package/src/memory/conflict-intent.ts +0 -205
- package/src/memory/conflict-policy.ts +0 -127
- package/src/memory/conflict-store.ts +0 -410
- package/src/memory/contradiction-checker.ts +0 -508
- package/src/memory/entity-extractor.ts +0 -535
- package/src/memory/format-recall.ts +0 -47
- package/src/memory/fts-reconciler.ts +0 -165
- package/src/memory/job-handlers/conflict.ts +0 -200
- package/src/memory/profile-compiler.ts +0 -195
- package/src/memory/recall-cache.ts +0 -117
- package/src/memory/search/entity.ts +0 -535
- package/src/memory/search/query-expansion.test.ts +0 -70
- package/src/memory/search/query-expansion.ts +0 -118
- package/src/runtime/routes/mcp-routes.ts +0 -20
|
@@ -1,325 +1,3 @@
|
|
|
1
|
-
import { inArray, sql } from "drizzle-orm";
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
AssistantConfig,
|
|
5
|
-
MemoryRerankingConfig,
|
|
6
|
-
} from "../../config/types.js";
|
|
7
|
-
import { estimateTextTokens } from "../../context/token-estimator.js";
|
|
8
|
-
import {
|
|
9
|
-
extractText,
|
|
10
|
-
getConfiguredProvider,
|
|
11
|
-
userMessage,
|
|
12
|
-
} from "../../providers/provider-send-message.js";
|
|
13
|
-
import { getLogger } from "../../util/logger.js";
|
|
14
|
-
import { getDb } from "../db.js";
|
|
15
|
-
import { memoryItems } from "../schema.js";
|
|
16
|
-
import { buildInjectedText } from "./formatting.js";
|
|
17
|
-
import type { Candidate, CandidateSource, ItemMetadata } from "./types.js";
|
|
18
|
-
|
|
19
|
-
const log = getLogger("memory-retriever");
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Trust weight by verification state. Higher = more trusted.
|
|
23
|
-
* Bounded: lowest weight is 0.7, never zero -- low-trust items are
|
|
24
|
-
* down-ranked but not suppressed.
|
|
25
|
-
*/
|
|
26
|
-
const TRUST_WEIGHTS: Record<string, number> = {
|
|
27
|
-
user_confirmed: 1.0,
|
|
28
|
-
user_reported: 0.9,
|
|
29
|
-
assistant_inferred: 0.7,
|
|
30
|
-
};
|
|
31
|
-
const DEFAULT_TRUST_WEIGHT = 0.85;
|
|
32
|
-
|
|
33
|
-
export const SOURCE_WEIGHTS: Record<CandidateSource, number> = {
|
|
34
|
-
lexical: 1.0,
|
|
35
|
-
semantic: 1.0,
|
|
36
|
-
recency: 1.0,
|
|
37
|
-
entity_direct: 1.0,
|
|
38
|
-
item_direct: 0.95,
|
|
39
|
-
entity_relation: 1.0,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const MS_PER_DAY = 86_400_000;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Reciprocal Rank Fusion (RRF) -- merge candidates from independent ranking
|
|
46
|
-
* lists without assuming comparable score scales.
|
|
47
|
-
*
|
|
48
|
-
* Each candidate's RRF contribution from a list is `1 / (k + rank)` where
|
|
49
|
-
* rank is 1-based position in that list sorted by its native score.
|
|
50
|
-
* The final score is further modulated by importance so that high-importance
|
|
51
|
-
* memories surface more readily.
|
|
52
|
-
*
|
|
53
|
-
* For item-type candidates we also apply retrieval reinforcement: access_count
|
|
54
|
-
* from the DB boosts effective importance via `min(1, importance + 0.03 * accessCount)`.
|
|
55
|
-
*/
|
|
56
|
-
export function mergeCandidates(
|
|
57
|
-
lexical: Candidate[],
|
|
58
|
-
semantic: Candidate[],
|
|
59
|
-
recency: Candidate[],
|
|
60
|
-
entity: Candidate[] = [],
|
|
61
|
-
freshnessConfig?: {
|
|
62
|
-
enabled: boolean;
|
|
63
|
-
maxAgeDays: Record<string, number>;
|
|
64
|
-
staleDecay: number;
|
|
65
|
-
reinforcementShieldDays: number;
|
|
66
|
-
},
|
|
67
|
-
relationScoreMultiplier?: number,
|
|
68
|
-
candidateDepthMap?: Map<string, number>,
|
|
69
|
-
): Candidate[] {
|
|
70
|
-
// Build effective weight map that reflects the actual scoring weight for
|
|
71
|
-
// each source. For entity_relation the static SOURCE_WEIGHTS entry is 1.0
|
|
72
|
-
// (a neutral placeholder) but the real multiplier comes from the config
|
|
73
|
-
// (relationScoreMultiplier). Using the effective weight in the dedup
|
|
74
|
-
// upgrade comparison ensures item_direct (0.95) correctly outranks
|
|
75
|
-
// entity_relation (e.g. 0.7) when both sources return the same candidate.
|
|
76
|
-
const effectiveWeights: Record<string, number> = { ...SOURCE_WEIGHTS };
|
|
77
|
-
if (relationScoreMultiplier != null) {
|
|
78
|
-
effectiveWeights["entity_relation"] = relationScoreMultiplier;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Build merged candidate map (dedup by key, keep best metadata)
|
|
82
|
-
const merged = new Map<string, Candidate>();
|
|
83
|
-
for (const candidate of [...lexical, ...semantic, ...recency, ...entity]) {
|
|
84
|
-
const existing = merged.get(candidate.key);
|
|
85
|
-
if (!existing) {
|
|
86
|
-
merged.set(candidate.key, { ...candidate });
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
existing.lexical = Math.max(existing.lexical, candidate.lexical);
|
|
90
|
-
existing.semantic = Math.max(existing.semantic, candidate.semantic);
|
|
91
|
-
existing.recency = Math.max(existing.recency, candidate.recency);
|
|
92
|
-
existing.confidence = Math.max(existing.confidence, candidate.confidence);
|
|
93
|
-
existing.importance = Math.max(existing.importance, candidate.importance);
|
|
94
|
-
if (candidate.text.length > existing.text.length) {
|
|
95
|
-
existing.text = candidate.text;
|
|
96
|
-
}
|
|
97
|
-
// Upgrade source to whichever has the higher effective weight so scoring
|
|
98
|
-
// and caps reflect the strongest retrieval signal for this candidate.
|
|
99
|
-
const existingWeight = effectiveWeights[existing.source] ?? 1.0;
|
|
100
|
-
const candidateWeight = effectiveWeights[candidate.source] ?? 1.0;
|
|
101
|
-
if (candidateWeight > existingWeight) {
|
|
102
|
-
existing.source = candidate.source;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Build 1-based rank maps from each list (sorted by native score desc)
|
|
107
|
-
const lexicalRanks = buildRankMap(lexical, (c) => c.lexical);
|
|
108
|
-
const semanticRanks = buildRankMap(semantic, (c) => c.semantic);
|
|
109
|
-
const recencyRanks = buildRankMap(recency, (c) => c.recency);
|
|
110
|
-
const entityRanks = buildRankMap(entity, (c) => c.confidence);
|
|
111
|
-
|
|
112
|
-
// Look up access_count and verification_state for item-type candidates
|
|
113
|
-
const itemIds = [...merged.values()]
|
|
114
|
-
.filter((c) => c.type === "item")
|
|
115
|
-
.map((c) => c.id);
|
|
116
|
-
const itemMetadata = lookupItemMetadata(itemIds);
|
|
117
|
-
|
|
118
|
-
const rows = [...merged.values()];
|
|
119
|
-
for (const row of rows) {
|
|
120
|
-
const ranks: number[] = [];
|
|
121
|
-
if (lexicalRanks.has(row.key)) ranks.push(lexicalRanks.get(row.key)!);
|
|
122
|
-
if (semanticRanks.has(row.key)) ranks.push(semanticRanks.get(row.key)!);
|
|
123
|
-
if (recencyRanks.has(row.key)) ranks.push(recencyRanks.get(row.key)!);
|
|
124
|
-
if (entityRanks.has(row.key)) ranks.push(entityRanks.get(row.key)!);
|
|
125
|
-
|
|
126
|
-
const rrfScore = rrf(ranks);
|
|
127
|
-
|
|
128
|
-
// Retrieval reinforcement: boost importance by accessCount
|
|
129
|
-
const meta = itemMetadata.get(row.id);
|
|
130
|
-
const accessCount = meta?.accessCount ?? 0;
|
|
131
|
-
const effectiveImportance = Math.min(
|
|
132
|
-
1,
|
|
133
|
-
row.importance + 0.03 * accessCount,
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
// Trust-aware ranking: only apply to item candidates (segments/summaries have no metadata)
|
|
137
|
-
const trustWeight =
|
|
138
|
-
row.type === "item" && meta
|
|
139
|
-
? (TRUST_WEIGHTS[meta.verificationState] ?? DEFAULT_TRUST_WEIGHT)
|
|
140
|
-
: 1.0;
|
|
141
|
-
|
|
142
|
-
// Freshness decay: down-rank stale items unless recently reinforced
|
|
143
|
-
const lastUsedAt = meta?.lastUsedAt ?? null;
|
|
144
|
-
const freshnessWeight = computeFreshnessWeight(
|
|
145
|
-
row,
|
|
146
|
-
accessCount,
|
|
147
|
-
lastUsedAt,
|
|
148
|
-
freshnessConfig,
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
let sourceWeight = effectiveWeights[row.source] ?? 1.0;
|
|
152
|
-
if (
|
|
153
|
-
row.source === "entity_relation" &&
|
|
154
|
-
candidateDepthMap &&
|
|
155
|
-
relationScoreMultiplier != null
|
|
156
|
-
) {
|
|
157
|
-
const depth = candidateDepthMap.get(row.key) ?? 1;
|
|
158
|
-
sourceWeight = Math.pow(relationScoreMultiplier, depth);
|
|
159
|
-
}
|
|
160
|
-
row.finalScore =
|
|
161
|
-
rrfScore *
|
|
162
|
-
(0.5 + 0.5 * effectiveImportance) *
|
|
163
|
-
trustWeight *
|
|
164
|
-
freshnessWeight *
|
|
165
|
-
sourceWeight;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
rows.sort((a, b) => {
|
|
169
|
-
const scoreDelta = b.finalScore - a.finalScore;
|
|
170
|
-
if (scoreDelta !== 0) return scoreDelta;
|
|
171
|
-
const createdAtDelta = b.createdAt - a.createdAt;
|
|
172
|
-
if (createdAtDelta !== 0) return createdAtDelta;
|
|
173
|
-
return a.key.localeCompare(b.key);
|
|
174
|
-
});
|
|
175
|
-
return rows;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function applySourceCaps(
|
|
179
|
-
candidates: Candidate[],
|
|
180
|
-
config: AssistantConfig,
|
|
181
|
-
): Candidate[] {
|
|
182
|
-
if (candidates.length === 0) return candidates;
|
|
183
|
-
const sourceCaps = buildSourceCaps(config);
|
|
184
|
-
const counts: Partial<Record<CandidateSource, number>> = {};
|
|
185
|
-
const capped: Candidate[] = [];
|
|
186
|
-
|
|
187
|
-
for (const candidate of candidates) {
|
|
188
|
-
const cap = sourceCaps[candidate.source];
|
|
189
|
-
const current = counts[candidate.source] ?? 0;
|
|
190
|
-
if (current >= cap) continue;
|
|
191
|
-
counts[candidate.source] = current + 1;
|
|
192
|
-
capped.push(candidate);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return capped;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function buildSourceCaps(
|
|
199
|
-
config: AssistantConfig,
|
|
200
|
-
): Record<CandidateSource, number> {
|
|
201
|
-
const lexicalTopK = Math.max(1, config.memory.retrieval.lexicalTopK);
|
|
202
|
-
const semanticTopK = Math.max(1, config.memory.retrieval.semanticTopK);
|
|
203
|
-
const relationLimit = Math.max(
|
|
204
|
-
3,
|
|
205
|
-
Math.floor(
|
|
206
|
-
Math.min(
|
|
207
|
-
config.memory.entity.relationRetrieval.maxNeighborEntities,
|
|
208
|
-
config.memory.entity.relationRetrieval.maxEdges,
|
|
209
|
-
semanticTopK,
|
|
210
|
-
) * 0.4,
|
|
211
|
-
),
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
lexical: Math.max(12, lexicalTopK),
|
|
216
|
-
semantic: Math.max(8, semanticTopK),
|
|
217
|
-
recency: Math.max(6, Math.floor(semanticTopK / 2)),
|
|
218
|
-
entity_direct: Math.max(6, Math.floor(semanticTopK / 2)),
|
|
219
|
-
item_direct: Math.max(8, Math.floor(lexicalTopK / 2)),
|
|
220
|
-
entity_relation: relationLimit,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Reciprocal Rank Fusion score: sum of 1/(k+rank) across all lists. */
|
|
225
|
-
function rrf(ranks: number[], k = 60): number {
|
|
226
|
-
return ranks.reduce((sum, rank) => sum + 1 / (k + rank), 0);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Build a map from candidate key to 1-based rank within a list,
|
|
231
|
-
* sorted descending by the given score accessor.
|
|
232
|
-
*/
|
|
233
|
-
function buildRankMap(
|
|
234
|
-
candidates: Candidate[],
|
|
235
|
-
scoreAccessor: (c: Candidate) => number,
|
|
236
|
-
): Map<string, number> {
|
|
237
|
-
const sorted = [...candidates].sort(
|
|
238
|
-
(a, b) => scoreAccessor(b) - scoreAccessor(a),
|
|
239
|
-
);
|
|
240
|
-
const rankMap = new Map<string, number>();
|
|
241
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
242
|
-
rankMap.set(sorted[i].key, i + 1);
|
|
243
|
-
}
|
|
244
|
-
return rankMap;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Look up access_count and verification_state from memory_items for a batch of item IDs.
|
|
249
|
-
*/
|
|
250
|
-
function lookupItemMetadata(itemIds: string[]): Map<string, ItemMetadata> {
|
|
251
|
-
const metadata = new Map<string, ItemMetadata>();
|
|
252
|
-
if (itemIds.length === 0) return metadata;
|
|
253
|
-
try {
|
|
254
|
-
const db = getDb();
|
|
255
|
-
const rows = db
|
|
256
|
-
.select({
|
|
257
|
-
id: memoryItems.id,
|
|
258
|
-
accessCount: memoryItems.accessCount,
|
|
259
|
-
lastUsedAt: memoryItems.lastUsedAt,
|
|
260
|
-
verificationState: memoryItems.verificationState,
|
|
261
|
-
})
|
|
262
|
-
.from(memoryItems)
|
|
263
|
-
.where(inArray(memoryItems.id, itemIds))
|
|
264
|
-
.all();
|
|
265
|
-
for (const row of rows) {
|
|
266
|
-
metadata.set(row.id, {
|
|
267
|
-
accessCount: row.accessCount,
|
|
268
|
-
lastUsedAt: row.lastUsedAt,
|
|
269
|
-
verificationState: row.verificationState,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
} catch (err) {
|
|
273
|
-
log.warn({ err }, "Failed to look up item metadata for retrieval ranking");
|
|
274
|
-
}
|
|
275
|
-
return metadata;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Compute a freshness weight for a candidate based on its kind and age.
|
|
280
|
-
* Returns 1.0 for fresh items and `staleDecay` for items past their window.
|
|
281
|
-
* Items with recent reinforcement (accessed via lastUsedAt within the shield
|
|
282
|
-
* window) are shielded from decay.
|
|
283
|
-
*/
|
|
284
|
-
export function computeFreshnessWeight(
|
|
285
|
-
candidate: { type: string; kind: string; createdAt: number },
|
|
286
|
-
accessCount: number,
|
|
287
|
-
lastUsedAt: number | null,
|
|
288
|
-
config?: {
|
|
289
|
-
enabled: boolean;
|
|
290
|
-
maxAgeDays: Record<string, number>;
|
|
291
|
-
staleDecay: number;
|
|
292
|
-
reinforcementShieldDays: number;
|
|
293
|
-
},
|
|
294
|
-
): number {
|
|
295
|
-
if (!config?.enabled) return 1.0;
|
|
296
|
-
|
|
297
|
-
// Only apply freshness to item-type candidates
|
|
298
|
-
if (candidate.type !== "item") return 1.0;
|
|
299
|
-
|
|
300
|
-
const maxAgeDays = config.maxAgeDays[candidate.kind] ?? 0;
|
|
301
|
-
// maxAgeDays of 0 means no expiry for this kind
|
|
302
|
-
if (maxAgeDays <= 0) return 1.0;
|
|
303
|
-
|
|
304
|
-
const now = Date.now();
|
|
305
|
-
const ageMs = now - candidate.createdAt;
|
|
306
|
-
const ageDays = ageMs / MS_PER_DAY;
|
|
307
|
-
|
|
308
|
-
if (ageDays <= maxAgeDays) return 1.0;
|
|
309
|
-
|
|
310
|
-
// Check reinforcement shield: items retrieved within the shield window are protected
|
|
311
|
-
if (
|
|
312
|
-
accessCount > 0 &&
|
|
313
|
-
lastUsedAt != null &&
|
|
314
|
-
config.reinforcementShieldDays > 0
|
|
315
|
-
) {
|
|
316
|
-
const shieldCutoff = now - config.reinforcementShieldDays * MS_PER_DAY;
|
|
317
|
-
if (lastUsedAt >= shieldCutoff) return 1.0;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return config.staleDecay;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
1
|
/**
|
|
324
2
|
* Logarithmic recency decay (ACT-R inspired).
|
|
325
3
|
*
|
|
@@ -335,136 +13,3 @@ export function computeRecencyScore(createdAt: number): number {
|
|
|
335
13
|
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
|
336
14
|
return 1 / (1 + Math.log2(1 + ageDays));
|
|
337
15
|
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* LLM re-ranking: send candidate memories to Haiku for relevance scoring.
|
|
341
|
-
* Returns candidates re-sorted by LLM-assigned relevance score.
|
|
342
|
-
*/
|
|
343
|
-
export async function rerankWithLLM(
|
|
344
|
-
query: string,
|
|
345
|
-
candidates: Candidate[],
|
|
346
|
-
rerankingConfig: MemoryRerankingConfig,
|
|
347
|
-
): Promise<Candidate[]> {
|
|
348
|
-
const provider = getConfiguredProvider();
|
|
349
|
-
if (!provider) {
|
|
350
|
-
log.debug("Configured provider unavailable for LLM re-ranking, skipping");
|
|
351
|
-
return candidates;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const candidateList = candidates.map((c, i) => ({
|
|
355
|
-
index: i,
|
|
356
|
-
id: c.key,
|
|
357
|
-
text: truncate(c.text, 200),
|
|
358
|
-
}));
|
|
359
|
-
|
|
360
|
-
const response = await provider.sendMessage(
|
|
361
|
-
[
|
|
362
|
-
userMessage(
|
|
363
|
-
`Query: ${truncate(query, 200)}\n\nCandidates:\n${candidateList
|
|
364
|
-
.map((c) => `[${c.index}] ${c.text}`)
|
|
365
|
-
.join("\n")}`,
|
|
366
|
-
),
|
|
367
|
-
],
|
|
368
|
-
undefined,
|
|
369
|
-
'You are a relevance scoring assistant. Given a query and a list of memory candidates, rate each candidate\'s relevance to the query on a scale of 0-10. Return ONLY a JSON array of objects with "index" (the candidate index) and "score" (0-10 integer). No explanation.',
|
|
370
|
-
{
|
|
371
|
-
config: {
|
|
372
|
-
modelIntent: rerankingConfig.modelIntent,
|
|
373
|
-
max_tokens: 1024,
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
);
|
|
377
|
-
|
|
378
|
-
// Extract text from the response
|
|
379
|
-
const responseText = extractText(response);
|
|
380
|
-
if (!responseText) {
|
|
381
|
-
log.warn("LLM re-ranking returned no text block, skipping");
|
|
382
|
-
return candidates;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Parse the JSON array from the response
|
|
386
|
-
const jsonMatch = responseText.match(/\[[\s\S]*\]/);
|
|
387
|
-
if (!jsonMatch) {
|
|
388
|
-
log.warn("LLM re-ranking response did not contain JSON array, skipping");
|
|
389
|
-
return candidates;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
let scores: Array<{ index: number; score: number }>;
|
|
393
|
-
try {
|
|
394
|
-
scores = JSON.parse(jsonMatch[0]) as Array<{
|
|
395
|
-
index: number;
|
|
396
|
-
score: number;
|
|
397
|
-
}>;
|
|
398
|
-
} catch {
|
|
399
|
-
log.warn("Failed to parse LLM re-ranking JSON response, skipping");
|
|
400
|
-
return candidates;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Build a score map from LLM results
|
|
404
|
-
const scoreMap = new Map<number, number>();
|
|
405
|
-
for (const entry of scores) {
|
|
406
|
-
if (typeof entry.index === "number" && typeof entry.score === "number") {
|
|
407
|
-
scoreMap.set(entry.index, Math.max(0, Math.min(10, entry.score)));
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Re-sort candidates by LLM score (desc); unscored candidates keep original order after scored ones
|
|
412
|
-
const reranked = candidates.map((c, i) => ({
|
|
413
|
-
candidate: c,
|
|
414
|
-
llmScore: scoreMap.has(i) ? scoreMap.get(i)! : null,
|
|
415
|
-
originalIndex: i,
|
|
416
|
-
}));
|
|
417
|
-
|
|
418
|
-
reranked.sort((a, b) => {
|
|
419
|
-
// Scored items come before unscored items
|
|
420
|
-
if (a.llmScore != null && b.llmScore == null) return -1;
|
|
421
|
-
if (a.llmScore == null && b.llmScore != null) return 1;
|
|
422
|
-
// Both scored: sort by score descending
|
|
423
|
-
if (a.llmScore != null && b.llmScore != null) {
|
|
424
|
-
const scoreDelta = b.llmScore - a.llmScore;
|
|
425
|
-
if (scoreDelta !== 0) return scoreDelta;
|
|
426
|
-
}
|
|
427
|
-
// Both unscored or tie: preserve original RRF order
|
|
428
|
-
return a.originalIndex - b.originalIndex;
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
return reranked.map((r) => r.candidate);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
export function trimToTokenBudget(
|
|
435
|
-
candidates: Candidate[],
|
|
436
|
-
maxTokens: number,
|
|
437
|
-
format: string = "markdown",
|
|
438
|
-
): Candidate[] {
|
|
439
|
-
if (maxTokens <= 0) return [];
|
|
440
|
-
const selected: Candidate[] = [];
|
|
441
|
-
for (const candidate of candidates) {
|
|
442
|
-
const tentativeText = buildInjectedText([...selected, candidate], format);
|
|
443
|
-
const cost = estimateTextTokens(tentativeText);
|
|
444
|
-
if (cost > maxTokens) continue;
|
|
445
|
-
selected.push(candidate);
|
|
446
|
-
if (cost >= maxTokens) break;
|
|
447
|
-
}
|
|
448
|
-
return selected;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
export function markItemUsage(candidates: Candidate[]): void {
|
|
452
|
-
const itemIds = candidates
|
|
453
|
-
.filter((candidate) => candidate.type === "item")
|
|
454
|
-
.map((candidate) => candidate.id);
|
|
455
|
-
if (itemIds.length === 0) return;
|
|
456
|
-
const db = getDb();
|
|
457
|
-
const now = Date.now();
|
|
458
|
-
db.update(memoryItems)
|
|
459
|
-
.set({
|
|
460
|
-
lastUsedAt: now,
|
|
461
|
-
accessCount: sql`${memoryItems.accessCount} + 1`,
|
|
462
|
-
})
|
|
463
|
-
.where(inArray(memoryItems.id, itemIds))
|
|
464
|
-
.run();
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function truncate(text: string, max: number): string {
|
|
468
|
-
if (text.length <= max) return text;
|
|
469
|
-
return `${text.slice(0, max - 3)}...`;
|
|
470
|
-
}
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
_resetQdrantBreaker,
|
|
8
8
|
withQdrantBreaker,
|
|
9
9
|
} from "../qdrant-circuit-breaker.js";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
QdrantSearchResult,
|
|
12
|
+
QdrantSparseVector,
|
|
13
|
+
} from "../qdrant-client.js";
|
|
11
14
|
import { getQdrantClient } from "../qdrant-client.js";
|
|
12
15
|
import {
|
|
13
16
|
conversations,
|
|
@@ -31,6 +34,7 @@ export async function semanticSearch(
|
|
|
31
34
|
limit: number,
|
|
32
35
|
excludedMessageIds: string[] = [],
|
|
33
36
|
scopeIds?: string[],
|
|
37
|
+
sparseVector?: QdrantSparseVector,
|
|
34
38
|
): Promise<Candidate[]> {
|
|
35
39
|
if (limit <= 0) return [];
|
|
36
40
|
|
|
@@ -40,14 +44,33 @@ export async function semanticSearch(
|
|
|
40
44
|
// Use 3x when exclusions are active to ensure enough results survive filtering
|
|
41
45
|
const overfetchMultiplier = excludedMessageIds.length > 0 ? 3 : 2;
|
|
42
46
|
const fetchLimit = limit * overfetchMultiplier;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
|
|
48
|
+
// When a sparse vector is available, use hybrid search (dense + sparse RRF fusion)
|
|
49
|
+
// for better recall; otherwise fall back to dense-only search.
|
|
50
|
+
let results: QdrantSearchResult[];
|
|
51
|
+
let isHybrid = false;
|
|
52
|
+
if (sparseVector && sparseVector.indices.length > 0) {
|
|
53
|
+
isHybrid = true;
|
|
54
|
+
const filter = buildHybridFilter(excludedMessageIds, scopeIds);
|
|
55
|
+
results = await withQdrantBreaker(() =>
|
|
56
|
+
qdrant.hybridSearch({
|
|
57
|
+
denseVector: queryVector,
|
|
58
|
+
sparseVector,
|
|
59
|
+
filter,
|
|
60
|
+
limit: fetchLimit,
|
|
61
|
+
prefetchLimit: fetchLimit,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
} else {
|
|
65
|
+
results = await withQdrantBreaker(() =>
|
|
66
|
+
qdrant.searchWithFilter(
|
|
67
|
+
queryVector,
|
|
68
|
+
fetchLimit,
|
|
69
|
+
["item", "summary", "segment", "media"],
|
|
70
|
+
excludedMessageIds,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
51
74
|
|
|
52
75
|
const db = getDb();
|
|
53
76
|
|
|
@@ -137,7 +160,8 @@ export async function semanticSearch(
|
|
|
137
160
|
const candidates: Candidate[] = [];
|
|
138
161
|
for (const result of results) {
|
|
139
162
|
const { payload, score } = result;
|
|
140
|
-
|
|
163
|
+
// Store raw score; hybrid RRF normalization happens after filtering
|
|
164
|
+
const semantic = isHybrid ? score : mapCosineToUnit(score);
|
|
141
165
|
const createdAt = payload.created_at ?? Date.now();
|
|
142
166
|
|
|
143
167
|
if (payload.target_type === "item") {
|
|
@@ -160,7 +184,6 @@ export async function semanticSearch(
|
|
|
160
184
|
confidence: item.confidence,
|
|
161
185
|
importance: item.importance ?? 0.5,
|
|
162
186
|
createdAt: item.lastSeenAt,
|
|
163
|
-
lexical: 0,
|
|
164
187
|
semantic,
|
|
165
188
|
recency: computeRecencyScore(item.lastSeenAt),
|
|
166
189
|
finalScore: 0,
|
|
@@ -181,7 +204,6 @@ export async function semanticSearch(
|
|
|
181
204
|
confidence: 0.6,
|
|
182
205
|
importance: 0.6,
|
|
183
206
|
createdAt: payload.last_seen_at ?? createdAt,
|
|
184
|
-
lexical: 0,
|
|
185
207
|
semantic,
|
|
186
208
|
recency: computeRecencyScore(payload.last_seen_at ?? createdAt),
|
|
187
209
|
finalScore: 0,
|
|
@@ -214,7 +236,6 @@ export async function semanticSearch(
|
|
|
214
236
|
confidence: 0.7,
|
|
215
237
|
importance: 0.6,
|
|
216
238
|
createdAt,
|
|
217
|
-
lexical: 0,
|
|
218
239
|
semantic,
|
|
219
240
|
recency: computeRecencyScore(createdAt),
|
|
220
241
|
finalScore: 0,
|
|
@@ -234,7 +255,6 @@ export async function semanticSearch(
|
|
|
234
255
|
confidence: 0.55,
|
|
235
256
|
importance: 0.5,
|
|
236
257
|
createdAt,
|
|
237
|
-
lexical: 0,
|
|
238
258
|
semantic,
|
|
239
259
|
recency: computeRecencyScore(createdAt),
|
|
240
260
|
finalScore: 0,
|
|
@@ -242,9 +262,75 @@ export async function semanticSearch(
|
|
|
242
262
|
}
|
|
243
263
|
if (candidates.length >= limit) break;
|
|
244
264
|
}
|
|
265
|
+
|
|
266
|
+
// For hybrid search (RRF fusion), normalize semantic scores relative to
|
|
267
|
+
// the surviving candidates' maximum — not the raw Qdrant batch. Filtered-out
|
|
268
|
+
// high-scoring hits must not anchor normalization and deflate survivors.
|
|
269
|
+
if (isHybrid && candidates.length > 0) {
|
|
270
|
+
const maxScore = Math.max(...candidates.map((c) => c.semantic));
|
|
271
|
+
if (maxScore > 0) {
|
|
272
|
+
for (const c of candidates) {
|
|
273
|
+
c.semantic = c.semantic / maxScore;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
245
278
|
return candidates;
|
|
246
279
|
}
|
|
247
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Build a Qdrant filter for hybrid search. Mirrors the logic in
|
|
283
|
+
* `searchWithFilter` but as a standalone object for the query API.
|
|
284
|
+
*
|
|
285
|
+
* Scope filtering: items and media store `memory_scope_id` on the Qdrant
|
|
286
|
+
* point payload, so we can filter at the Qdrant level. Segments and
|
|
287
|
+
* summaries rely on post-query DB filtering (same as dense-only search).
|
|
288
|
+
*/
|
|
289
|
+
function buildHybridFilter(
|
|
290
|
+
excludeMessageIds: string[],
|
|
291
|
+
_scopeIds?: string[],
|
|
292
|
+
): Record<string, unknown> {
|
|
293
|
+
const mustConditions: Array<Record<string, unknown>> = [
|
|
294
|
+
{
|
|
295
|
+
key: "target_type",
|
|
296
|
+
match: { any: ["item", "summary", "segment", "media"] },
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
if (excludeMessageIds.length > 0) {
|
|
301
|
+
// Only require status=active for items; segments and summaries don't have a status field
|
|
302
|
+
mustConditions.push({
|
|
303
|
+
should: [
|
|
304
|
+
{
|
|
305
|
+
must: [
|
|
306
|
+
{ key: "target_type", match: { value: "item" } },
|
|
307
|
+
{ key: "status", match: { value: "active" } },
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: "target_type",
|
|
312
|
+
match: { any: ["segment", "summary", "media"] },
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const mustNotConditions: Array<Record<string, unknown>> = [
|
|
319
|
+
{ key: "_meta", match: { value: true } },
|
|
320
|
+
];
|
|
321
|
+
if (excludeMessageIds.length > 0) {
|
|
322
|
+
mustNotConditions.push({
|
|
323
|
+
key: "message_id",
|
|
324
|
+
match: { any: excludeMessageIds },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
must: mustConditions,
|
|
330
|
+
must_not: mustNotConditions,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
248
334
|
export function mapCosineToUnit(value: number): number {
|
|
249
335
|
return Math.max(0, Math.min(1, (value + 1) / 2));
|
|
250
336
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TieredCandidate } from "./tier-classifier.js";
|
|
2
|
+
import type { StalenessLevel } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const BASE_LIFETIME_MS: Record<string, number> = {
|
|
5
|
+
identity: 180 * 86_400_000, // 6 months
|
|
6
|
+
preference: 90 * 86_400_000, // 3 months
|
|
7
|
+
constraint: 30 * 86_400_000, // 1 month
|
|
8
|
+
project: 14 * 86_400_000, // 2 weeks
|
|
9
|
+
decision: 14 * 86_400_000, // 2 weeks
|
|
10
|
+
event: 3 * 86_400_000, // 3 days
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LIFETIME_MS = 30 * 86_400_000;
|
|
14
|
+
|
|
15
|
+
export function computeStaleness(
|
|
16
|
+
item: {
|
|
17
|
+
kind: string;
|
|
18
|
+
firstSeenAt: number;
|
|
19
|
+
sourceConversationCount: number;
|
|
20
|
+
},
|
|
21
|
+
now: number,
|
|
22
|
+
): { level: StalenessLevel; ratio: number } {
|
|
23
|
+
const baseLifetime = BASE_LIFETIME_MS[item.kind] ?? DEFAULT_LIFETIME_MS;
|
|
24
|
+
const reinforcement = Math.max(1, 1 + 0.3 * (item.sourceConversationCount - 1));
|
|
25
|
+
const effectiveLifetime = baseLifetime * reinforcement;
|
|
26
|
+
const age = now - item.firstSeenAt;
|
|
27
|
+
const ratio = age / effectiveLifetime;
|
|
28
|
+
|
|
29
|
+
if (ratio < 0.5) return { level: "fresh", ratio };
|
|
30
|
+
if (ratio <= 1) return { level: "aging", ratio };
|
|
31
|
+
if (ratio <= 2) return { level: "stale", ratio };
|
|
32
|
+
return { level: "very_stale", ratio };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Demote very_stale tier-1 candidates to tier 2.
|
|
37
|
+
*/
|
|
38
|
+
export function applyStaleDemotion(
|
|
39
|
+
candidates: TieredCandidate[],
|
|
40
|
+
): TieredCandidate[] {
|
|
41
|
+
return candidates.map((c) => {
|
|
42
|
+
if (c.tier === 1 && c.staleness === "very_stale") {
|
|
43
|
+
return { ...c, tier: 2 as const };
|
|
44
|
+
}
|
|
45
|
+
return c;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Candidate } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type Tier = 1 | 2;
|
|
4
|
+
|
|
5
|
+
export interface TieredCandidate extends Candidate {
|
|
6
|
+
tier: Tier;
|
|
7
|
+
/** Human-readable label for the source conversation/summary (e.g. conversation title). */
|
|
8
|
+
sourceLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function classifyTier(score: number): Tier | null {
|
|
12
|
+
if (score > 0.8) return 1;
|
|
13
|
+
if (score > 0.6) return 2;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyTiers(candidates: Candidate[]): TieredCandidate[] {
|
|
18
|
+
return candidates
|
|
19
|
+
.map((c) => ({ ...c, tier: classifyTier(c.finalScore) }))
|
|
20
|
+
.filter((c): c is TieredCandidate => c.tier != null);
|
|
21
|
+
}
|