@tpsdev-ai/flair 0.2.0 → 0.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.
@@ -54,12 +54,22 @@ export class BootstrapMemories extends Resource {
54
54
  let tokenBudget = maxTokens;
55
55
  let memoriesIncluded = 0;
56
56
  let memoriesAvailable = 0;
57
- // --- 1. Soul records (unconditionalnot subject to token budget) ---
58
- // Soul is who you are. It's not optional context to be trimmed.
59
- // Skill assignments (key='skill-assignment') are separated into their own section.
57
+ // --- 1. Soul records (budgetedprioritized by key importance) ---
58
+ // Soul is who you are, but we still need to respect token budgets.
59
+ // Workspace files (SOUL.md, AGENTS.md) can be massive they're already
60
+ // injected by the runtime via workspace context, so we prioritize
61
+ // concise soul entries over full file dumps.
62
+ const SOUL_KEY_PRIORITY = {
63
+ role: 0, identity: 1, thinking: 2, communication_style: 3,
64
+ team: 4, ownership: 5, infrastructure: 6, "user-context": 7,
65
+ // Full workspace files — lowest priority (runtime already injects these)
66
+ soul: 90, "workspace-rules": 91,
67
+ };
60
68
  const skillAssignments = [];
69
+ const soulMaxTokens = Math.floor(maxTokens * 0.4); // 40% of budget for soul
61
70
  if (includeSoul) {
62
71
  let soulTokens = 0;
72
+ const soulEntries = [];
63
73
  for await (const record of databases.flair.Soul.search()) {
64
74
  if (record.agentId !== agentId)
65
75
  continue;
@@ -68,11 +78,29 @@ export class BootstrapMemories extends Resource {
68
78
  continue;
69
79
  }
70
80
  const line = `**${record.key}:** ${record.value}`;
71
- sections.soul.push(line);
72
- soulTokens += estimateTokens(line);
81
+ const tokens = estimateTokens(line);
82
+ const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
83
+ soulEntries.push({ key: record.key, line, tokens, priority });
84
+ }
85
+ // Sort by priority (lower = more important)
86
+ soulEntries.sort((a, b) => a.priority - b.priority);
87
+ for (const entry of soulEntries) {
88
+ if (soulTokens + entry.tokens > soulMaxTokens) {
89
+ // Skip large entries that exceed budget — truncate or skip
90
+ if (entry.priority >= 90)
91
+ continue; // skip full workspace files
92
+ // Truncate if it's important but too long
93
+ const maxChars = (soulMaxTokens - soulTokens) * 4;
94
+ if (maxChars > 100) {
95
+ const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
96
+ sections.soul.push(truncated);
97
+ soulTokens += estimateTokens(truncated);
98
+ }
99
+ continue;
100
+ }
101
+ sections.soul.push(entry.line);
102
+ soulTokens += entry.tokens;
73
103
  }
74
- // Soul tokens are tracked but don't reduce memory budget
75
- tokenBudget = maxTokens; // memory budget is separate from soul
76
104
  }
77
105
  // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
78
106
  if (skillAssignments.length > 0) {
@@ -134,15 +162,27 @@ export class BootstrapMemories extends Resource {
134
162
  memoriesIncluded++;
135
163
  }
136
164
  }
137
- // --- 3. Recent memories (last 24-48h, standard + persistent) ---
138
- const sinceDate = since
139
- ? new Date(since)
140
- : new Date(Date.now() - 48 * 3600_000);
141
- const recent = activeMemories
142
- .filter((m) => m.durability !== "permanent" &&
143
- m.createdAt &&
144
- new Date(m.createdAt) >= sinceDate)
165
+ // --- 3. Recent memories (adaptive window) ---
166
+ // Start with 48h. If nothing found, widen to 7d, then 30d.
167
+ // This prevents empty recent sections for agents that were idle.
168
+ const nonPermanent = activeMemories
169
+ .filter((m) => m.durability !== "permanent" && m.createdAt)
145
170
  .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
171
+ let effectiveSince;
172
+ if (since) {
173
+ effectiveSince = new Date(since);
174
+ }
175
+ else {
176
+ const windows = [48 * 3600_000, 7 * 24 * 3600_000, 30 * 24 * 3600_000];
177
+ effectiveSince = new Date(Date.now() - windows[0]);
178
+ for (const w of windows) {
179
+ effectiveSince = new Date(Date.now() - w);
180
+ const count = nonPermanent.filter((m) => new Date(m.createdAt) >= effectiveSince).length;
181
+ if (count >= 3)
182
+ break; // found enough recent memories
183
+ }
184
+ }
185
+ const recent = nonPermanent.filter((m) => new Date(m.createdAt) >= effectiveSince);
146
186
  // Budget: up to 40% of remaining for recent
147
187
  const recentBudget = Math.floor(tokenBudget * 0.4);
148
188
  let recentSpent = 0;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * MemoryMaintenance.ts — Maintenance worker for memory hygiene.
3
+ *
4
+ * POST /MemoryMaintenance/ — runs cleanup tasks:
5
+ * 1. Delete expired ephemeral memories (expiresAt < now)
6
+ * 2. Archive old session memories (> 30 days, standard durability)
7
+ * 3. Report stats
8
+ *
9
+ * Designed to run periodically (daily cron or heartbeat).
10
+ * Requires admin auth.
11
+ */
12
+ export default class MemoryMaintenance {
13
+ static ROUTE = "MemoryMaintenance";
14
+ static METHOD = "POST";
15
+ async post(data) {
16
+ const { databases } = this;
17
+ const request = this.request;
18
+ const { dryRun = false, agentId } = data || {};
19
+ // Scope to authenticated agent. Admin can pass agentId for system-wide
20
+ // maintenance; non-admin always scoped to their own agent.
21
+ const authAgent = request?.headers?.get?.("x-tps-agent");
22
+ const isAdmin = request?.tpsAgentIsAdmin === true;
23
+ const targetAgent = isAdmin && agentId ? agentId : authAgent;
24
+ if (!targetAgent && !isAdmin) {
25
+ return { error: "agentId required" };
26
+ }
27
+ const now = new Date();
28
+ const stats = { expired: 0, archived: 0, total: 0, errors: 0, agent: targetAgent || "all" };
29
+ try {
30
+ for await (const record of databases.flair.Memory.search()) {
31
+ // Skip records not belonging to target agent (unless admin running system-wide)
32
+ if (targetAgent && record.agentId !== targetAgent)
33
+ continue;
34
+ stats.total++;
35
+ // 1. Delete expired memories
36
+ if (record.expiresAt && new Date(record.expiresAt) < now) {
37
+ if (!dryRun) {
38
+ try {
39
+ await databases.flair.Memory.delete(record.id);
40
+ stats.expired++;
41
+ }
42
+ catch {
43
+ stats.errors++;
44
+ }
45
+ }
46
+ else {
47
+ stats.expired++;
48
+ }
49
+ continue;
50
+ }
51
+ // 2. Archive old standard session memories (> 30 days)
52
+ // These are low-value session notes that weren't promoted to persistent.
53
+ // Archiving removes them from search results but keeps the data.
54
+ if (record.durability === "standard" &&
55
+ record.type === "session" &&
56
+ !record.archived &&
57
+ record.createdAt) {
58
+ const ageMs = now.getTime() - new Date(record.createdAt).getTime();
59
+ const ageDays = ageMs / (24 * 3600_000);
60
+ if (ageDays > 30) {
61
+ if (!dryRun) {
62
+ try {
63
+ // Soft archive — set archived flag, keep data
64
+ await databases.flair.Memory.update(record.id, {
65
+ ...record,
66
+ archived: true,
67
+ archivedAt: now.toISOString(),
68
+ });
69
+ stats.archived++;
70
+ }
71
+ catch {
72
+ stats.errors++;
73
+ }
74
+ }
75
+ else {
76
+ stats.archived++;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ catch (err) {
83
+ return { error: err.message, stats };
84
+ }
85
+ return {
86
+ message: dryRun ? "Dry run complete" : "Maintenance complete",
87
+ stats,
88
+ };
89
+ }
90
+ }
@@ -44,7 +44,7 @@ function compositeScore(semanticScore, record) {
44
44
  }
45
45
  export class SemanticSearch extends Resource {
46
46
  async post(data) {
47
- const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite" } = data || {};
47
+ const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since } = data || {};
48
48
  const subjectFilter = subjects
49
49
  ? new Set(subjects.map((s) => s.toLowerCase()))
50
50
  : subject
@@ -86,6 +86,39 @@ export class SemanticSearch extends Resource {
86
86
  catch { }
87
87
  }
88
88
  }
89
+ // ─── Temporal intent detection ────────────────────────────────────────────
90
+ // If the query implies a time window and no explicit `since` was provided,
91
+ // auto-detect and apply a recency boost.
92
+ let sinceDate = since ? new Date(since) : null;
93
+ let temporalBoost = 1.0;
94
+ if (q && !sinceDate) {
95
+ const lq = String(q).toLowerCase();
96
+ if (/\btoday\b|\bthis morning\b|\bthis afternoon\b/.test(lq)) {
97
+ const d = new Date();
98
+ d.setHours(0, 0, 0, 0);
99
+ sinceDate = d;
100
+ temporalBoost = 1.5; // boost recent results for temporal queries
101
+ }
102
+ else if (/\byesterday\b/.test(lq)) {
103
+ const d = new Date();
104
+ d.setDate(d.getDate() - 1);
105
+ d.setHours(0, 0, 0, 0);
106
+ sinceDate = d;
107
+ temporalBoost = 1.3;
108
+ }
109
+ else if (/\bthis week\b|\blast few days\b/.test(lq)) {
110
+ sinceDate = new Date(Date.now() - 7 * 24 * 3600_000);
111
+ temporalBoost = 1.2;
112
+ }
113
+ else if (/\blast week\b/.test(lq)) {
114
+ sinceDate = new Date(Date.now() - 14 * 24 * 3600_000);
115
+ temporalBoost = 1.1;
116
+ }
117
+ else if (/\brecently\b|\blately\b/.test(lq)) {
118
+ sinceDate = new Date(Date.now() - 3 * 24 * 3600_000);
119
+ temporalBoost = 1.3;
120
+ }
121
+ }
89
122
  const results = [];
90
123
  // Iterate ALL memories, filter by agent ID set
91
124
  for await (const record of databases.flair.Memory.search()) {
@@ -95,24 +128,34 @@ export class SemanticSearch extends Resource {
95
128
  continue;
96
129
  }
97
130
  if (record.archived === true)
98
- continue; // soft-deleted — excluded from search by default
131
+ continue;
99
132
  if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
100
133
  continue;
101
134
  if (tag && !(record.tags || []).includes(tag))
102
135
  continue;
103
136
  if (subjectFilter && record.subject && !subjectFilter.has(String(record.subject).toLowerCase()))
104
137
  continue;
105
- let rawScore = 0;
138
+ // Time window filter
139
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
140
+ continue;
141
+ let semanticScore = 0;
142
+ let keywordHit = false;
106
143
  if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
107
- rawScore += 0.5;
144
+ keywordHit = true;
108
145
  }
109
146
  if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
110
- rawScore += cosineSimilarity(qEmb, record.embedding);
147
+ semanticScore = cosineSimilarity(qEmb, record.embedding);
111
148
  }
149
+ // Keyword match is a small tiebreaker (5%), not a primary signal.
150
+ // This prevents weak semantic matches from ranking high just because
151
+ // a query word appears in the content.
152
+ const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
112
153
  if (q && rawScore === 0)
113
154
  continue;
114
- // Apply composite scoring (temporal decay + durability + retrieval boost)
115
- const finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
155
+ // Apply composite scoring (temporal decay + durability + retrieval boost + temporal intent)
156
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
157
+ if (temporalBoost > 1.0)
158
+ finalScore *= temporalBoost;
116
159
  const { embedding, ...rest } = record;
117
160
  results.push({
118
161
  ...rest,
@@ -131,6 +174,10 @@ export class SemanticSearch extends Resource {
131
174
  }
132
175
  filteredResults = results.filter((r) => !supersededIds.has(r.id));
133
176
  }
177
+ // Apply minimum score filter
178
+ if (minScore > 0) {
179
+ filteredResults = filteredResults.filter((r) => r._score >= minScore);
180
+ }
134
181
  filteredResults.sort((a, b) => b._score - a._score);
135
182
  const topResults = filteredResults.slice(0, limit);
136
183
  // Async hit tracking — don't block the response
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.2.0",
4
- "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality all in a single process.",
3
+ "version": "0.3.0",
4
+ "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality \u2014 all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -39,13 +39,17 @@
39
39
  "scripts": {
40
40
  "build": "tsc -p tsconfig.json --noCheck",
41
41
  "build:cli": "tsc -p tsconfig.cli.json --noCheck",
42
+ "prepublishOnly": "npm run build && npm run build:cli",
42
43
  "test": "bun test"
43
44
  },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
44
48
  "engines": {
45
49
  "node": ">=22"
46
50
  },
47
51
  "dependencies": {
48
- "@harperfast/harper": "5.0.0-beta.1",
52
+ "@harperfast/harper": "5.0.0-beta.4",
49
53
  "@node-llama-cpp/mac-arm64-metal": "^3.17.1",
50
54
  "commander": "14.0.3",
51
55
  "harper-fabric-embeddings": "^0.2.0",
@@ -58,5 +62,9 @@
58
62
  },
59
63
  "trustedDependencies": [
60
64
  "harper-fabric-embeddings"
65
+ ],
66
+ "workspaces": [
67
+ "packages/*",
68
+ "plugins/*"
61
69
  ]
62
- }
70
+ }
@@ -68,12 +68,24 @@ export class BootstrapMemories extends Resource {
68
68
  let memoriesIncluded = 0;
69
69
  let memoriesAvailable = 0;
70
70
 
71
- // --- 1. Soul records (unconditionalnot subject to token budget) ---
72
- // Soul is who you are. It's not optional context to be trimmed.
73
- // Skill assignments (key='skill-assignment') are separated into their own section.
71
+ // --- 1. Soul records (budgetedprioritized by key importance) ---
72
+ // Soul is who you are, but we still need to respect token budgets.
73
+ // Workspace files (SOUL.md, AGENTS.md) can be massive they're already
74
+ // injected by the runtime via workspace context, so we prioritize
75
+ // concise soul entries over full file dumps.
76
+ const SOUL_KEY_PRIORITY: Record<string, number> = {
77
+ role: 0, identity: 1, thinking: 2, communication_style: 3,
78
+ team: 4, ownership: 5, infrastructure: 6, "user-context": 7,
79
+ // Full workspace files — lowest priority (runtime already injects these)
80
+ soul: 90, "workspace-rules": 91,
81
+ };
82
+
74
83
  const skillAssignments: any[] = [];
84
+ const soulMaxTokens = Math.floor(maxTokens * 0.4); // 40% of budget for soul
75
85
  if (includeSoul) {
76
86
  let soulTokens = 0;
87
+ const soulEntries: { key: string; line: string; tokens: number; priority: number }[] = [];
88
+
77
89
  for await (const record of (databases as any).flair.Soul.search()) {
78
90
  if (record.agentId !== agentId) continue;
79
91
  if (record.key === "skill-assignment") {
@@ -81,11 +93,30 @@ export class BootstrapMemories extends Resource {
81
93
  continue;
82
94
  }
83
95
  const line = `**${record.key}:** ${record.value}`;
84
- sections.soul.push(line);
85
- soulTokens += estimateTokens(line);
96
+ const tokens = estimateTokens(line);
97
+ const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
98
+ soulEntries.push({ key: record.key, line, tokens, priority });
99
+ }
100
+
101
+ // Sort by priority (lower = more important)
102
+ soulEntries.sort((a, b) => a.priority - b.priority);
103
+
104
+ for (const entry of soulEntries) {
105
+ if (soulTokens + entry.tokens > soulMaxTokens) {
106
+ // Skip large entries that exceed budget — truncate or skip
107
+ if (entry.priority >= 90) continue; // skip full workspace files
108
+ // Truncate if it's important but too long
109
+ const maxChars = (soulMaxTokens - soulTokens) * 4;
110
+ if (maxChars > 100) {
111
+ const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
112
+ sections.soul.push(truncated);
113
+ soulTokens += estimateTokens(truncated);
114
+ }
115
+ continue;
116
+ }
117
+ sections.soul.push(entry.line);
118
+ soulTokens += entry.tokens;
86
119
  }
87
- // Soul tokens are tracked but don't reduce memory budget
88
- tokenBudget = maxTokens; // memory budget is separate from soul
89
120
  }
90
121
 
91
122
  // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
@@ -147,19 +178,28 @@ export class BootstrapMemories extends Resource {
147
178
  }
148
179
  }
149
180
 
150
- // --- 3. Recent memories (last 24-48h, standard + persistent) ---
151
- const sinceDate = since
152
- ? new Date(since)
153
- : new Date(Date.now() - 48 * 3600_000);
154
- const recent = activeMemories
155
- .filter(
156
- (m) =>
157
- m.durability !== "permanent" &&
158
- m.createdAt &&
159
- new Date(m.createdAt) >= sinceDate
160
- )
181
+ // --- 3. Recent memories (adaptive window) ---
182
+ // Start with 48h. If nothing found, widen to 7d, then 30d.
183
+ // This prevents empty recent sections for agents that were idle.
184
+ const nonPermanent = activeMemories
185
+ .filter((m) => m.durability !== "permanent" && m.createdAt)
161
186
  .sort((a: any, b: any) => (b.createdAt || "").localeCompare(a.createdAt || ""));
162
187
 
188
+ let effectiveSince: Date;
189
+ if (since) {
190
+ effectiveSince = new Date(since);
191
+ } else {
192
+ const windows = [48 * 3600_000, 7 * 24 * 3600_000, 30 * 24 * 3600_000];
193
+ effectiveSince = new Date(Date.now() - windows[0]);
194
+ for (const w of windows) {
195
+ effectiveSince = new Date(Date.now() - w);
196
+ const count = nonPermanent.filter((m) => new Date(m.createdAt!) >= effectiveSince).length;
197
+ if (count >= 3) break; // found enough recent memories
198
+ }
199
+ }
200
+
201
+ const recent = nonPermanent.filter((m) => new Date(m.createdAt!) >= effectiveSince);
202
+
163
203
  // Budget: up to 40% of remaining for recent
164
204
  const recentBudget = Math.floor(tokenBudget * 0.4);
165
205
  let recentSpent = 0;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * MemoryMaintenance.ts — Maintenance worker for memory hygiene.
3
+ *
4
+ * POST /MemoryMaintenance/ — runs cleanup tasks:
5
+ * 1. Delete expired ephemeral memories (expiresAt < now)
6
+ * 2. Archive old session memories (> 30 days, standard durability)
7
+ * 3. Report stats
8
+ *
9
+ * Designed to run periodically (daily cron or heartbeat).
10
+ * Requires admin auth.
11
+ */
12
+
13
+ export default class MemoryMaintenance {
14
+ static ROUTE = "MemoryMaintenance";
15
+ static METHOD = "POST";
16
+
17
+ async post(data: any) {
18
+ const { databases }: any = this;
19
+ const request = (this as any).request;
20
+ const { dryRun = false, agentId } = data || {};
21
+
22
+ // Scope to authenticated agent. Admin can pass agentId for system-wide
23
+ // maintenance; non-admin always scoped to their own agent.
24
+ const authAgent = request?.headers?.get?.("x-tps-agent");
25
+ const isAdmin = (request as any)?.tpsAgentIsAdmin === true;
26
+ const targetAgent = isAdmin && agentId ? agentId : authAgent;
27
+
28
+ if (!targetAgent && !isAdmin) {
29
+ return { error: "agentId required" };
30
+ }
31
+
32
+ const now = new Date();
33
+ const stats = { expired: 0, archived: 0, total: 0, errors: 0, agent: targetAgent || "all" };
34
+
35
+ try {
36
+ for await (const record of (databases as any).flair.Memory.search()) {
37
+ // Skip records not belonging to target agent (unless admin running system-wide)
38
+ if (targetAgent && record.agentId !== targetAgent) continue;
39
+ stats.total++;
40
+
41
+ // 1. Delete expired memories
42
+ if (record.expiresAt && new Date(record.expiresAt) < now) {
43
+ if (!dryRun) {
44
+ try {
45
+ await (databases as any).flair.Memory.delete(record.id);
46
+ stats.expired++;
47
+ } catch {
48
+ stats.errors++;
49
+ }
50
+ } else {
51
+ stats.expired++;
52
+ }
53
+ continue;
54
+ }
55
+
56
+ // 2. Archive old standard session memories (> 30 days)
57
+ // These are low-value session notes that weren't promoted to persistent.
58
+ // Archiving removes them from search results but keeps the data.
59
+ if (
60
+ record.durability === "standard" &&
61
+ record.type === "session" &&
62
+ !record.archived &&
63
+ record.createdAt
64
+ ) {
65
+ const ageMs = now.getTime() - new Date(record.createdAt).getTime();
66
+ const ageDays = ageMs / (24 * 3600_000);
67
+ if (ageDays > 30) {
68
+ if (!dryRun) {
69
+ try {
70
+ // Soft archive — set archived flag, keep data
71
+ await (databases as any).flair.Memory.update(record.id, {
72
+ ...record,
73
+ archived: true,
74
+ archivedAt: now.toISOString(),
75
+ });
76
+ stats.archived++;
77
+ } catch {
78
+ stats.errors++;
79
+ }
80
+ } else {
81
+ stats.archived++;
82
+ }
83
+ }
84
+ }
85
+ }
86
+ } catch (err: any) {
87
+ return { error: err.message, stats };
88
+ }
89
+
90
+ return {
91
+ message: dryRun ? "Dry run complete" : "Maintenance complete",
92
+ stats,
93
+ };
94
+ }
95
+ }
@@ -52,7 +52,7 @@ function compositeScore(
52
52
 
53
53
  export class SemanticSearch extends Resource {
54
54
  async post(data: any) {
55
- const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite" } = data || {};
55
+ const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since } = data || {};
56
56
  const subjectFilter = subjects
57
57
  ? new Set((subjects as string[]).map((s: string) => s.toLowerCase()))
58
58
  : subject
@@ -94,6 +94,33 @@ export class SemanticSearch extends Resource {
94
94
  }
95
95
  }
96
96
 
97
+ // ─── Temporal intent detection ────────────────────────────────────────────
98
+ // If the query implies a time window and no explicit `since` was provided,
99
+ // auto-detect and apply a recency boost.
100
+ let sinceDate: Date | null = since ? new Date(since) : null;
101
+ let temporalBoost = 1.0;
102
+ if (q && !sinceDate) {
103
+ const lq = String(q).toLowerCase();
104
+ if (/\btoday\b|\bthis morning\b|\bthis afternoon\b/.test(lq)) {
105
+ const d = new Date(); d.setHours(0, 0, 0, 0);
106
+ sinceDate = d;
107
+ temporalBoost = 1.5; // boost recent results for temporal queries
108
+ } else if (/\byesterday\b/.test(lq)) {
109
+ const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(0, 0, 0, 0);
110
+ sinceDate = d;
111
+ temporalBoost = 1.3;
112
+ } else if (/\bthis week\b|\blast few days\b/.test(lq)) {
113
+ sinceDate = new Date(Date.now() - 7 * 24 * 3600_000);
114
+ temporalBoost = 1.2;
115
+ } else if (/\blast week\b/.test(lq)) {
116
+ sinceDate = new Date(Date.now() - 14 * 24 * 3600_000);
117
+ temporalBoost = 1.1;
118
+ } else if (/\brecently\b|\blately\b/.test(lq)) {
119
+ sinceDate = new Date(Date.now() - 3 * 24 * 3600_000);
120
+ temporalBoost = 1.3;
121
+ }
122
+ }
123
+
97
124
  const results: any[] = [];
98
125
 
99
126
  // Iterate ALL memories, filter by agent ID set
@@ -103,22 +130,30 @@ export class SemanticSearch extends Resource {
103
130
  if (record.visibility !== "office") continue;
104
131
  }
105
132
 
106
- if (record.archived === true) continue; // soft-deleted — excluded from search by default
133
+ if (record.archived === true) continue;
107
134
  if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) continue;
108
135
  if (tag && !(record.tags || []).includes(tag)) continue;
109
136
  if (subjectFilter && record.subject && !subjectFilter.has(String(record.subject).toLowerCase())) continue;
137
+ // Time window filter
138
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate) continue;
110
139
 
111
- let rawScore = 0;
140
+ let semanticScore = 0;
141
+ let keywordHit = false;
112
142
  if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
113
- rawScore += 0.5;
143
+ keywordHit = true;
114
144
  }
115
145
  if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
116
- rawScore += cosineSimilarity(qEmb, record.embedding);
146
+ semanticScore = cosineSimilarity(qEmb, record.embedding);
117
147
  }
148
+ // Keyword match is a small tiebreaker (5%), not a primary signal.
149
+ // This prevents weak semantic matches from ranking high just because
150
+ // a query word appears in the content.
151
+ const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
118
152
  if (q && rawScore === 0) continue;
119
153
 
120
- // Apply composite scoring (temporal decay + durability + retrieval boost)
121
- const finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
154
+ // Apply composite scoring (temporal decay + durability + retrieval boost + temporal intent)
155
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
156
+ if (temporalBoost > 1.0) finalScore *= temporalBoost;
122
157
 
123
158
  const { embedding, ...rest } = record;
124
159
  results.push({
@@ -139,6 +174,11 @@ export class SemanticSearch extends Resource {
139
174
  filteredResults = results.filter((r: any) => !supersededIds.has(r.id));
140
175
  }
141
176
 
177
+ // Apply minimum score filter
178
+ if (minScore > 0) {
179
+ filteredResults = filteredResults.filter((r: any) => r._score >= minScore);
180
+ }
181
+
142
182
  filteredResults.sort((a: any, b: any) => b._score - a._score);
143
183
  const topResults = filteredResults.slice(0, limit);
144
184