clawmem 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/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- package/src/watcher.ts +58 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Bootstrap Hook - SessionStart
|
|
3
|
+
*
|
|
4
|
+
* Fires at the start of each Claude Code session. Surfaces:
|
|
5
|
+
* 0. User profile (highest priority, ~800 tokens)
|
|
6
|
+
* 1. Latest handoff note (~600 tokens)
|
|
7
|
+
* 2. Recent decisions (~400 tokens)
|
|
8
|
+
* 3. Stale notes reminder (~200 tokens)
|
|
9
|
+
*
|
|
10
|
+
* Total budget: ~2000 tokens (~8000 chars).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Store } from "../store.ts";
|
|
14
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
15
|
+
import {
|
|
16
|
+
makeEmptyOutput,
|
|
17
|
+
smartTruncate,
|
|
18
|
+
estimateTokens,
|
|
19
|
+
logInjection,
|
|
20
|
+
} from "../hooks.ts";
|
|
21
|
+
import { sanitizeSnippet } from "../promptguard.ts";
|
|
22
|
+
import { getProfile } from "../profile.ts";
|
|
23
|
+
import { hostname } from "os";
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Config
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
const TOTAL_TOKEN_BUDGET = 2000;
|
|
30
|
+
const PROFILE_TOKEN_BUDGET = 800;
|
|
31
|
+
const HANDOFF_TOKEN_BUDGET = 600;
|
|
32
|
+
const DECISION_TOKEN_BUDGET = 400;
|
|
33
|
+
const STALE_TOKEN_BUDGET = 200;
|
|
34
|
+
const DECISION_LOOKBACK_DAYS = 7;
|
|
35
|
+
const STALE_LOOKBACK_DAYS = 30;
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Handler
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export async function sessionBootstrap(
|
|
42
|
+
store: Store,
|
|
43
|
+
input: HookInput
|
|
44
|
+
): Promise<HookOutput> {
|
|
45
|
+
const sessionId = input.sessionId || `session-${Date.now()}`;
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
|
|
48
|
+
// Register the session
|
|
49
|
+
try {
|
|
50
|
+
store.insertSession(sessionId, now, hostname());
|
|
51
|
+
} catch {
|
|
52
|
+
// Session may already exist (duplicate hook fire)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sections: string[] = [];
|
|
56
|
+
const paths: string[] = [];
|
|
57
|
+
let totalTokens = 0;
|
|
58
|
+
|
|
59
|
+
// 0. User profile (highest priority)
|
|
60
|
+
const profileSection = getProfileSection(store, PROFILE_TOKEN_BUDGET);
|
|
61
|
+
if (profileSection) {
|
|
62
|
+
const tokens = estimateTokens(profileSection.text);
|
|
63
|
+
if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
|
|
64
|
+
sections.push(profileSection.text);
|
|
65
|
+
paths.push(...profileSection.paths);
|
|
66
|
+
totalTokens += tokens;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 1. Latest handoff
|
|
71
|
+
const handoffSection = getLatestHandoff(store, HANDOFF_TOKEN_BUDGET);
|
|
72
|
+
if (handoffSection) {
|
|
73
|
+
const tokens = estimateTokens(handoffSection.text);
|
|
74
|
+
if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
|
|
75
|
+
sections.push(handoffSection.text);
|
|
76
|
+
paths.push(...handoffSection.paths);
|
|
77
|
+
totalTokens += tokens;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Recent decisions
|
|
82
|
+
const decisionSection = getRecentDecisions(store, DECISION_TOKEN_BUDGET);
|
|
83
|
+
if (decisionSection) {
|
|
84
|
+
const tokens = estimateTokens(decisionSection.text);
|
|
85
|
+
if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
|
|
86
|
+
sections.push(decisionSection.text);
|
|
87
|
+
paths.push(...decisionSection.paths);
|
|
88
|
+
totalTokens += tokens;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Stale notes reminder
|
|
93
|
+
const staleSection = getStaleNotes(store, STALE_TOKEN_BUDGET);
|
|
94
|
+
if (staleSection) {
|
|
95
|
+
const tokens = estimateTokens(staleSection.text);
|
|
96
|
+
if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
|
|
97
|
+
sections.push(staleSection.text);
|
|
98
|
+
paths.push(...staleSection.paths);
|
|
99
|
+
totalTokens += tokens;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (sections.length === 0) return makeEmptyOutput("session-bootstrap");
|
|
104
|
+
|
|
105
|
+
// Log the injection
|
|
106
|
+
logInjection(store, sessionId, "session-bootstrap", paths, totalTokens);
|
|
107
|
+
|
|
108
|
+
// C4b fix: makeContextOutput drops content for SessionStart hooks (null in HOOK_EVENT_MAP).
|
|
109
|
+
// Return direct output with io6-bootstrap event name to ensure content reaches cmdSurface.
|
|
110
|
+
const sessionContext = `<vault-session>\n${sections.join("\n\n---\n\n")}\n</vault-session>`;
|
|
111
|
+
return {
|
|
112
|
+
continue: true,
|
|
113
|
+
suppressOutput: false,
|
|
114
|
+
hookSpecificOutput: {
|
|
115
|
+
hookEventName: "io6-bootstrap",
|
|
116
|
+
additionalContext: sessionContext,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// Section Builders
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
function getProfileSection(
|
|
126
|
+
store: Store,
|
|
127
|
+
maxTokens: number
|
|
128
|
+
): { text: string; paths: string[] } | null {
|
|
129
|
+
const profile = getProfile(store);
|
|
130
|
+
if (!profile) return null;
|
|
131
|
+
if (profile.static.length === 0 && profile.dynamic.length === 0) return null;
|
|
132
|
+
|
|
133
|
+
const maxChars = maxTokens * 4;
|
|
134
|
+
const lines: string[] = ["### User Profile"];
|
|
135
|
+
let charCount = 20;
|
|
136
|
+
|
|
137
|
+
if (profile.static.length > 0) {
|
|
138
|
+
lines.push("**Known Context:**");
|
|
139
|
+
charCount += 20;
|
|
140
|
+
for (const fact of profile.static) {
|
|
141
|
+
if (charCount + fact.length + 4 > maxChars) break;
|
|
142
|
+
lines.push(`- ${fact}`);
|
|
143
|
+
charCount += fact.length + 4;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (profile.dynamic.length > 0) {
|
|
148
|
+
lines.push("", "**Current Focus:**");
|
|
149
|
+
charCount += 22;
|
|
150
|
+
for (const item of profile.dynamic) {
|
|
151
|
+
if (charCount + item.length + 4 > maxChars) break;
|
|
152
|
+
lines.push(`- ${item}`);
|
|
153
|
+
charCount += item.length + 4;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { text: lines.join("\n"), paths: ["_clawmem/profile.md"] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getLatestHandoff(
|
|
161
|
+
store: Store,
|
|
162
|
+
maxTokens: number
|
|
163
|
+
): { text: string; paths: string[] } | null {
|
|
164
|
+
// Get most recent session with a handoff
|
|
165
|
+
const sessions = store.getRecentSessions(5);
|
|
166
|
+
const withHandoff = sessions.find(s => s.handoffPath);
|
|
167
|
+
if (!withHandoff?.handoffPath) {
|
|
168
|
+
// Fall back: get most recent handoff-type document
|
|
169
|
+
const handoffs = store.getDocumentsByType("handoff", 1);
|
|
170
|
+
if (handoffs.length === 0) return null;
|
|
171
|
+
|
|
172
|
+
const doc = handoffs[0]!;
|
|
173
|
+
const body = store.getDocumentBody({ filepath: `${doc.collection}/${doc.path}`, displayPath: `${doc.collection}/${doc.path}` } as any);
|
|
174
|
+
if (!body) return null;
|
|
175
|
+
|
|
176
|
+
const text = formatHandoff(doc.title, body, maxTokens);
|
|
177
|
+
return { text, paths: [`${doc.collection}/${doc.path}`] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Try to read the handoff note from the DB
|
|
181
|
+
const handoffPath = withHandoff.handoffPath;
|
|
182
|
+
const parts = handoffPath.split("/");
|
|
183
|
+
if (parts.length >= 2) {
|
|
184
|
+
const collection = parts[0]!;
|
|
185
|
+
const path = parts.slice(1).join("/");
|
|
186
|
+
const docInfo = store.findActiveDocument(collection, path);
|
|
187
|
+
if (docInfo) {
|
|
188
|
+
const body = store.getDocumentBody({ filepath: handoffPath, displayPath: handoffPath } as any);
|
|
189
|
+
if (body) {
|
|
190
|
+
const text = formatHandoff(docInfo.title, body, maxTokens);
|
|
191
|
+
return { text, paths: [handoffPath] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Fall back to session summary
|
|
197
|
+
if (withHandoff.summary) {
|
|
198
|
+
const text = `### Last Session\n${smartTruncate(withHandoff.summary, maxTokens * 4)}`;
|
|
199
|
+
return { text, paths: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatHandoff(title: string, body: string, maxTokens: number): string {
|
|
206
|
+
// Prompt injection guard
|
|
207
|
+
body = sanitizeSnippet(body);
|
|
208
|
+
if (body === "[content filtered for security]") return "";
|
|
209
|
+
const maxChars = maxTokens * 4;
|
|
210
|
+
|
|
211
|
+
// Extract key sections in priority order
|
|
212
|
+
const prioritySections = [
|
|
213
|
+
"Next Session Should",
|
|
214
|
+
"Next Session",
|
|
215
|
+
"Next Steps",
|
|
216
|
+
"Current State",
|
|
217
|
+
"Request",
|
|
218
|
+
"What Was Done",
|
|
219
|
+
"What Was Learned",
|
|
220
|
+
"What Was Investigated",
|
|
221
|
+
"Accomplishments",
|
|
222
|
+
"Decisions Made",
|
|
223
|
+
"Files Changed",
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const extracted: string[] = [];
|
|
227
|
+
let remaining = maxChars;
|
|
228
|
+
|
|
229
|
+
for (const sectionName of prioritySections) {
|
|
230
|
+
if (remaining <= 100) break;
|
|
231
|
+
const section = extractSection(body, sectionName);
|
|
232
|
+
if (section) {
|
|
233
|
+
const truncated = smartTruncate(section, remaining);
|
|
234
|
+
extracted.push(truncated);
|
|
235
|
+
remaining -= truncated.length;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (extracted.length > 0) {
|
|
240
|
+
return `### Last Handoff: ${title}\n${extracted.join("\n\n")}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// No structured sections, use raw content
|
|
244
|
+
return `### Last Handoff: ${title}\n${smartTruncate(body, maxChars)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractSection(body: string, sectionName: string): string | null {
|
|
248
|
+
const regex = new RegExp(`^#{1,3}\\s+${escapeRegex(sectionName)}\\b[^\n]*\n([\\s\\S]*?)(?=^#{1,3}\\s|$)`, "mi");
|
|
249
|
+
const match = body.match(regex);
|
|
250
|
+
if (!match?.[1]) return null;
|
|
251
|
+
const text = match[1].trim();
|
|
252
|
+
return text.length > 10 ? `**${sectionName}:**\n${text}` : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getRecentDecisions(
|
|
256
|
+
store: Store,
|
|
257
|
+
maxTokens: number
|
|
258
|
+
): { text: string; paths: string[] } | null {
|
|
259
|
+
const decisions = store.getDocumentsByType("decision", 5);
|
|
260
|
+
if (decisions.length === 0) return null;
|
|
261
|
+
|
|
262
|
+
const cutoff = new Date();
|
|
263
|
+
cutoff.setDate(cutoff.getDate() - DECISION_LOOKBACK_DAYS);
|
|
264
|
+
const cutoffStr = cutoff.toISOString();
|
|
265
|
+
|
|
266
|
+
// Filter to recent decisions
|
|
267
|
+
const recent = decisions.filter(d => d.modifiedAt >= cutoffStr);
|
|
268
|
+
if (recent.length === 0) return null;
|
|
269
|
+
|
|
270
|
+
const maxChars = maxTokens * 4;
|
|
271
|
+
const lines: string[] = ["### Recent Decisions"];
|
|
272
|
+
const paths: string[] = [];
|
|
273
|
+
let charCount = 25; // header
|
|
274
|
+
|
|
275
|
+
for (const d of recent) {
|
|
276
|
+
if (charCount >= maxChars) break;
|
|
277
|
+
let body = store.getDocumentBody({ filepath: `${d.collection}/${d.path}`, displayPath: `${d.collection}/${d.path}` } as any);
|
|
278
|
+
if (body) body = sanitizeSnippet(body);
|
|
279
|
+
if (body === "[content filtered for security]") continue;
|
|
280
|
+
const snippet = body ? smartTruncate(body, 200) : d.title;
|
|
281
|
+
const entry = `- **${d.title}** (${d.modifiedAt.slice(0, 10)})\n ${snippet}`;
|
|
282
|
+
const entryLen = entry.length;
|
|
283
|
+
if (charCount + entryLen > maxChars && lines.length > 1) break;
|
|
284
|
+
lines.push(entry);
|
|
285
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
286
|
+
charCount += entryLen;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getStaleNotes(
|
|
293
|
+
store: Store,
|
|
294
|
+
maxTokens: number
|
|
295
|
+
): { text: string; paths: string[] } | null {
|
|
296
|
+
const cutoff = new Date();
|
|
297
|
+
cutoff.setDate(cutoff.getDate() - STALE_LOOKBACK_DAYS);
|
|
298
|
+
const stale = store.getStaleDocuments(cutoff.toISOString());
|
|
299
|
+
|
|
300
|
+
if (stale.length === 0) return null;
|
|
301
|
+
|
|
302
|
+
const maxChars = maxTokens * 4;
|
|
303
|
+
const lines: string[] = ["### Notes to Review"];
|
|
304
|
+
const paths: string[] = [];
|
|
305
|
+
let charCount = 25;
|
|
306
|
+
|
|
307
|
+
for (const d of stale.slice(0, 5)) {
|
|
308
|
+
const entry = `- ${d.title} (${d.collection}/${d.path}) — last modified ${d.modifiedAt.slice(0, 10)}`;
|
|
309
|
+
if (charCount + entry.length > maxChars && lines.length > 1) break;
|
|
310
|
+
lines.push(entry);
|
|
311
|
+
paths.push(`${d.collection}/${d.path}`);
|
|
312
|
+
charCount += entry.length;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// =============================================================================
|
|
319
|
+
// Utilities
|
|
320
|
+
// =============================================================================
|
|
321
|
+
|
|
322
|
+
function escapeRegex(str: string): string {
|
|
323
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
324
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness Check Hook - SessionStart
|
|
3
|
+
*
|
|
4
|
+
* Fires at session start. Finds documents that have a review_by date
|
|
5
|
+
* in the past and haven't been updated recently. Injects a gentle
|
|
6
|
+
* reminder to review them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Store } from "../store.ts";
|
|
10
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
11
|
+
import {
|
|
12
|
+
makeContextOutput,
|
|
13
|
+
makeEmptyOutput,
|
|
14
|
+
estimateTokens,
|
|
15
|
+
logInjection,
|
|
16
|
+
} from "../hooks.ts";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Config
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
const MAX_STALE_NOTES = 5;
|
|
23
|
+
const MAX_TOKEN_BUDGET = 250;
|
|
24
|
+
const STALE_DAYS = 30;
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Handler
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
export async function stalenessCheck(
|
|
31
|
+
store: Store,
|
|
32
|
+
input: HookInput
|
|
33
|
+
): Promise<HookOutput> {
|
|
34
|
+
const now = new Date();
|
|
35
|
+
|
|
36
|
+
// Find documents with review_by in the past
|
|
37
|
+
const reviewDue = findReviewDue(store, now);
|
|
38
|
+
|
|
39
|
+
// Find documents not modified in STALE_DAYS
|
|
40
|
+
const stale = findStaleByAge(store, now);
|
|
41
|
+
|
|
42
|
+
// Merge and deduplicate
|
|
43
|
+
const allStale = new Map<string, { title: string; path: string; reason: string }>();
|
|
44
|
+
|
|
45
|
+
for (const d of reviewDue) {
|
|
46
|
+
allStale.set(d.path, d);
|
|
47
|
+
}
|
|
48
|
+
for (const d of stale) {
|
|
49
|
+
if (!allStale.has(d.path)) {
|
|
50
|
+
allStale.set(d.path, d);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (allStale.size === 0) return makeEmptyOutput("staleness-check");
|
|
55
|
+
|
|
56
|
+
// Build context within budget
|
|
57
|
+
const entries = [...allStale.values()].slice(0, MAX_STALE_NOTES);
|
|
58
|
+
const lines = ["**Notes needing review:**"];
|
|
59
|
+
const paths: string[] = [];
|
|
60
|
+
let tokens = estimateTokens(lines[0]!);
|
|
61
|
+
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const line = `- ${entry.title} (${entry.path}) — ${entry.reason}`;
|
|
64
|
+
const lineTokens = estimateTokens(line);
|
|
65
|
+
if (tokens + lineTokens > MAX_TOKEN_BUDGET && lines.length > 1) break;
|
|
66
|
+
lines.push(line);
|
|
67
|
+
paths.push(entry.path);
|
|
68
|
+
tokens += lineTokens;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (lines.length <= 1) return makeEmptyOutput("staleness-check");
|
|
72
|
+
|
|
73
|
+
// Log injection
|
|
74
|
+
if (input.sessionId) {
|
|
75
|
+
logInjection(store, input.sessionId, "staleness-check", paths, tokens);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return makeContextOutput(
|
|
79
|
+
"staleness-check",
|
|
80
|
+
`<vault-staleness>\n${lines.join("\n")}\n</vault-staleness>`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Finders
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
function findReviewDue(
|
|
89
|
+
store: Store,
|
|
90
|
+
now: Date
|
|
91
|
+
): { title: string; path: string; reason: string }[] {
|
|
92
|
+
const nowStr = now.toISOString();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const rows = store.db.prepare(`
|
|
96
|
+
SELECT collection, path, title, review_by
|
|
97
|
+
FROM documents
|
|
98
|
+
WHERE active = 1
|
|
99
|
+
AND review_by IS NOT NULL
|
|
100
|
+
AND review_by != ''
|
|
101
|
+
AND review_by <= ?
|
|
102
|
+
ORDER BY review_by ASC
|
|
103
|
+
LIMIT ?
|
|
104
|
+
`).all(nowStr, MAX_STALE_NOTES) as { collection: string; path: string; title: string; review_by: string }[];
|
|
105
|
+
|
|
106
|
+
return rows.map(r => ({
|
|
107
|
+
title: r.title,
|
|
108
|
+
path: `${r.collection}/${r.path}`,
|
|
109
|
+
reason: `review due ${r.review_by.slice(0, 10)}`,
|
|
110
|
+
}));
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findStaleByAge(
|
|
117
|
+
store: Store,
|
|
118
|
+
now: Date
|
|
119
|
+
): { title: string; path: string; reason: string }[] {
|
|
120
|
+
const cutoff = new Date(now);
|
|
121
|
+
cutoff.setDate(cutoff.getDate() - STALE_DAYS);
|
|
122
|
+
|
|
123
|
+
const stale = store.getStaleDocuments(cutoff.toISOString());
|
|
124
|
+
|
|
125
|
+
return stale.slice(0, MAX_STALE_NOTES).map(d => ({
|
|
126
|
+
title: d.title,
|
|
127
|
+
path: `${d.collection}/${d.path}`,
|
|
128
|
+
reason: `not modified since ${d.modifiedAt.slice(0, 10)}`,
|
|
129
|
+
}));
|
|
130
|
+
}
|