@memoryrelay/plugin-memoryrelay-ai 0.12.0 → 0.12.2
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 +249 -23
- package/index.ts +1 -1
- package/package.json +4 -3
- package/src/cli/stats-command.ts +166 -0
- package/src/debug-logger.test.ts +233 -0
- package/src/debug-logger.ts +169 -0
- package/src/heartbeat/daily-stats.ts +233 -0
- package/src/onboarding/first-run.ts +236 -0
- package/src/status-reporter.test.ts +230 -0
- package/src/status-reporter.ts +284 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DebugLogger Tests (Corrected)
|
|
3
|
+
*
|
|
4
|
+
* Tests matching actual implementation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect, beforeEach, vi } from "vitest";
|
|
8
|
+
import { DebugLogger, type LogEntry } from "./debug-logger";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
|
|
11
|
+
vi.mock("fs");
|
|
12
|
+
|
|
13
|
+
describe("DebugLogger", () => {
|
|
14
|
+
let logger: DebugLogger;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
logger = new DebugLogger({
|
|
19
|
+
enabled: true,
|
|
20
|
+
verbose: false,
|
|
21
|
+
maxEntries: 5,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("logs are stored when enabled", () => {
|
|
26
|
+
logger.log({
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
tool: "memory_store",
|
|
29
|
+
method: "POST",
|
|
30
|
+
path: "/v1/memories",
|
|
31
|
+
duration: 142,
|
|
32
|
+
status: "success",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const logs = logger.getAllLogs();
|
|
36
|
+
expect(logs).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("logs are not stored when disabled", () => {
|
|
40
|
+
const disabledLogger = new DebugLogger({
|
|
41
|
+
enabled: false,
|
|
42
|
+
verbose: false,
|
|
43
|
+
maxEntries: 5,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
disabledLogger.log({
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
tool: "memory_store",
|
|
49
|
+
method: "POST",
|
|
50
|
+
path: "/v1/memories",
|
|
51
|
+
duration: 142,
|
|
52
|
+
status: "success",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const logs = disabledLogger.getAllLogs();
|
|
56
|
+
expect(logs).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("respects circular buffer limit (FIFO)", () => {
|
|
60
|
+
for (let i = 0; i < 10; i++) {
|
|
61
|
+
logger.log({
|
|
62
|
+
timestamp: new Date(Date.now() + i).toISOString(),
|
|
63
|
+
tool: `tool_${i}`,
|
|
64
|
+
method: "GET",
|
|
65
|
+
path: `/test/${i}`,
|
|
66
|
+
duration: 100,
|
|
67
|
+
status: "success",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const logs = logger.getAllLogs();
|
|
72
|
+
expect(logs).toHaveLength(5); // maxEntries = 5
|
|
73
|
+
expect(logs[0].tool).toBe("tool_5"); // Oldest kept
|
|
74
|
+
expect(logs[4].tool).toBe("tool_9"); // Newest
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("getRecentLogs returns last N entries", () => {
|
|
78
|
+
for (let i = 0; i < 3; i++) {
|
|
79
|
+
logger.log({
|
|
80
|
+
timestamp: new Date(Date.now() + i).toISOString(),
|
|
81
|
+
tool: `tool_${i}`,
|
|
82
|
+
method: "GET",
|
|
83
|
+
path: `/test/${i}`,
|
|
84
|
+
duration: 100,
|
|
85
|
+
status: "success",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const logs = logger.getRecentLogs(2);
|
|
90
|
+
expect(logs).toHaveLength(2);
|
|
91
|
+
expect(logs[0].tool).toBe("tool_1"); // Second-to-last
|
|
92
|
+
expect(logs[1].tool).toBe("tool_2"); // Last
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("getToolLogs filters by tool name", () => {
|
|
96
|
+
logger.log({
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
tool: "memory_store",
|
|
99
|
+
method: "POST",
|
|
100
|
+
path: "/v1/memories",
|
|
101
|
+
duration: 142,
|
|
102
|
+
status: "success",
|
|
103
|
+
});
|
|
104
|
+
logger.log({
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
tool: "memory_recall",
|
|
107
|
+
method: "POST",
|
|
108
|
+
path: "/v1/memories/search",
|
|
109
|
+
duration: 78,
|
|
110
|
+
status: "success",
|
|
111
|
+
});
|
|
112
|
+
logger.log({
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
tool: "memory_store",
|
|
115
|
+
method: "POST",
|
|
116
|
+
path: "/v1/memories",
|
|
117
|
+
duration: 156,
|
|
118
|
+
status: "error",
|
|
119
|
+
error: "500",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const logs = logger.getToolLogs("memory_store", 10);
|
|
123
|
+
expect(logs).toHaveLength(2);
|
|
124
|
+
expect(logs.every(l => l.tool === "memory_store")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("getErrorLogs filters by error status", () => {
|
|
128
|
+
logger.log({
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
tool: "memory_store",
|
|
131
|
+
method: "POST",
|
|
132
|
+
path: "/v1/memories",
|
|
133
|
+
duration: 142,
|
|
134
|
+
status: "success",
|
|
135
|
+
});
|
|
136
|
+
logger.log({
|
|
137
|
+
timestamp: new Date().toISOString(),
|
|
138
|
+
tool: "memory_recall",
|
|
139
|
+
method: "POST",
|
|
140
|
+
path: "/v1/memories/search",
|
|
141
|
+
duration: 78,
|
|
142
|
+
status: "error",
|
|
143
|
+
error: "404",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const logs = logger.getErrorLogs(10);
|
|
147
|
+
expect(logs).toHaveLength(1);
|
|
148
|
+
expect(logs[0].status).toBe("error");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("getStats calculates correctly", () => {
|
|
152
|
+
logger.log({
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
tool: "memory_store",
|
|
155
|
+
method: "POST",
|
|
156
|
+
path: "/v1/memories",
|
|
157
|
+
duration: 100,
|
|
158
|
+
status: "success",
|
|
159
|
+
});
|
|
160
|
+
logger.log({
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
tool: "memory_recall",
|
|
163
|
+
method: "POST",
|
|
164
|
+
path: "/v1/memories/search",
|
|
165
|
+
duration: 200,
|
|
166
|
+
status: "success",
|
|
167
|
+
});
|
|
168
|
+
logger.log({
|
|
169
|
+
timestamp: new Date().toISOString(),
|
|
170
|
+
tool: "memory_store",
|
|
171
|
+
method: "POST",
|
|
172
|
+
path: "/v1/memories",
|
|
173
|
+
duration: 150,
|
|
174
|
+
status: "error",
|
|
175
|
+
error: "500",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const stats = logger.getStats();
|
|
179
|
+
expect(stats.total).toBe(3);
|
|
180
|
+
expect(stats.successful).toBe(2);
|
|
181
|
+
expect(stats.failed).toBe(1);
|
|
182
|
+
expect(stats.avgDuration).toBe(150); // (100+200+150)/3 = 150
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("clear() empties logs", () => {
|
|
186
|
+
logger.log({
|
|
187
|
+
timestamp: new Date().toISOString(),
|
|
188
|
+
tool: "memory_store",
|
|
189
|
+
method: "POST",
|
|
190
|
+
path: "/v1/memories",
|
|
191
|
+
duration: 142,
|
|
192
|
+
status: "success",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
logger.clear();
|
|
196
|
+
const logs = logger.getAllLogs();
|
|
197
|
+
expect(logs).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("formatEntry creates human-readable output", () => {
|
|
201
|
+
const entry: LogEntry = {
|
|
202
|
+
timestamp: new Date().toISOString(),
|
|
203
|
+
tool: "memory_store",
|
|
204
|
+
method: "POST",
|
|
205
|
+
path: "/v1/memories",
|
|
206
|
+
duration: 142,
|
|
207
|
+
status: "success",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const formatted = DebugLogger.formatEntry(entry);
|
|
211
|
+
expect(formatted).toContain("memory_store");
|
|
212
|
+
expect(formatted).toContain("142ms");
|
|
213
|
+
expect(formatted).toContain("✓");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("formatEntry shows error", () => {
|
|
217
|
+
const entry: LogEntry = {
|
|
218
|
+
timestamp: new Date().toISOString(),
|
|
219
|
+
tool: "memory_store",
|
|
220
|
+
method: "POST",
|
|
221
|
+
path: "/v1/memories",
|
|
222
|
+
duration: 156,
|
|
223
|
+
status: "error",
|
|
224
|
+
error: "500 Internal Server Error",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const formatted = DebugLogger.formatEntry(entry);
|
|
228
|
+
expect(formatted).toContain("memory_store");
|
|
229
|
+
expect(formatted).toContain("156ms");
|
|
230
|
+
expect(formatted).toContain("✗");
|
|
231
|
+
expect(formatted).toContain("500 Internal Server Error");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug Logger for MemoryRelay OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive logging of API calls with request/response capture
|
|
5
|
+
* for troubleshooting and performance analysis.
|
|
6
|
+
*
|
|
7
|
+
* Note: File logging has been removed in v0.8.4 to pass OpenClaw security validation.
|
|
8
|
+
* All logs are kept in-memory only. Use gateway methods (coming in v0.9.0) to access logs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface LogEntry {
|
|
12
|
+
timestamp: string;
|
|
13
|
+
tool: string;
|
|
14
|
+
method: string;
|
|
15
|
+
path: string;
|
|
16
|
+
duration: number;
|
|
17
|
+
status: "success" | "error";
|
|
18
|
+
requestBody?: unknown;
|
|
19
|
+
responseBody?: unknown;
|
|
20
|
+
responseStatus?: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
retries?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DebugLoggerConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
verbose: boolean;
|
|
28
|
+
maxEntries: number;
|
|
29
|
+
logFile?: string; // Deprecated: File logging removed for security compliance
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DebugLogger {
|
|
33
|
+
private logs: LogEntry[] = [];
|
|
34
|
+
private config: DebugLoggerConfig;
|
|
35
|
+
|
|
36
|
+
constructor(config: DebugLoggerConfig) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
|
|
39
|
+
// logFile is no longer supported (v0.8.4)
|
|
40
|
+
if (config.logFile) {
|
|
41
|
+
console.warn(
|
|
42
|
+
"memoryrelay: logFile is deprecated and ignored. " +
|
|
43
|
+
"Use gateway methods to access debug logs (coming in v0.9.0)"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Log an API call
|
|
50
|
+
*/
|
|
51
|
+
log(entry: LogEntry): void {
|
|
52
|
+
if (!this.config.enabled) return;
|
|
53
|
+
|
|
54
|
+
// Add to in-memory buffer
|
|
55
|
+
this.logs.push(entry);
|
|
56
|
+
|
|
57
|
+
// Trim if exceeds max
|
|
58
|
+
if (this.logs.length > this.config.maxEntries) {
|
|
59
|
+
this.logs.shift();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get recent logs
|
|
65
|
+
*/
|
|
66
|
+
getRecentLogs(limit: number = 10): LogEntry[] {
|
|
67
|
+
return this.logs.slice(-limit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get logs for specific tool
|
|
72
|
+
*/
|
|
73
|
+
getToolLogs(toolName: string, limit: number = 10): LogEntry[] {
|
|
74
|
+
return this.logs
|
|
75
|
+
.filter(log => log.tool === toolName)
|
|
76
|
+
.slice(-limit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get error logs only
|
|
81
|
+
*/
|
|
82
|
+
getErrorLogs(limit: number = 10): LogEntry[] {
|
|
83
|
+
return this.logs
|
|
84
|
+
.filter(log => log.status === "error")
|
|
85
|
+
.slice(-limit);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get all logs
|
|
90
|
+
*/
|
|
91
|
+
getAllLogs(): LogEntry[] {
|
|
92
|
+
return [...this.logs];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clear logs
|
|
97
|
+
*/
|
|
98
|
+
clear(): void {
|
|
99
|
+
this.logs = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get statistics
|
|
104
|
+
*/
|
|
105
|
+
getStats() {
|
|
106
|
+
const total = this.logs.length;
|
|
107
|
+
const successful = this.logs.filter(l => l.status === "success").length;
|
|
108
|
+
const failed = total - successful;
|
|
109
|
+
const avgDuration = total > 0
|
|
110
|
+
? this.logs.reduce((sum, l) => sum + l.duration, 0) / total
|
|
111
|
+
: 0;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
total,
|
|
115
|
+
successful,
|
|
116
|
+
failed,
|
|
117
|
+
successRate: total > 0 ? (successful / total) * 100 : 0,
|
|
118
|
+
avgDuration: Math.round(avgDuration),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Format log entry for display
|
|
124
|
+
*/
|
|
125
|
+
static formatEntry(entry: LogEntry): string {
|
|
126
|
+
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
|
|
127
|
+
const status = entry.status === "success" ? "✓" : "✗";
|
|
128
|
+
const duration = `${entry.duration}ms`;
|
|
129
|
+
|
|
130
|
+
let output = `${timestamp} ${entry.tool.padEnd(20)} ${duration.padStart(6)} ${status}`;
|
|
131
|
+
|
|
132
|
+
if (entry.error) {
|
|
133
|
+
output += `\n Error: ${entry.error}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (entry.retries && entry.retries > 0) {
|
|
137
|
+
output += ` (${entry.retries} retries)`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return output;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Format logs as table
|
|
145
|
+
*/
|
|
146
|
+
static formatTable(logs: LogEntry[]): string {
|
|
147
|
+
if (logs.length === 0) {
|
|
148
|
+
return "No logs available";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lines = [
|
|
152
|
+
"TIMESTAMP TOOL DURATION STATUS ERROR",
|
|
153
|
+
"━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━ ━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const entry of logs) {
|
|
157
|
+
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
|
|
158
|
+
const status = entry.status === "success" ? "✓" : "✗";
|
|
159
|
+
const duration = `${entry.duration}ms`;
|
|
160
|
+
const error = entry.error ? entry.error.substring(0, 30) : "";
|
|
161
|
+
|
|
162
|
+
lines.push(
|
|
163
|
+
`${timestamp} ${entry.tool.padEnd(20)} ${duration.padStart(8)} ${status.padEnd(6)} ${error}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily Memory Stats for Heartbeat (Phase 1 - Issue #10)
|
|
3
|
+
*
|
|
4
|
+
* Provides morning and evening memory stat summaries for agent heartbeat checks.
|
|
5
|
+
* Shows growth trends, categories, and most valuable memories.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface DailyStatsConfig {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
morningTime?: string; // HH:MM format (default: "09:00")
|
|
11
|
+
eveningTime?: string; // HH:MM format (default: "20:00")
|
|
12
|
+
timezone?: string; // IANA timezone (default: system timezone)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryStats {
|
|
16
|
+
total: number;
|
|
17
|
+
today: number;
|
|
18
|
+
thisWeek: number;
|
|
19
|
+
weeklyGrowth: number; // Percentage change from last week
|
|
20
|
+
topCategories: Array<{ category: string; count: number }>;
|
|
21
|
+
mostValuable?: {
|
|
22
|
+
id: string;
|
|
23
|
+
content: string;
|
|
24
|
+
recallCount: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeartbeatResult {
|
|
29
|
+
shouldNotify: boolean;
|
|
30
|
+
message?: string;
|
|
31
|
+
stats?: MemoryStats;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Calculate memory statistics for the current period
|
|
36
|
+
*/
|
|
37
|
+
export async function calculateStats(
|
|
38
|
+
getAllMemories: () => Promise<Array<{ id: string; content: string; metadata: Record<string, string>; created_at: number }>>,
|
|
39
|
+
getRecallCount: (memoryId: string) => number = () => 0
|
|
40
|
+
): Promise<MemoryStats> {
|
|
41
|
+
const memories = await getAllMemories();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const todayStart = new Date().setHours(0, 0, 0, 0);
|
|
44
|
+
const weekStart = now - 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const lastWeekStart = now - 14 * 24 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
// Count memories by period
|
|
48
|
+
const total = memories.length;
|
|
49
|
+
const today = memories.filter((m) => m.created_at >= todayStart).length;
|
|
50
|
+
const thisWeek = memories.filter((m) => m.created_at >= weekStart).length;
|
|
51
|
+
const lastWeek = memories.filter(
|
|
52
|
+
(m) => m.created_at >= lastWeekStart && m.created_at < weekStart
|
|
53
|
+
).length;
|
|
54
|
+
|
|
55
|
+
// Calculate weekly growth
|
|
56
|
+
const weeklyGrowth = lastWeek > 0 ? ((thisWeek - lastWeek) / lastWeek) * 100 : 0;
|
|
57
|
+
|
|
58
|
+
// Top categories
|
|
59
|
+
const categoryCount = new Map<string, number>();
|
|
60
|
+
for (const memory of memories) {
|
|
61
|
+
const category = memory.metadata.category || "uncategorized";
|
|
62
|
+
categoryCount.set(category, (categoryCount.get(category) || 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const topCategories = Array.from(categoryCount.entries())
|
|
66
|
+
.map(([category, count]) => ({ category, count }))
|
|
67
|
+
.sort((a, b) => b.count - a.count)
|
|
68
|
+
.slice(0, 5);
|
|
69
|
+
|
|
70
|
+
// Most valuable memory (by recall count - simulated for now)
|
|
71
|
+
let mostValuable: MemoryStats["mostValuable"];
|
|
72
|
+
if (memories.length > 0) {
|
|
73
|
+
const memoriesWithRecalls = memories.map((m) => ({
|
|
74
|
+
...m,
|
|
75
|
+
recallCount: getRecallCount(m.id),
|
|
76
|
+
}));
|
|
77
|
+
memoriesWithRecalls.sort((a, b) => b.recallCount - a.recallCount);
|
|
78
|
+
|
|
79
|
+
if (memoriesWithRecalls[0].recallCount > 0) {
|
|
80
|
+
mostValuable = {
|
|
81
|
+
id: memoriesWithRecalls[0].id,
|
|
82
|
+
content: memoriesWithRecalls[0].content.slice(0, 100) + "...",
|
|
83
|
+
recallCount: memoriesWithRecalls[0].recallCount,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
total,
|
|
90
|
+
today,
|
|
91
|
+
thisWeek,
|
|
92
|
+
weeklyGrowth,
|
|
93
|
+
topCategories,
|
|
94
|
+
mostValuable,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Morning check: Show memory stats and growth
|
|
100
|
+
*/
|
|
101
|
+
export async function morningCheck(
|
|
102
|
+
stats: MemoryStats
|
|
103
|
+
): Promise<HeartbeatResult> {
|
|
104
|
+
// Don't notify if no activity or very early in adoption
|
|
105
|
+
if (stats.total < 5 && stats.today === 0) {
|
|
106
|
+
return { shouldNotify: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build morning message
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
lines.push("📊 Morning Memory Check");
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push(`📚 Total memories: ${stats.total}`);
|
|
114
|
+
|
|
115
|
+
if (stats.today > 0) {
|
|
116
|
+
lines.push(`✨ Added today: ${stats.today}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (stats.thisWeek > 0) {
|
|
120
|
+
const growthIndicator = stats.weeklyGrowth > 0 ? "📈" : stats.weeklyGrowth < 0 ? "📉" : "➡️";
|
|
121
|
+
lines.push(`${growthIndicator} This week: ${stats.thisWeek} (${stats.weeklyGrowth > 0 ? '+' : ''}${stats.weeklyGrowth.toFixed(0)}%)`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (stats.topCategories.length > 0) {
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push("🏆 Top categories:");
|
|
127
|
+
for (const cat of stats.topCategories.slice(0, 3)) {
|
|
128
|
+
lines.push(` • ${cat.category}: ${cat.count}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
shouldNotify: true,
|
|
134
|
+
message: lines.join("\n"),
|
|
135
|
+
stats,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Evening review: Show today's activity and most valuable memory
|
|
141
|
+
*/
|
|
142
|
+
export async function eveningReview(
|
|
143
|
+
stats: MemoryStats
|
|
144
|
+
): Promise<HeartbeatResult> {
|
|
145
|
+
// Don't notify if no activity today
|
|
146
|
+
if (stats.today === 0 && !stats.mostValuable) {
|
|
147
|
+
return { shouldNotify: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
lines.push("🌙 Evening Memory Review");
|
|
152
|
+
lines.push("");
|
|
153
|
+
|
|
154
|
+
if (stats.today > 0) {
|
|
155
|
+
lines.push(`✅ Stored today: ${stats.today} memories`);
|
|
156
|
+
} else {
|
|
157
|
+
lines.push("📝 No new memories today");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (stats.mostValuable) {
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push("💎 Most valuable memory:");
|
|
163
|
+
lines.push(` "${stats.mostValuable.content}"`);
|
|
164
|
+
lines.push(` Recalled ${stats.mostValuable.recallCount} times`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
shouldNotify: stats.today > 0 || (stats.mostValuable !== undefined),
|
|
169
|
+
message: lines.join("\n"),
|
|
170
|
+
stats,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if it's time for a heartbeat notification
|
|
176
|
+
*/
|
|
177
|
+
export function shouldRunHeartbeat(
|
|
178
|
+
config: DailyStatsConfig,
|
|
179
|
+
currentTime: Date = new Date()
|
|
180
|
+
): "morning" | "evening" | null {
|
|
181
|
+
if (!config.enabled) return null;
|
|
182
|
+
|
|
183
|
+
const morningTime = config.morningTime || "09:00";
|
|
184
|
+
const eveningTime = config.eveningTime || "20:00";
|
|
185
|
+
|
|
186
|
+
const [morningHour, morningMin] = morningTime.split(":").map(Number);
|
|
187
|
+
const [eveningHour, eveningMin] = eveningTime.split(":").map(Number);
|
|
188
|
+
|
|
189
|
+
const currentHour = currentTime.getHours();
|
|
190
|
+
const currentMin = currentTime.getMinutes();
|
|
191
|
+
|
|
192
|
+
// Check if within 5-minute window of morning time
|
|
193
|
+
const morningStart = morningHour * 60 + morningMin;
|
|
194
|
+
const morningEnd = morningStart + 5;
|
|
195
|
+
const currentMinutes = currentHour * 60 + currentMin;
|
|
196
|
+
|
|
197
|
+
if (currentMinutes >= morningStart && currentMinutes < morningEnd) {
|
|
198
|
+
return "morning";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if within 5-minute window of evening time
|
|
202
|
+
const eveningStart = eveningHour * 60 + eveningMin;
|
|
203
|
+
const eveningEnd = eveningStart + 5;
|
|
204
|
+
|
|
205
|
+
if (currentMinutes >= eveningStart && currentMinutes < eveningEnd) {
|
|
206
|
+
return "evening";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Format stats for console/log output
|
|
214
|
+
*/
|
|
215
|
+
export function formatStatsForDisplay(stats: MemoryStats): string {
|
|
216
|
+
const lines: string[] = [];
|
|
217
|
+
lines.push(`Total: ${stats.total} | Today: ${stats.today} | Week: ${stats.thisWeek}`);
|
|
218
|
+
|
|
219
|
+
if (stats.weeklyGrowth !== 0) {
|
|
220
|
+
const sign = stats.weeklyGrowth > 0 ? "+" : "";
|
|
221
|
+
lines.push(`Growth: ${sign}${stats.weeklyGrowth.toFixed(0)}%`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (stats.topCategories.length > 0) {
|
|
225
|
+
const topCats = stats.topCategories
|
|
226
|
+
.slice(0, 3)
|
|
227
|
+
.map((c) => `${c.category}(${c.count})`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
lines.push(`Top: ${topCats}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return lines.join(" | ");
|
|
233
|
+
}
|