@illuma-ai/agents 1.1.25 → 1.3.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/dist/cjs/agents/AgentContext.cjs +20 -3
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/spawnPath.cjs +104 -0
- package/dist/cjs/common/spawnPath.cjs.map +1 -0
- package/dist/cjs/graphs/Graph.cjs +87 -31
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/HandoffRegistry.cjs +143 -0
- package/dist/cjs/graphs/HandoffRegistry.cjs.map +1 -0
- package/dist/cjs/graphs/MultiAgentGraph.cjs +587 -184
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/graphs/phases/flushLoop.cjs +214 -0
- package/dist/cjs/graphs/phases/flushLoop.cjs.map +1 -0
- package/dist/cjs/graphs/phases/memoryFlushPhase.cjs +102 -0
- package/dist/cjs/graphs/phases/memoryFlushPhase.cjs.map +1 -0
- package/dist/cjs/llm/bedrock/index.cjs +4 -3
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +115 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/memory/citations.cjs +69 -0
- package/dist/cjs/memory/citations.cjs.map +1 -0
- package/dist/cjs/memory/compositeBackend.cjs +60 -0
- package/dist/cjs/memory/compositeBackend.cjs.map +1 -0
- package/dist/cjs/memory/constants.cjs +232 -0
- package/dist/cjs/memory/constants.cjs.map +1 -0
- package/dist/cjs/memory/embeddings.cjs +151 -0
- package/dist/cjs/memory/embeddings.cjs.map +1 -0
- package/dist/cjs/memory/factory.cjs +95 -0
- package/dist/cjs/memory/factory.cjs.map +1 -0
- package/dist/cjs/memory/migrate.cjs +81 -0
- package/dist/cjs/memory/migrate.cjs.map +1 -0
- package/dist/cjs/memory/mmr.cjs +138 -0
- package/dist/cjs/memory/mmr.cjs.map +1 -0
- package/dist/cjs/memory/paths.cjs +217 -0
- package/dist/cjs/memory/paths.cjs.map +1 -0
- package/dist/cjs/memory/pgvectorStore.cjs +225 -0
- package/dist/cjs/memory/pgvectorStore.cjs.map +1 -0
- package/dist/cjs/memory/recallTracking.cjs +98 -0
- package/dist/cjs/memory/recallTracking.cjs.map +1 -0
- package/dist/cjs/memory/schema.sql +51 -0
- package/dist/cjs/memory/temporalDecay.cjs +118 -0
- package/dist/cjs/memory/temporalDecay.cjs.map +1 -0
- package/dist/cjs/nodes/ApprovalGateNode.cjs +1 -1
- package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -1
- package/dist/cjs/prompts/memoryFlushPrompt.cjs +49 -0
- package/dist/cjs/prompts/memoryFlushPrompt.cjs.map +1 -0
- package/dist/cjs/run.cjs +16 -3
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +4 -4
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/AskUser.cjs +6 -1
- package/dist/cjs/tools/AskUser.cjs.map +1 -1
- package/dist/cjs/tools/BrowserTools.cjs +1 -1
- package/dist/cjs/tools/BrowserTools.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +127 -10
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/approval/constants.cjs +2 -2
- package/dist/cjs/tools/approval/constants.cjs.map +1 -1
- package/dist/cjs/tools/memory/index.cjs +58 -0
- package/dist/cjs/tools/memory/index.cjs.map +1 -0
- package/dist/cjs/tools/memory/memoryAppendTool.cjs +69 -0
- package/dist/cjs/tools/memory/memoryAppendTool.cjs.map +1 -0
- package/dist/cjs/tools/memory/memoryGetTool.cjs +49 -0
- package/dist/cjs/tools/memory/memoryGetTool.cjs.map +1 -0
- package/dist/cjs/tools/memory/memorySearchTool.cjs +65 -0
- package/dist/cjs/tools/memory/memorySearchTool.cjs.map +1 -0
- package/dist/cjs/tools/memory/shared.cjs +106 -0
- package/dist/cjs/tools/memory/shared.cjs.map +1 -0
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/cjs/utils/childAgentContext.cjs +242 -0
- package/dist/cjs/utils/childAgentContext.cjs.map +1 -0
- package/dist/cjs/utils/events.cjs +36 -4
- package/dist/cjs/utils/events.cjs.map +1 -1
- package/dist/cjs/utils/finishReasons.cjs +44 -0
- package/dist/cjs/utils/finishReasons.cjs.map +1 -0
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/logging.cjs +34 -0
- package/dist/cjs/utils/logging.cjs.map +1 -0
- package/dist/cjs/utils/toolCallNormalization.cjs +250 -0
- package/dist/cjs/utils/toolCallNormalization.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +20 -3
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/spawnPath.mjs +95 -0
- package/dist/esm/common/spawnPath.mjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +87 -31
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/HandoffRegistry.mjs +141 -0
- package/dist/esm/graphs/HandoffRegistry.mjs.map +1 -0
- package/dist/esm/graphs/MultiAgentGraph.mjs +587 -184
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/graphs/phases/flushLoop.mjs +209 -0
- package/dist/esm/graphs/phases/flushLoop.mjs.map +1 -0
- package/dist/esm/graphs/phases/memoryFlushPhase.mjs +99 -0
- package/dist/esm/graphs/phases/memoryFlushPhase.mjs.map +1 -0
- package/dist/esm/llm/bedrock/index.mjs +4 -3
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/main.mjs +21 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/memory/citations.mjs +64 -0
- package/dist/esm/memory/citations.mjs.map +1 -0
- package/dist/esm/memory/compositeBackend.mjs +58 -0
- package/dist/esm/memory/compositeBackend.mjs.map +1 -0
- package/dist/esm/memory/constants.mjs +198 -0
- package/dist/esm/memory/constants.mjs.map +1 -0
- package/dist/esm/memory/embeddings.mjs +148 -0
- package/dist/esm/memory/embeddings.mjs.map +1 -0
- package/dist/esm/memory/factory.mjs +93 -0
- package/dist/esm/memory/factory.mjs.map +1 -0
- package/dist/esm/memory/migrate.mjs +78 -0
- package/dist/esm/memory/migrate.mjs.map +1 -0
- package/dist/esm/memory/mmr.mjs +130 -0
- package/dist/esm/memory/mmr.mjs.map +1 -0
- package/dist/esm/memory/paths.mjs +207 -0
- package/dist/esm/memory/paths.mjs.map +1 -0
- package/dist/esm/memory/pgvectorStore.mjs +223 -0
- package/dist/esm/memory/pgvectorStore.mjs.map +1 -0
- package/dist/esm/memory/recallTracking.mjs +94 -0
- package/dist/esm/memory/recallTracking.mjs.map +1 -0
- package/dist/esm/memory/schema.sql +51 -0
- package/dist/esm/memory/temporalDecay.mjs +110 -0
- package/dist/esm/memory/temporalDecay.mjs.map +1 -0
- package/dist/esm/nodes/ApprovalGateNode.mjs +1 -1
- package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -1
- package/dist/esm/prompts/memoryFlushPrompt.mjs +44 -0
- package/dist/esm/prompts/memoryFlushPrompt.mjs.map +1 -0
- package/dist/esm/run.mjs +16 -3
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +4 -4
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/AskUser.mjs +6 -1
- package/dist/esm/tools/AskUser.mjs.map +1 -1
- package/dist/esm/tools/BrowserTools.mjs +1 -1
- package/dist/esm/tools/BrowserTools.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +128 -11
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/approval/constants.mjs +2 -2
- package/dist/esm/tools/approval/constants.mjs.map +1 -1
- package/dist/esm/tools/memory/index.mjs +46 -0
- package/dist/esm/tools/memory/index.mjs.map +1 -0
- package/dist/esm/tools/memory/memoryAppendTool.mjs +67 -0
- package/dist/esm/tools/memory/memoryAppendTool.mjs.map +1 -0
- package/dist/esm/tools/memory/memoryGetTool.mjs +47 -0
- package/dist/esm/tools/memory/memoryGetTool.mjs.map +1 -0
- package/dist/esm/tools/memory/memorySearchTool.mjs +63 -0
- package/dist/esm/tools/memory/memorySearchTool.mjs.map +1 -0
- package/dist/esm/tools/memory/shared.mjs +98 -0
- package/dist/esm/tools/memory/shared.mjs.map +1 -0
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/esm/utils/childAgentContext.mjs +237 -0
- package/dist/esm/utils/childAgentContext.mjs.map +1 -0
- package/dist/esm/utils/events.mjs +36 -5
- package/dist/esm/utils/events.mjs.map +1 -1
- package/dist/esm/utils/finishReasons.mjs +41 -0
- package/dist/esm/utils/finishReasons.mjs.map +1 -0
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/logging.mjs +31 -0
- package/dist/esm/utils/logging.mjs.map +1 -0
- package/dist/esm/utils/toolCallNormalization.mjs +247 -0
- package/dist/esm/utils/toolCallNormalization.mjs.map +1 -0
- package/dist/types/common/index.d.ts +1 -0
- package/dist/types/common/spawnPath.d.ts +59 -0
- package/dist/types/graphs/HandoffRegistry.d.ts +97 -0
- package/dist/types/graphs/MultiAgentGraph.d.ts +58 -18
- package/dist/types/graphs/index.d.ts +1 -0
- package/dist/types/graphs/phases/flushLoop.d.ts +106 -0
- package/dist/types/graphs/phases/memoryFlushPhase.d.ts +100 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/memory/__tests__/mockBackend.d.ts +40 -0
- package/dist/types/memory/citations.d.ts +39 -0
- package/dist/types/memory/compositeBackend.d.ts +30 -0
- package/dist/types/memory/constants.d.ts +121 -0
- package/dist/types/memory/embeddings.d.ts +15 -0
- package/dist/types/memory/factory.d.ts +23 -0
- package/dist/types/memory/index.d.ts +21 -0
- package/dist/types/memory/migrate.d.ts +14 -0
- package/dist/types/memory/mmr.d.ts +50 -0
- package/dist/types/memory/paths.d.ts +107 -0
- package/dist/types/memory/pgvectorStore.d.ts +56 -0
- package/dist/types/memory/recallTracking.d.ts +30 -0
- package/dist/types/memory/temporalDecay.d.ts +53 -0
- package/dist/types/memory/types.d.ts +182 -0
- package/dist/types/prompts/memoryFlushPrompt.d.ts +54 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/AskUser.d.ts +1 -1
- package/dist/types/tools/BrowserTools.d.ts +2 -2
- package/dist/types/tools/approval/constants.d.ts +2 -2
- package/dist/types/tools/memory/index.d.ts +39 -0
- package/dist/types/tools/memory/memoryAppendTool.d.ts +27 -0
- package/dist/types/tools/memory/memoryGetTool.d.ts +22 -0
- package/dist/types/tools/memory/memorySearchTool.d.ts +22 -0
- package/dist/types/tools/memory/shared.d.ts +106 -0
- package/dist/types/types/graph.d.ts +16 -3
- package/dist/types/utils/childAgentContext.d.ts +99 -0
- package/dist/types/utils/events.d.ts +21 -0
- package/dist/types/utils/finishReasons.d.ts +32 -0
- package/dist/types/utils/logging.d.ts +2 -0
- package/dist/types/utils/toolCallNormalization.d.ts +44 -0
- package/package.json +6 -4
- package/src/agents/AgentContext.ts +26 -3
- package/src/common/__tests__/enum.test.ts +4 -2
- package/src/common/__tests__/spawnPath.test.ts +110 -0
- package/src/common/index.ts +1 -0
- package/src/common/spawnPath.ts +101 -0
- package/src/graphs/Graph.ts +94 -43
- package/src/graphs/HandoffRegistry.ts +199 -0
- package/src/graphs/MultiAgentGraph.ts +694 -226
- package/src/graphs/__tests__/HandoffRegistry.test.ts +410 -0
- package/src/graphs/__tests__/multi-agent-delegate.test.ts +61 -16
- package/src/graphs/__tests__/multi-agent-edges.test.ts +4 -2
- package/src/graphs/__tests__/multi-agent-nested-subgraph.test.ts +221 -0
- package/src/graphs/__tests__/structured-output.integration.test.ts +212 -118
- package/src/graphs/contextManagement.e2e.test.ts +1 -1
- package/src/graphs/index.ts +1 -0
- package/src/graphs/phases/__tests__/flushLoop.test.ts +264 -0
- package/src/graphs/phases/__tests__/memoryFlushPhase.test.ts +37 -0
- package/src/graphs/phases/__tests__/runMemoryFlush.test.ts +150 -0
- package/src/graphs/phases/flushLoop.ts +303 -0
- package/src/graphs/phases/memoryFlushPhase.ts +209 -0
- package/src/index.ts +30 -1
- package/src/llm/bedrock/index.ts +4 -5
- package/src/memory/__tests__/citations.test.ts +61 -0
- package/src/memory/__tests__/compositeBackend.test.ts +79 -0
- package/src/memory/__tests__/isolation.test.ts +206 -0
- package/src/memory/__tests__/mmr.test.ts +148 -0
- package/src/memory/__tests__/mockBackend.ts +161 -0
- package/src/memory/__tests__/paths.test.ts +168 -0
- package/src/memory/__tests__/recallTracking.test.ts +96 -0
- package/src/memory/__tests__/temporalDecay.test.ts +151 -0
- package/src/memory/citations.ts +80 -0
- package/src/memory/compositeBackend.ts +99 -0
- package/src/memory/constants.ts +229 -0
- package/src/memory/embeddings.ts +188 -0
- package/src/memory/factory.ts +111 -0
- package/src/memory/index.ts +46 -0
- package/src/memory/migrate.ts +116 -0
- package/src/memory/mmr.ts +161 -0
- package/src/memory/paths.ts +258 -0
- package/src/memory/pgvectorStore.ts +324 -0
- package/src/memory/recallTracking.ts +127 -0
- package/src/memory/schema.sql +51 -0
- package/src/memory/temporalDecay.ts +134 -0
- package/src/memory/types.ts +185 -0
- package/src/nodes/ApprovalGateNode.ts +4 -10
- package/src/nodes/__tests__/ApprovalGateNode.test.ts +11 -20
- package/src/prompts/memoryFlushPrompt.ts +78 -0
- package/src/run.ts +17 -6
- package/src/scripts/test-bedrock-handoff-autonomous.ts +56 -20
- package/src/specs/agent-handoffs-bedrock.integration.test.ts +8 -5
- package/src/specs/agent-handoffs.test.ts +8 -2
- package/src/stream.ts +4 -6
- package/src/tools/AskUser.ts +7 -2
- package/src/tools/BrowserTools.ts +3 -5
- package/src/tools/ToolNode.ts +150 -13
- package/src/tools/__tests__/ToolApproval.test.ts +22 -9
- package/src/tools/approval/__tests__/constants.test.ts +4 -4
- package/src/tools/approval/constants.ts +2 -2
- package/src/tools/memory/__tests__/memoryTools.test.ts +205 -0
- package/src/tools/memory/index.ts +96 -0
- package/src/tools/memory/memoryAppendTool.ts +101 -0
- package/src/tools/memory/memoryGetTool.ts +53 -0
- package/src/tools/memory/memorySearchTool.ts +80 -0
- package/src/tools/memory/shared.ts +169 -0
- package/src/tools/search/search.test.ts +6 -1
- package/src/types/graph.ts +16 -3
- package/src/utils/__tests__/childAgentContext.test.ts +217 -0
- package/src/utils/__tests__/finishReasons.test.ts +55 -0
- package/src/utils/__tests__/toolCallNormalization.test.ts +181 -0
- package/src/utils/childAgentContext.ts +259 -0
- package/src/utils/events.ts +37 -4
- package/src/utils/finishReasons.ts +40 -0
- package/src/utils/llm.ts +0 -1
- package/src/utils/logging.ts +45 -8
- package/src/utils/toolCallNormalization.ts +271 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres + pgvector implementation of {@link MemoryBackend}.
|
|
3
|
+
*
|
|
4
|
+
* ## Scoping model (two-tier, layered)
|
|
5
|
+
*
|
|
6
|
+
* Every read query applies the layered filter
|
|
7
|
+
*
|
|
8
|
+
* WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)
|
|
9
|
+
*
|
|
10
|
+
* so the caller sees:
|
|
11
|
+
* - agent-tier rows (`user_id IS NULL`) — shared operational knowledge,
|
|
12
|
+
* visible to every user of the agent
|
|
13
|
+
* - their own user-tier rows (`user_id = <caller>`) — private per-user
|
|
14
|
+
* personalization
|
|
15
|
+
*
|
|
16
|
+
* Another user's user-tier rows are invisible — the privacy boundary is
|
|
17
|
+
* enforced in SQL, not just in the UI or route layer.
|
|
18
|
+
*
|
|
19
|
+
* ## Writes
|
|
20
|
+
*
|
|
21
|
+
* `append()` routes to a row based on the path's tier, resolved via
|
|
22
|
+
* {@link assertWritablePath}:
|
|
23
|
+
*
|
|
24
|
+
* - `memory/agent/*` → stored with `user_id = NULL` regardless of
|
|
25
|
+
* what scope the caller passed. Agent-tier content is inherently
|
|
26
|
+
* shared; scoping it per-user would defeat the point.
|
|
27
|
+
* - `memory/user/*` → stored with `user_id = scope.userId`. A missing
|
|
28
|
+
* `scope.userId` throws — user-tier paths cannot be written from
|
|
29
|
+
* isolated/autonomous contexts.
|
|
30
|
+
*
|
|
31
|
+
* UPSERT key is `(agent_id, user_id, path)` with `NULLS NOT DISTINCT`,
|
|
32
|
+
* so each user gets their own `user/preferences.md` row and there is
|
|
33
|
+
* exactly one `agent/playbook.md` row shared across the whole user base.
|
|
34
|
+
* Content accumulates via `\n\n` concatenation on conflict, with the
|
|
35
|
+
* embedding regenerated over the merged content so search stays
|
|
36
|
+
* consistent.
|
|
37
|
+
*/
|
|
38
|
+
import type { Pool } from 'pg';
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_MAX_SEARCH_RESULTS,
|
|
41
|
+
DEFAULT_MEMORY_TABLE,
|
|
42
|
+
DEFAULT_MIN_SCORE,
|
|
43
|
+
HYBRID_TEXT_WEIGHT,
|
|
44
|
+
HYBRID_VECTOR_WEIGHT,
|
|
45
|
+
} from './constants';
|
|
46
|
+
import { getMemoryEmbedder, type EmbeddingProvider } from './embeddings';
|
|
47
|
+
import { applyMMRToMemoryHits } from './mmr';
|
|
48
|
+
import { applyTemporalDecayToHits } from './temporalDecay';
|
|
49
|
+
import { decorateCitations, shouldIncludeCitations } from './citations';
|
|
50
|
+
import { assertWritablePath, getTierForPath } from './paths';
|
|
51
|
+
import type {
|
|
52
|
+
MemoryAppendInput,
|
|
53
|
+
MemoryBackend,
|
|
54
|
+
MemoryEntry,
|
|
55
|
+
MemoryGetOptions,
|
|
56
|
+
MemoryHealth,
|
|
57
|
+
MemoryReadResult,
|
|
58
|
+
MemoryScope,
|
|
59
|
+
MemorySearchOptions,
|
|
60
|
+
} from './types';
|
|
61
|
+
|
|
62
|
+
export interface PgvectorStoreOptions {
|
|
63
|
+
pool: Pool;
|
|
64
|
+
table?: string;
|
|
65
|
+
embedder?: EmbeddingProvider;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertScope(scope: MemoryScope): void {
|
|
69
|
+
if (!scope || !scope.agentId) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'MemoryScope { agentId } is required — agentId must be non-empty'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** pgvector literal format: "[0.1,0.2,...]". */
|
|
77
|
+
function toVectorLiteral(vec: number[]): string {
|
|
78
|
+
return `[${vec.join(',')}]`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize caller userId for the layered read filter.
|
|
83
|
+
*
|
|
84
|
+
* The SQL filter is `(user_id IS NULL OR user_id = $2)`, so an empty
|
|
85
|
+
* string from the caller must not match rows whose user_id was set.
|
|
86
|
+
* We coerce empty/null/undefined to `null`, and pg treats `$2 = null`
|
|
87
|
+
* as `false` — which is exactly what we want for isolated callers:
|
|
88
|
+
* they see only agent-tier rows and nothing in the user tier.
|
|
89
|
+
*/
|
|
90
|
+
function normalizeCallerId(scope: MemoryScope): string | null {
|
|
91
|
+
const raw = scope.userId;
|
|
92
|
+
if (raw == null || raw === '') return null;
|
|
93
|
+
return String(raw);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class PgvectorMemoryStore implements MemoryBackend {
|
|
97
|
+
readonly kind = 'vector' as const;
|
|
98
|
+
private pool: Pool;
|
|
99
|
+
private table: string;
|
|
100
|
+
private embedder: EmbeddingProvider;
|
|
101
|
+
|
|
102
|
+
constructor(opts: PgvectorStoreOptions) {
|
|
103
|
+
this.pool = opts.pool;
|
|
104
|
+
this.table = opts.table ?? DEFAULT_MEMORY_TABLE;
|
|
105
|
+
this.embedder = opts.embedder ?? getMemoryEmbedder();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async search(
|
|
109
|
+
scope: MemoryScope,
|
|
110
|
+
query: string,
|
|
111
|
+
opts: MemorySearchOptions = {}
|
|
112
|
+
): Promise<MemoryEntry[]> {
|
|
113
|
+
assertScope(scope);
|
|
114
|
+
const trimmed = query.trim();
|
|
115
|
+
if (!trimmed) return [];
|
|
116
|
+
|
|
117
|
+
const maxResults = Math.max(
|
|
118
|
+
1,
|
|
119
|
+
opts.maxResults ?? DEFAULT_MAX_SEARCH_RESULTS
|
|
120
|
+
);
|
|
121
|
+
const minScore = opts.minScore ?? DEFAULT_MIN_SCORE;
|
|
122
|
+
|
|
123
|
+
const vector = await this.embedder.embed(trimmed);
|
|
124
|
+
const vectorLiteral = toVectorLiteral(vector);
|
|
125
|
+
const callerId = normalizeCallerId(scope);
|
|
126
|
+
|
|
127
|
+
// [memory-layered-search] debug: layered scope filter
|
|
128
|
+
// agent-tier rows (user_id IS NULL) + this caller's user-tier rows.
|
|
129
|
+
// Another user's rows are invisible at the SQL layer.
|
|
130
|
+
const sql = `
|
|
131
|
+
WITH scored AS (
|
|
132
|
+
SELECT
|
|
133
|
+
id,
|
|
134
|
+
path,
|
|
135
|
+
content,
|
|
136
|
+
created_at,
|
|
137
|
+
user_id,
|
|
138
|
+
(1 - (embedding <=> $1::vector)) AS vector_score,
|
|
139
|
+
ts_rank(tsv, plainto_tsquery('english', $2)) AS text_score
|
|
140
|
+
FROM ${this.table}
|
|
141
|
+
WHERE agent_id = $3
|
|
142
|
+
AND (user_id IS NULL OR user_id = $4)
|
|
143
|
+
)
|
|
144
|
+
SELECT
|
|
145
|
+
id,
|
|
146
|
+
path,
|
|
147
|
+
content,
|
|
148
|
+
created_at,
|
|
149
|
+
user_id,
|
|
150
|
+
(${HYBRID_VECTOR_WEIGHT} * vector_score + ${HYBRID_TEXT_WEIGHT} * text_score) AS score
|
|
151
|
+
FROM scored
|
|
152
|
+
WHERE (${HYBRID_VECTOR_WEIGHT} * vector_score + ${HYBRID_TEXT_WEIGHT} * text_score) >= $5
|
|
153
|
+
ORDER BY score DESC
|
|
154
|
+
LIMIT $6
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const { rows } = await this.pool.query(sql, [
|
|
158
|
+
vectorLiteral,
|
|
159
|
+
trimmed,
|
|
160
|
+
scope.agentId,
|
|
161
|
+
callerId,
|
|
162
|
+
minScore,
|
|
163
|
+
maxResults,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
let hits: MemoryEntry[] = rows.map(
|
|
167
|
+
(row: {
|
|
168
|
+
id: string | number;
|
|
169
|
+
path: string;
|
|
170
|
+
content: string;
|
|
171
|
+
created_at: Date;
|
|
172
|
+
user_id: string | null;
|
|
173
|
+
score: string | number;
|
|
174
|
+
}): MemoryEntry => ({
|
|
175
|
+
id: String(row.id),
|
|
176
|
+
path: row.path,
|
|
177
|
+
content: row.content,
|
|
178
|
+
createdAt: new Date(row.created_at),
|
|
179
|
+
score: Number(row.score),
|
|
180
|
+
source: 'vector',
|
|
181
|
+
tier: getTierForPath(row.path),
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Phase 2: temporal decay (before MMR so diversity sees post-decay ranks)
|
|
186
|
+
if (opts.temporalDecay?.enabled) {
|
|
187
|
+
hits = applyTemporalDecayToHits(hits, opts.temporalDecay);
|
|
188
|
+
hits.sort((a, b) => b.score - a.score);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Phase 2: MMR reranking
|
|
192
|
+
if (opts.mmr?.enabled) {
|
|
193
|
+
hits = applyMMRToMemoryHits(hits, opts.mmr);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Phase 2: citations (decorate last — mutates content with Source: trailer)
|
|
197
|
+
const citationsMode = opts.citations ?? 'auto';
|
|
198
|
+
if (shouldIncludeCitations(citationsMode)) {
|
|
199
|
+
hits = decorateCitations(hits, true);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return hits;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async get(
|
|
206
|
+
scope: MemoryScope,
|
|
207
|
+
opts: MemoryGetOptions
|
|
208
|
+
): Promise<MemoryReadResult | null> {
|
|
209
|
+
assertScope(scope);
|
|
210
|
+
if (!opts.path) return null;
|
|
211
|
+
|
|
212
|
+
const callerId = normalizeCallerId(scope);
|
|
213
|
+
const tier = getTierForPath(opts.path);
|
|
214
|
+
|
|
215
|
+
// Agent-tier rows always live under user_id=NULL. User-tier rows
|
|
216
|
+
// always carry the caller's id. Querying with a precise predicate
|
|
217
|
+
// is faster than leaving it open AND guarantees a user cannot
|
|
218
|
+
// read another user's row even if they know the path by heart.
|
|
219
|
+
const userClause = tier === 'agent' ? 'user_id IS NULL' : 'user_id = $3';
|
|
220
|
+
|
|
221
|
+
const params: unknown[] = [scope.agentId, opts.path];
|
|
222
|
+
if (tier === 'user') params.push(callerId);
|
|
223
|
+
|
|
224
|
+
const sql = `
|
|
225
|
+
SELECT content
|
|
226
|
+
FROM ${this.table}
|
|
227
|
+
WHERE agent_id = $1 AND path = $2 AND ${userClause}
|
|
228
|
+
ORDER BY updated_at DESC
|
|
229
|
+
LIMIT 1
|
|
230
|
+
`;
|
|
231
|
+
const { rows } = await this.pool.query(sql, params);
|
|
232
|
+
if (rows.length === 0) return null;
|
|
233
|
+
|
|
234
|
+
const text = String(rows[0].content ?? '');
|
|
235
|
+
if (opts.from == null && opts.lines == null) {
|
|
236
|
+
return { path: opts.path, text };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const allLines = text.split('\n');
|
|
240
|
+
const fromIdx = Math.max(0, (opts.from ?? 1) - 1);
|
|
241
|
+
const count = Math.max(0, opts.lines ?? allLines.length - fromIdx);
|
|
242
|
+
const slice = allLines.slice(fromIdx, fromIdx + count).join('\n');
|
|
243
|
+
return { path: opts.path, text: slice };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async append(scope: MemoryScope, input: MemoryAppendInput): Promise<void> {
|
|
247
|
+
assertScope(scope);
|
|
248
|
+
// Whitelist + tier + scope-compatibility check in one call. Throws
|
|
249
|
+
// with an actionable message for each failure mode.
|
|
250
|
+
const descriptor = assertWritablePath(input.path, scope);
|
|
251
|
+
const content = input.content.trim();
|
|
252
|
+
if (!content) {
|
|
253
|
+
throw new Error('memory_append content must be non-empty');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Tier determines the row's user_id:
|
|
257
|
+
// agent tier → NULL (shared across all users)
|
|
258
|
+
// user tier → the caller's id (assertWritablePath guarantees non-empty)
|
|
259
|
+
const rowUserId = descriptor.tier === 'agent' ? null : String(scope.userId);
|
|
260
|
+
const provenance = scope.userId != null ? String(scope.userId) : null;
|
|
261
|
+
|
|
262
|
+
// Read the existing row (if any) for THIS tier so we can embed the
|
|
263
|
+
// merged content. Agent-tier merges regardless of caller; user-tier
|
|
264
|
+
// merges only within the caller's own row.
|
|
265
|
+
const lookupSql = `
|
|
266
|
+
SELECT content FROM ${this.table}
|
|
267
|
+
WHERE agent_id = $1 AND path = $2 AND ${rowUserId === null ? 'user_id IS NULL' : 'user_id = $3'}
|
|
268
|
+
LIMIT 1
|
|
269
|
+
`;
|
|
270
|
+
const lookupParams: unknown[] =
|
|
271
|
+
rowUserId === null
|
|
272
|
+
? [scope.agentId, input.path]
|
|
273
|
+
: [scope.agentId, input.path, rowUserId];
|
|
274
|
+
const existing = await this.pool.query(lookupSql, lookupParams);
|
|
275
|
+
const priorContent: string =
|
|
276
|
+
existing.rows.length > 0 ? String(existing.rows[0].content ?? '') : '';
|
|
277
|
+
const mergedContent = priorContent
|
|
278
|
+
? `${priorContent.replace(/\s+$/, '')}\n\n${content}`
|
|
279
|
+
: content;
|
|
280
|
+
|
|
281
|
+
const vector = await this.embedder.embed(mergedContent);
|
|
282
|
+
const vectorLiteral = toVectorLiteral(vector);
|
|
283
|
+
|
|
284
|
+
// UPSERT on (agent_id, user_id, path) with NULLS NOT DISTINCT so
|
|
285
|
+
// two NULL user_ids collide on the same agent+path (exactly one
|
|
286
|
+
// agent-tier row) while per-user rows on the same path coexist.
|
|
287
|
+
//
|
|
288
|
+
// Provenance note: `last_user_id` always records WHO wrote the
|
|
289
|
+
// latest append, even for agent-tier rows where the row's own
|
|
290
|
+
// `user_id` stays NULL. That gives the admin UI an audit trail
|
|
291
|
+
// ("agent-tier row last updated by Alice") without changing the
|
|
292
|
+
// scoping semantics.
|
|
293
|
+
const upsertSql = `
|
|
294
|
+
INSERT INTO ${this.table} (agent_id, user_id, path, content, embedding, last_user_id, updated_at)
|
|
295
|
+
VALUES ($1, $2, $3, $4, $5::vector, $6, NOW())
|
|
296
|
+
ON CONFLICT (agent_id, user_id, path) DO UPDATE
|
|
297
|
+
SET content = EXCLUDED.content,
|
|
298
|
+
embedding = EXCLUDED.embedding,
|
|
299
|
+
last_user_id = EXCLUDED.last_user_id,
|
|
300
|
+
updated_at = NOW()
|
|
301
|
+
`;
|
|
302
|
+
await this.pool.query(upsertSql, [
|
|
303
|
+
scope.agentId,
|
|
304
|
+
rowUserId,
|
|
305
|
+
input.path,
|
|
306
|
+
mergedContent,
|
|
307
|
+
vectorLiteral,
|
|
308
|
+
provenance,
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async health(): Promise<MemoryHealth> {
|
|
313
|
+
try {
|
|
314
|
+
await this.pool.query('SELECT 1');
|
|
315
|
+
return { ok: true, backend: 'vector' };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
backend: 'vector',
|
|
320
|
+
error: err instanceof Error ? err.message : String(err),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall tracking — Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight adaptation of upstream
|
|
5
|
+
* `extensions/memory-core/src/short-term-promotion.ts::recordShortTermRecalls`.
|
|
6
|
+
* Upstream stores recalls in a JSON file under `memory/.dreams/`; we store
|
|
7
|
+
* them in a Postgres table `agent_memory_recalls`. Schema captures what the
|
|
8
|
+
* future Phase 3 dreaming/promotion algorithm will need:
|
|
9
|
+
* - which memory row was surfaced (`memory_id`)
|
|
10
|
+
* - the query that surfaced it (raw + SHA-256 hash for dedupe)
|
|
11
|
+
* - hybrid score at the time of recall
|
|
12
|
+
* - the day bucket (for per-day dedupe / frequency counting)
|
|
13
|
+
* - the recorded timestamp
|
|
14
|
+
*
|
|
15
|
+
* Best-effort: failures never block memory_search. The caller fires
|
|
16
|
+
* {@link RecallTracker.record} without awaiting the result and ignores errors.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'crypto';
|
|
19
|
+
import type { Pool } from 'pg';
|
|
20
|
+
|
|
21
|
+
export interface RecallTracker {
|
|
22
|
+
/** Record that the given memory ids were surfaced to the model for a query. */
|
|
23
|
+
record(params: RecallRecordParams): Promise<void>;
|
|
24
|
+
/** Backend-specific schema migration. Idempotent. */
|
|
25
|
+
migrate(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RecallRecordParams {
|
|
29
|
+
agentId: string;
|
|
30
|
+
query: string;
|
|
31
|
+
hits: Array<{ id: string; path: string; score: number }>;
|
|
32
|
+
nowMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const RECALL_TABLE = 'agent_memory_recalls';
|
|
36
|
+
|
|
37
|
+
function hashQuery(query: string): string {
|
|
38
|
+
return createHash('sha256')
|
|
39
|
+
.update(query.trim().toLowerCase())
|
|
40
|
+
.digest('hex')
|
|
41
|
+
.slice(0, 32);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dayBucket(nowMs: number): string {
|
|
45
|
+
const d = new Date(nowMs);
|
|
46
|
+
const y = d.getUTCFullYear();
|
|
47
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
48
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
49
|
+
return `${y}-${m}-${day}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class PgvectorRecallTracker implements RecallTracker {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly pool: Pool,
|
|
55
|
+
private readonly table: string = RECALL_TABLE
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
async migrate(): Promise<void> {
|
|
59
|
+
// [recall-tracking] debug: create table + indexes if missing
|
|
60
|
+
await this.pool.query(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
62
|
+
id BIGSERIAL PRIMARY KEY,
|
|
63
|
+
agent_id TEXT NOT NULL,
|
|
64
|
+
memory_id TEXT NOT NULL,
|
|
65
|
+
memory_path TEXT NOT NULL,
|
|
66
|
+
query TEXT NOT NULL,
|
|
67
|
+
query_hash TEXT NOT NULL,
|
|
68
|
+
score DOUBLE PRECISION NOT NULL,
|
|
69
|
+
day_bucket TEXT NOT NULL,
|
|
70
|
+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
71
|
+
)
|
|
72
|
+
`);
|
|
73
|
+
await this.pool.query(
|
|
74
|
+
`CREATE INDEX IF NOT EXISTS ${this.table}_agent_day_idx ON ${this.table} (agent_id, day_bucket)`
|
|
75
|
+
);
|
|
76
|
+
await this.pool.query(
|
|
77
|
+
`CREATE INDEX IF NOT EXISTS ${this.table}_memory_idx ON ${this.table} (agent_id, memory_id)`
|
|
78
|
+
);
|
|
79
|
+
await this.pool.query(
|
|
80
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${this.table}_dedupe_idx
|
|
81
|
+
ON ${this.table} (agent_id, memory_id, query_hash, day_bucket)`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async record(params: RecallRecordParams): Promise<void> {
|
|
86
|
+
if (!params.agentId || !params.query.trim() || params.hits.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
89
|
+
const qhash = hashQuery(params.query);
|
|
90
|
+
const bucket = dayBucket(nowMs);
|
|
91
|
+
|
|
92
|
+
// [recall-tracking] debug: upsert one row per (agent, memory, query, day)
|
|
93
|
+
// Upstream dedupes per-day per-query so repeated searches don't inflate counts.
|
|
94
|
+
const values: string[] = [];
|
|
95
|
+
const args: unknown[] = [];
|
|
96
|
+
let i = 1;
|
|
97
|
+
for (const hit of params.hits) {
|
|
98
|
+
values.push(
|
|
99
|
+
`($${i++}, $${i++}, $${i++}, $${i++}, $${i++}, $${i++}, $${i++}, NOW())`
|
|
100
|
+
);
|
|
101
|
+
args.push(
|
|
102
|
+
params.agentId,
|
|
103
|
+
hit.id,
|
|
104
|
+
hit.path,
|
|
105
|
+
params.query,
|
|
106
|
+
qhash,
|
|
107
|
+
hit.score,
|
|
108
|
+
bucket
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const sql = `
|
|
112
|
+
INSERT INTO ${this.table}
|
|
113
|
+
(agent_id, memory_id, memory_path, query, query_hash, score, day_bucket, recorded_at)
|
|
114
|
+
VALUES ${values.join(', ')}
|
|
115
|
+
ON CONFLICT (agent_id, memory_id, query_hash, day_bucket) DO UPDATE
|
|
116
|
+
SET score = GREATEST(${this.table}.score, EXCLUDED.score),
|
|
117
|
+
recorded_at = NOW()
|
|
118
|
+
`;
|
|
119
|
+
await this.pool.query(sql, args);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** No-op tracker — used when recall tracking is disabled or the backend isn't pgvector. */
|
|
124
|
+
export class NullRecallTracker implements RecallTracker {
|
|
125
|
+
async record(): Promise<void> {}
|
|
126
|
+
async migrate(): Promise<void> {}
|
|
127
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Autonomous memory — Postgres schema (v2: layered tier scoping).
|
|
2
|
+
--
|
|
3
|
+
-- Two-tier canonical-document model:
|
|
4
|
+
--
|
|
5
|
+
-- memory/agent/* → shared across every user of the agent (user_id = NULL)
|
|
6
|
+
-- memory/user/* → private to a specific caller (user_id = <callerId>)
|
|
7
|
+
--
|
|
8
|
+
-- Uniqueness key is `(agent_id, user_id, path)` with NULLS NOT DISTINCT
|
|
9
|
+
-- so two NULL user_id rows on the same path collide (exactly-one
|
|
10
|
+
-- agent-tier row per path) while per-user rows on the same path coexist
|
|
11
|
+
-- (one row per user for user-tier paths).
|
|
12
|
+
--
|
|
13
|
+
-- The read path filters with `(user_id IS NULL OR user_id = $caller)`,
|
|
14
|
+
-- so callers see agent-tier rows + only their own user-tier rows —
|
|
15
|
+
-- never another user's private memory. Enforcement is in SQL, not just
|
|
16
|
+
-- the UI.
|
|
17
|
+
--
|
|
18
|
+
-- NULLS NOT DISTINCT requires PostgreSQL 15+. Host's production
|
|
19
|
+
-- pgvector image is PG16, so this is safe. The migration in
|
|
20
|
+
-- `migrate.ts` will drop the legacy `(agent_id, path)` constraint
|
|
21
|
+
-- before creating the new one if it exists on an older database.
|
|
22
|
+
|
|
23
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS agent_memories (
|
|
26
|
+
id BIGSERIAL PRIMARY KEY,
|
|
27
|
+
agent_id TEXT NOT NULL,
|
|
28
|
+
user_id TEXT, -- NULL = agent-tier, shared
|
|
29
|
+
path TEXT NOT NULL,
|
|
30
|
+
content TEXT NOT NULL,
|
|
31
|
+
embedding VECTOR(1024),
|
|
32
|
+
tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
|
|
33
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
34
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
35
|
+
last_user_id TEXT, -- latest session to append
|
|
36
|
+
CONSTRAINT agent_memories_agent_user_path_uq
|
|
37
|
+
UNIQUE NULLS NOT DISTINCT (agent_id, user_id, path)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- Primary lookup index for list/search/generate-prompt — matches the
|
|
41
|
+
-- exact filter shape of every read query.
|
|
42
|
+
CREATE INDEX IF NOT EXISTS agent_memories_scope_idx
|
|
43
|
+
ON agent_memories (agent_id, user_id, path);
|
|
44
|
+
|
|
45
|
+
-- Vector ANN index — untouched by the scoping change.
|
|
46
|
+
CREATE INDEX IF NOT EXISTS agent_memories_vector_idx
|
|
47
|
+
ON agent_memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
|
48
|
+
|
|
49
|
+
-- Full-text GIN index — untouched.
|
|
50
|
+
CREATE INDEX IF NOT EXISTS agent_memories_tsv_idx
|
|
51
|
+
ON agent_memories USING GIN (tsv);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal decay — Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* Ported from upstream `extensions/memory-core/src/memory/temporal-decay.ts`.
|
|
5
|
+
* Ages dated memory files (`memory/YYYY-MM-DD.md`) using exponential decay
|
|
6
|
+
* `multiplier = exp(-ln(2) / halfLifeDays * ageInDays)`. At half-life, the
|
|
7
|
+
* score is exactly halved.
|
|
8
|
+
*
|
|
9
|
+
* Evergreen files (MEMORY.md, memory/topics.md, any non-dated file inside
|
|
10
|
+
* memory/) do NOT decay — they represent durable knowledge and should stay
|
|
11
|
+
* hot regardless of age. This mirrors upstream's `isEvergreenMemoryPath`.
|
|
12
|
+
*
|
|
13
|
+
* Since our pgvector rows carry `createdAt`, we don't need filesystem stat
|
|
14
|
+
* fallback — the row timestamp is authoritative for any file without a
|
|
15
|
+
* date in the path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface TemporalDecayConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
halfLifeDays: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_TEMPORAL_DECAY_CONFIG: TemporalDecayConfig = {
|
|
24
|
+
enabled: false,
|
|
25
|
+
halfLifeDays: 30,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
|
|
30
|
+
|
|
31
|
+
export function toDecayLambda(halfLifeDays: number): number {
|
|
32
|
+
if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) return 0;
|
|
33
|
+
return Math.LN2 / halfLifeDays;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function calculateTemporalDecayMultiplier(params: {
|
|
37
|
+
ageInDays: number;
|
|
38
|
+
halfLifeDays: number;
|
|
39
|
+
}): number {
|
|
40
|
+
const lambda = toDecayLambda(params.halfLifeDays);
|
|
41
|
+
const age = Math.max(0, params.ageInDays);
|
|
42
|
+
if (lambda <= 0 || !Number.isFinite(age)) return 1;
|
|
43
|
+
return Math.exp(-lambda * age);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function applyTemporalDecayToScore(params: {
|
|
47
|
+
score: number;
|
|
48
|
+
ageInDays: number;
|
|
49
|
+
halfLifeDays: number;
|
|
50
|
+
}): number {
|
|
51
|
+
return params.score * calculateTemporalDecayMultiplier(params);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizePath(p: string): string {
|
|
55
|
+
return (p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse a date out of `memory/YYYY-MM-DD.md` — returns null on non-match or invalid date. */
|
|
59
|
+
export function parseMemoryDateFromPath(filePath: string): Date | null {
|
|
60
|
+
const m = DATED_MEMORY_PATH_RE.exec(normalizePath(filePath));
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
const y = Number(m[1]);
|
|
63
|
+
const mo = Number(m[2]);
|
|
64
|
+
const d = Number(m[3]);
|
|
65
|
+
if (!Number.isInteger(y) || !Number.isInteger(mo) || !Number.isInteger(d))
|
|
66
|
+
return null;
|
|
67
|
+
const ts = Date.UTC(y, mo - 1, d);
|
|
68
|
+
const parsed = new Date(ts);
|
|
69
|
+
if (
|
|
70
|
+
parsed.getUTCFullYear() !== y ||
|
|
71
|
+
parsed.getUTCMonth() !== mo - 1 ||
|
|
72
|
+
parsed.getUTCDate() !== d
|
|
73
|
+
) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Evergreen = durable knowledge file that should not decay.
|
|
81
|
+
* - `MEMORY.md` / `memory.md` at root
|
|
82
|
+
* - anything inside `memory/` that is NOT a dated `YYYY-MM-DD.md` file
|
|
83
|
+
*/
|
|
84
|
+
export function isEvergreenMemoryPath(filePath: string): boolean {
|
|
85
|
+
const n = normalizePath(filePath);
|
|
86
|
+
if (n === 'MEMORY.md' || n === 'memory.md') return true;
|
|
87
|
+
if (!n.startsWith('memory/')) return false;
|
|
88
|
+
return !DATED_MEMORY_PATH_RE.test(n);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ageInDays(timestamp: Date, nowMs: number): number {
|
|
92
|
+
return Math.max(0, nowMs - timestamp.getTime()) / DAY_MS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DecayCandidate {
|
|
96
|
+
path: string;
|
|
97
|
+
score: number;
|
|
98
|
+
createdAt?: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply temporal decay to a list of memory hits.
|
|
103
|
+
*
|
|
104
|
+
* Priority for the effective timestamp:
|
|
105
|
+
* 1. Dated path (`memory/YYYY-MM-DD.md`) — use the date in the path
|
|
106
|
+
* 2. Otherwise, if the path is evergreen — NO decay
|
|
107
|
+
* 3. Otherwise, use the row's `createdAt`
|
|
108
|
+
*/
|
|
109
|
+
export function applyTemporalDecayToHits<T extends DecayCandidate>(
|
|
110
|
+
hits: T[],
|
|
111
|
+
config: Partial<TemporalDecayConfig> = {},
|
|
112
|
+
nowMs: number = Date.now()
|
|
113
|
+
): T[] {
|
|
114
|
+
const merged = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...config };
|
|
115
|
+
if (!merged.enabled) return [...hits];
|
|
116
|
+
|
|
117
|
+
return hits.map((h) => {
|
|
118
|
+
const datedTs = parseMemoryDateFromPath(h.path);
|
|
119
|
+
let ts: Date | null = datedTs;
|
|
120
|
+
if (!ts) {
|
|
121
|
+
if (isEvergreenMemoryPath(h.path)) return h;
|
|
122
|
+
ts = h.createdAt ?? null;
|
|
123
|
+
}
|
|
124
|
+
if (!ts) return h;
|
|
125
|
+
return {
|
|
126
|
+
...h,
|
|
127
|
+
score: applyTemporalDecayToScore({
|
|
128
|
+
score: h.score,
|
|
129
|
+
ageInDays: ageInDays(ts, nowMs),
|
|
130
|
+
halfLifeDays: merged.halfLifeDays,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|