instar 0.1.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/.claude/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelationshipManager — Core system for tracking everyone the agent interacts with.
|
|
3
|
+
*
|
|
4
|
+
* Relationships are fundamental, not a plugin. Same tier as identity and memory.
|
|
5
|
+
* Every person the agent interacts with — across any channel/platform — gets a
|
|
6
|
+
* relationship record that grows over time.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - One JSON file per person in .instar/relationships/
|
|
10
|
+
* - Cross-platform identity resolution via channel index
|
|
11
|
+
* - Auto-enrichment from every interaction
|
|
12
|
+
* - Context injection before any interaction with a known person
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import type {
|
|
19
|
+
RelationshipRecord,
|
|
20
|
+
RelationshipManagerConfig,
|
|
21
|
+
InteractionSummary,
|
|
22
|
+
UserChannel,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
export class RelationshipManager {
|
|
26
|
+
private relationships: Map<string, RelationshipRecord> = new Map();
|
|
27
|
+
/** Maps "channel_type:identifier" -> relationship ID for cross-platform resolution */
|
|
28
|
+
private channelIndex: Map<string, string> = new Map();
|
|
29
|
+
private config: RelationshipManagerConfig;
|
|
30
|
+
|
|
31
|
+
constructor(config: RelationshipManagerConfig) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
if (!existsSync(config.relationshipsDir)) {
|
|
34
|
+
mkdirSync(config.relationshipsDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
this.loadAll();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Core Operations ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find or create a relationship from an incoming interaction.
|
|
43
|
+
* Resolves cross-platform: if the same person messages from Telegram and email,
|
|
44
|
+
* this returns the same relationship.
|
|
45
|
+
*/
|
|
46
|
+
findOrCreate(name: string, channel: UserChannel): RelationshipRecord {
|
|
47
|
+
const channelKey = `${channel.type}:${channel.identifier}`;
|
|
48
|
+
|
|
49
|
+
// Try to resolve by channel first
|
|
50
|
+
const existingId = this.channelIndex.get(channelKey);
|
|
51
|
+
if (existingId) {
|
|
52
|
+
return this.relationships.get(existingId)!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create new relationship
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const record: RelationshipRecord = {
|
|
58
|
+
id: randomUUID(),
|
|
59
|
+
name,
|
|
60
|
+
channels: [channel],
|
|
61
|
+
firstInteraction: now,
|
|
62
|
+
lastInteraction: now,
|
|
63
|
+
interactionCount: 0,
|
|
64
|
+
themes: [],
|
|
65
|
+
notes: '',
|
|
66
|
+
significance: 1,
|
|
67
|
+
recentInteractions: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
this.relationships.set(record.id, record);
|
|
71
|
+
this.channelIndex.set(channelKey, record.id);
|
|
72
|
+
this.save(record);
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a channel identifier to an existing relationship, or null.
|
|
78
|
+
*/
|
|
79
|
+
resolveByChannel(channel: UserChannel): RelationshipRecord | null {
|
|
80
|
+
const channelKey = `${channel.type}:${channel.identifier}`;
|
|
81
|
+
const id = this.channelIndex.get(channelKey);
|
|
82
|
+
return id ? this.relationships.get(id) ?? null : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get a relationship by ID.
|
|
87
|
+
*/
|
|
88
|
+
get(id: string): RelationshipRecord | null {
|
|
89
|
+
return this.relationships.get(id) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get all relationships, optionally sorted by significance or recency.
|
|
94
|
+
*/
|
|
95
|
+
getAll(sortBy: 'significance' | 'recent' | 'name' = 'significance'): RelationshipRecord[] {
|
|
96
|
+
const all = Array.from(this.relationships.values());
|
|
97
|
+
switch (sortBy) {
|
|
98
|
+
case 'significance':
|
|
99
|
+
return all.sort((a, b) => b.significance - a.significance);
|
|
100
|
+
case 'recent':
|
|
101
|
+
return all.sort((a, b) => b.lastInteraction.localeCompare(a.lastInteraction));
|
|
102
|
+
case 'name':
|
|
103
|
+
return all.sort((a, b) => a.name.localeCompare(b.name));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Enrichment ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record an interaction with a person. Updates recency, count, and interaction log.
|
|
111
|
+
*/
|
|
112
|
+
recordInteraction(
|
|
113
|
+
id: string,
|
|
114
|
+
interaction: InteractionSummary,
|
|
115
|
+
): void {
|
|
116
|
+
const record = this.relationships.get(id);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
|
|
119
|
+
record.lastInteraction = interaction.timestamp;
|
|
120
|
+
record.interactionCount++;
|
|
121
|
+
|
|
122
|
+
// Add to recent interactions, trim to max
|
|
123
|
+
record.recentInteractions.push(interaction);
|
|
124
|
+
if (record.recentInteractions.length > this.config.maxRecentInteractions) {
|
|
125
|
+
record.recentInteractions = record.recentInteractions.slice(
|
|
126
|
+
-this.config.maxRecentInteractions,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merge new topics into themes
|
|
131
|
+
if (interaction.topics) {
|
|
132
|
+
for (const topic of interaction.topics) {
|
|
133
|
+
if (!record.themes.includes(topic)) {
|
|
134
|
+
record.themes.push(topic);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Keep themes manageable
|
|
138
|
+
if (record.themes.length > 20) {
|
|
139
|
+
record.themes = record.themes.slice(-20);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Auto-derive significance from frequency and recency
|
|
144
|
+
record.significance = this.calculateSignificance(record);
|
|
145
|
+
|
|
146
|
+
this.save(record);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update notes or other metadata for a relationship.
|
|
151
|
+
*/
|
|
152
|
+
updateNotes(id: string, notes: string): void {
|
|
153
|
+
const record = this.relationships.get(id);
|
|
154
|
+
if (!record) return;
|
|
155
|
+
record.notes = notes;
|
|
156
|
+
this.save(record);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Update the arc summary for a relationship.
|
|
161
|
+
*/
|
|
162
|
+
updateArcSummary(id: string, arcSummary: string): void {
|
|
163
|
+
const record = this.relationships.get(id);
|
|
164
|
+
if (!record) return;
|
|
165
|
+
record.arcSummary = arcSummary;
|
|
166
|
+
this.save(record);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Link a new channel to an existing relationship (cross-platform identity merge).
|
|
171
|
+
*/
|
|
172
|
+
linkChannel(id: string, channel: UserChannel): void {
|
|
173
|
+
const record = this.relationships.get(id);
|
|
174
|
+
if (!record) return;
|
|
175
|
+
|
|
176
|
+
const channelKey = `${channel.type}:${channel.identifier}`;
|
|
177
|
+
|
|
178
|
+
// Check if this channel is already linked to someone else
|
|
179
|
+
const existingId = this.channelIndex.get(channelKey);
|
|
180
|
+
if (existingId && existingId !== id) {
|
|
181
|
+
// Merge the other record into this one
|
|
182
|
+
this.mergeRelationships(id, existingId);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!record.channels.some((c) => c.type === channel.type && c.identifier === channel.identifier)) {
|
|
187
|
+
record.channels.push(channel);
|
|
188
|
+
this.channelIndex.set(channelKey, id);
|
|
189
|
+
this.save(record);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Merge two relationship records (when we discover two channels are the same person).
|
|
195
|
+
*/
|
|
196
|
+
mergeRelationships(keepId: string, mergeId: string): void {
|
|
197
|
+
const keep = this.relationships.get(keepId);
|
|
198
|
+
const merge = this.relationships.get(mergeId);
|
|
199
|
+
if (!keep || !merge) return;
|
|
200
|
+
|
|
201
|
+
// Merge channels
|
|
202
|
+
for (const channel of merge.channels) {
|
|
203
|
+
if (!keep.channels.some((c) => c.type === channel.type && c.identifier === channel.identifier)) {
|
|
204
|
+
keep.channels.push(channel);
|
|
205
|
+
}
|
|
206
|
+
this.channelIndex.set(`${channel.type}:${channel.identifier}`, keepId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Merge interaction history
|
|
210
|
+
keep.recentInteractions = [...keep.recentInteractions, ...merge.recentInteractions]
|
|
211
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
212
|
+
.slice(-this.config.maxRecentInteractions);
|
|
213
|
+
|
|
214
|
+
// Merge themes
|
|
215
|
+
for (const theme of merge.themes) {
|
|
216
|
+
if (!keep.themes.includes(theme)) keep.themes.push(theme);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Take the earlier first interaction
|
|
220
|
+
if (merge.firstInteraction < keep.firstInteraction) {
|
|
221
|
+
keep.firstInteraction = merge.firstInteraction;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Sum interaction counts
|
|
225
|
+
keep.interactionCount += merge.interactionCount;
|
|
226
|
+
|
|
227
|
+
// Merge notes
|
|
228
|
+
if (merge.notes && merge.notes !== keep.notes) {
|
|
229
|
+
keep.notes = keep.notes
|
|
230
|
+
? `${keep.notes}\n\n[Merged from ${merge.name}]: ${merge.notes}`
|
|
231
|
+
: merge.notes;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
keep.significance = this.calculateSignificance(keep);
|
|
235
|
+
this.save(keep);
|
|
236
|
+
|
|
237
|
+
// Delete the merged record
|
|
238
|
+
this.relationships.delete(mergeId);
|
|
239
|
+
this.deleteFile(mergeId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Context Generation ─────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Generate context string for injection into a Claude session before interacting
|
|
246
|
+
* with a known person. This is what makes the agent "know" who it's talking to.
|
|
247
|
+
*/
|
|
248
|
+
getContextForPerson(id: string): string | null {
|
|
249
|
+
const record = this.relationships.get(id);
|
|
250
|
+
if (!record) return null;
|
|
251
|
+
|
|
252
|
+
const lines: string[] = [
|
|
253
|
+
`<relationship_context person="${record.name}">`,
|
|
254
|
+
`Name: ${record.name}`,
|
|
255
|
+
`Known since: ${record.firstInteraction}`,
|
|
256
|
+
`Last interaction: ${record.lastInteraction}`,
|
|
257
|
+
`Total interactions: ${record.interactionCount}`,
|
|
258
|
+
`Significance: ${record.significance}/10`,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
if (record.themes.length > 0) {
|
|
262
|
+
lines.push(`Key themes: ${record.themes.join(', ')}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (record.communicationStyle) {
|
|
266
|
+
lines.push(`Communication style: ${record.communicationStyle}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (record.arcSummary) {
|
|
270
|
+
lines.push(`Relationship arc: ${record.arcSummary}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (record.notes) {
|
|
274
|
+
lines.push(`Notes: ${record.notes}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (record.recentInteractions.length > 0) {
|
|
278
|
+
lines.push('Recent interactions:');
|
|
279
|
+
for (const interaction of record.recentInteractions.slice(-5)) {
|
|
280
|
+
lines.push(` - [${interaction.timestamp}] ${interaction.summary}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push('</relationship_context>');
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find relationships that haven't been contacted in a while.
|
|
290
|
+
*/
|
|
291
|
+
getStaleRelationships(daysThreshold: number = 14): RelationshipRecord[] {
|
|
292
|
+
const cutoff = new Date();
|
|
293
|
+
cutoff.setDate(cutoff.getDate() - daysThreshold);
|
|
294
|
+
const cutoffStr = cutoff.toISOString();
|
|
295
|
+
|
|
296
|
+
return this.getAll('recent').filter(
|
|
297
|
+
(r) => r.lastInteraction < cutoffStr && r.significance >= 3,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Persistence ────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
private loadAll(): void {
|
|
304
|
+
if (!existsSync(this.config.relationshipsDir)) return;
|
|
305
|
+
|
|
306
|
+
const files = readdirSync(this.config.relationshipsDir).filter((f) => f.endsWith('.json'));
|
|
307
|
+
for (const file of files) {
|
|
308
|
+
try {
|
|
309
|
+
const data = JSON.parse(readFileSync(join(this.config.relationshipsDir, file), 'utf-8'));
|
|
310
|
+
this.relationships.set(data.id, data);
|
|
311
|
+
for (const channel of data.channels) {
|
|
312
|
+
this.channelIndex.set(`${channel.type}:${channel.identifier}`, data.id);
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
// Skip corrupted files
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private save(record: RelationshipRecord): void {
|
|
321
|
+
const filePath = join(this.config.relationshipsDir, `${record.id}.json`);
|
|
322
|
+
writeFileSync(filePath, JSON.stringify(record, null, 2));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private deleteFile(id: string): void {
|
|
326
|
+
const filePath = join(this.config.relationshipsDir, `${id}.json`);
|
|
327
|
+
try {
|
|
328
|
+
const { unlinkSync } = require('fs');
|
|
329
|
+
unlinkSync(filePath);
|
|
330
|
+
} catch {
|
|
331
|
+
// File may not exist
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Internal ───────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
private calculateSignificance(record: RelationshipRecord): number {
|
|
338
|
+
// Significance is derived from:
|
|
339
|
+
// - Interaction frequency (count)
|
|
340
|
+
// - Recency (how recently they interacted)
|
|
341
|
+
// - Theme depth (variety of topics)
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const lastInteraction = new Date(record.lastInteraction).getTime();
|
|
344
|
+
const daysSinceLastInteraction = (now - lastInteraction) / (1000 * 60 * 60 * 24);
|
|
345
|
+
|
|
346
|
+
let score = 0;
|
|
347
|
+
|
|
348
|
+
// Frequency component (0-4 points)
|
|
349
|
+
if (record.interactionCount >= 50) score += 4;
|
|
350
|
+
else if (record.interactionCount >= 20) score += 3;
|
|
351
|
+
else if (record.interactionCount >= 5) score += 2;
|
|
352
|
+
else if (record.interactionCount >= 2) score += 1;
|
|
353
|
+
|
|
354
|
+
// Recency component (0-3 points)
|
|
355
|
+
if (daysSinceLastInteraction < 1) score += 3;
|
|
356
|
+
else if (daysSinceLastInteraction < 7) score += 2;
|
|
357
|
+
else if (daysSinceLastInteraction < 30) score += 1;
|
|
358
|
+
|
|
359
|
+
// Theme depth (0-3 points)
|
|
360
|
+
if (record.themes.length >= 10) score += 3;
|
|
361
|
+
else if (record.themes.length >= 5) score += 2;
|
|
362
|
+
else if (record.themes.length >= 2) score += 1;
|
|
363
|
+
|
|
364
|
+
return Math.min(10, Math.max(1, score));
|
|
365
|
+
}
|
|
366
|
+
}
|