@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.
- package/README.md +100 -33
- package/dist/cli.js +1087 -0
- package/dist/resources/MemoryBootstrap.js +55 -15
- package/dist/resources/MemoryMaintenance.js +90 -0
- package/dist/resources/SemanticSearch.js +54 -7
- package/package.json +12 -4
- package/resources/MemoryBootstrap.ts +58 -18
- package/resources/MemoryMaintenance.ts +95 -0
- package/resources/SemanticSearch.ts +47 -7
|
@@ -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 (
|
|
58
|
-
// Soul is who you are
|
|
59
|
-
//
|
|
57
|
+
// --- 1. Soul records (budgeted — prioritized 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
|
-
|
|
72
|
-
|
|
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 (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
keywordHit = true;
|
|
108
145
|
}
|
|
109
146
|
if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
|
|
110
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality
|
|
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.
|
|
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 (
|
|
72
|
-
// Soul is who you are
|
|
73
|
-
//
|
|
71
|
+
// --- 1. Soul records (budgeted — prioritized 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
|
-
|
|
85
|
-
|
|
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 (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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;
|
|
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
|
|
140
|
+
let semanticScore = 0;
|
|
141
|
+
let keywordHit = false;
|
|
112
142
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
113
|
-
|
|
143
|
+
keywordHit = true;
|
|
114
144
|
}
|
|
115
145
|
if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|