lazyclaw 4.2.2 → 4.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.
@@ -0,0 +1,323 @@
1
+ // Skill lifecycle curator — usage tracking, freshness classification and
2
+ // recoverable archival layered over the <configDir>/skills/ store from
3
+ // skills.mjs.
4
+ //
5
+ // Skills accrete: agents synthesise new ones on the fly (created_by:
6
+ // agent) and most are never touched again. Left unchecked the recall
7
+ // index (skillsIndex) bloats the system prompt with dead weight. This
8
+ // module ages skills out: it counts how often each is recalled, marks
9
+ // them active / stale / archived by idle time, and physically moves the
10
+ // long-idle *agent-authored* skills into <configDir>/skills/.archive/
11
+ // where they no longer appear in listSkills but remain recoverable.
12
+ //
13
+ // Human-authored skills are NEVER archived — they're curated by people,
14
+ // not garbage to be collected.
15
+ //
16
+ // Determinism: every time-dependent function takes an injected `now`
17
+ // (epoch-ms). The core logic never reads the wall-clock, so the 30d /
18
+ // 90d boundaries are exactly reproducible in tests.
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ import {
24
+ defaultConfigDir,
25
+ skillsDir,
26
+ skillPath,
27
+ listSkills,
28
+ parseFrontmatter,
29
+ } from './skills.mjs';
30
+
31
+ // Freshness windows, measured in idle time since lastUsedAt.
32
+ // active : used within the last 30 days
33
+ // stale : 30..90 days idle
34
+ // archived : 90+ days idle (and, for curate(), agent-authored)
35
+ const DAY_MS = 24 * 60 * 60 * 1000;
36
+ export const STALE_MS = 30 * DAY_MS;
37
+ export const ARCHIVE_MS = 90 * DAY_MS;
38
+
39
+ const USAGE_FILENAME = '.usage.json';
40
+ const ARCHIVE_DIRNAME = '.archive';
41
+ const SKILL_EXT = '.md';
42
+
43
+ // Guard for the injected clock. The whole module is deterministic only
44
+ // because `now` is supplied by the caller; a NaN / Infinity / non-number
45
+ // would make every idle comparison false and silently classify all
46
+ // skills as 'archived', so we refuse it loudly instead.
47
+ function assertFiniteNow(now) {
48
+ if (typeof now !== 'number' || !Number.isFinite(now)) {
49
+ throw new TypeError(`now must be a finite number (got ${String(now)})`);
50
+ }
51
+ return now;
52
+ }
53
+
54
+ // Validate a skill identifier used as a key into the usage store.
55
+ // Reserved prototype keys ('__proto__' etc.) are NOT rejected here:
56
+ // the store is a null-prototype object, so they round-trip safely as
57
+ // ordinary own properties. We only insist on a non-empty string so a
58
+ // number / undefined / object can never become a phantom key.
59
+ function assertSkillKey(name) {
60
+ if (typeof name !== 'string' || name.length === 0) {
61
+ throw new TypeError(`skill name must be a non-empty string (got ${String(name)})`);
62
+ }
63
+ return name;
64
+ }
65
+
66
+ function usagePath(configDir = defaultConfigDir()) {
67
+ return path.join(skillsDir(configDir), USAGE_FILENAME);
68
+ }
69
+
70
+ function archiveDir(configDir = defaultConfigDir()) {
71
+ return path.join(skillsDir(configDir), ARCHIVE_DIRNAME);
72
+ }
73
+
74
+ // Read the whole usage map ({ <name>: { views, uses, lastUsedAt } }).
75
+ // A missing or corrupt file is treated as empty so a single bad write
76
+ // never bricks the tracker.
77
+ //
78
+ // The returned map is a null-prototype object, and only OWN enumerable
79
+ // keys of the parsed JSON are copied in. This is a hard prototype-
80
+ // pollution boundary: a hostile usage file carrying a `__proto__` /
81
+ // `constructor` payload can neither poison Object.prototype nor leak an
82
+ // inherited counter into an unrelated skill's read.
83
+ function readUsageStore(configDir = defaultConfigDir()) {
84
+ const p = usagePath(configDir);
85
+ const store = Object.create(null);
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
89
+ } catch {
90
+ return store;
91
+ }
92
+ if (!parsed || typeof parsed !== 'object') return store;
93
+ for (const key of Object.keys(parsed)) {
94
+ // JSON.parse already refuses to set a real `__proto__` accessor, but
95
+ // a literal "__proto__" string key still arrives as an own property;
96
+ // copy it onto the null-prototype map as plain data.
97
+ Object.defineProperty(store, key, {
98
+ value: parsed[key],
99
+ writable: true,
100
+ enumerable: true,
101
+ configurable: true,
102
+ });
103
+ }
104
+ return store;
105
+ }
106
+
107
+ // Atomic write: serialise to a sibling temp file then rename over the
108
+ // target so a crash mid-write can never leave a half-written store.
109
+ function writeUsageStore(store, configDir = defaultConfigDir()) {
110
+ const dir = skillsDir(configDir);
111
+ fs.mkdirSync(dir, { recursive: true });
112
+ const p = usagePath(configDir);
113
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
114
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
115
+ fs.renameSync(tmp, p);
116
+ }
117
+
118
+ // Normalise a raw record into the full counter shape with sane zeros so
119
+ // callers never have to null-check individual fields.
120
+ function normalizeRecord(raw) {
121
+ const r = raw && typeof raw === 'object' ? raw : {};
122
+ return {
123
+ views: Number.isFinite(r.views) ? r.views : 0,
124
+ uses: Number.isFinite(r.uses) ? r.uses : 0,
125
+ lastUsedAt: Number.isFinite(r.lastUsedAt) ? r.lastUsedAt : 0,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Bump a skill's usage counters and stamp lastUsedAt with the injected
131
+ * `now`. Both `views` and `uses` increment on each call — they're kept
132
+ * separate so a future "viewed the index but didn't pull the body"
133
+ * signal can diverge from a real recall without a schema change.
134
+ *
135
+ * @param {string} name
136
+ * @param {string} [configDir]
137
+ * @param {number} now epoch-ms, injected (never read from the clock)
138
+ * @returns {{views:number, uses:number, lastUsedAt:number}}
139
+ */
140
+ export function recordUsage(name, configDir = defaultConfigDir(), now) {
141
+ assertFiniteNow(now);
142
+ const key = assertSkillKey(name);
143
+ const store = readUsageStore(configDir);
144
+ // store is null-prototype, so store[key] never reaches the prototype
145
+ // chain even for reserved names like '__proto__'; Object.hasOwn keeps
146
+ // reads explicit regardless.
147
+ const rec = normalizeRecord(Object.hasOwn(store, key) ? store[key] : undefined);
148
+ rec.views += 1;
149
+ rec.uses += 1;
150
+ rec.lastUsedAt = now;
151
+ store[key] = rec;
152
+ writeUsageStore(store, configDir);
153
+ return rec;
154
+ }
155
+
156
+ /**
157
+ * Read a skill's counters. Returns a zeroed record (never throws) when
158
+ * the skill has no recorded usage yet.
159
+ *
160
+ * @param {string} name
161
+ * @param {string} [configDir]
162
+ * @returns {{views:number, uses:number, lastUsedAt:number}}
163
+ */
164
+ export function usageOf(name, configDir = defaultConfigDir()) {
165
+ const key = assertSkillKey(name);
166
+ const store = readUsageStore(configDir);
167
+ // Object.hasOwn guards against pulling an inherited value out of a
168
+ // hostile usage file; the store is null-prototype anyway, but this
169
+ // keeps the read intent explicit and prototype-safe.
170
+ return normalizeRecord(Object.hasOwn(store, key) ? store[key] : undefined);
171
+ }
172
+
173
+ /**
174
+ * Classify a skill by how long it has been idle relative to the
175
+ * injected `now`. A skill that has never been used (lastUsedAt 0) is
176
+ * treated as maximally idle, so at any real epoch it classifies as
177
+ * 'archived'.
178
+ *
179
+ * idle < 30d → 'active'
180
+ * 30d <= idle < 90d → 'stale'
181
+ * idle >= 90d → 'archived'
182
+ *
183
+ * @param {string} skillName
184
+ * @param {string} [configDir]
185
+ * @param {number} now epoch-ms, injected
186
+ * @returns {'active'|'stale'|'archived'}
187
+ */
188
+ export function classify(skillName, configDir = defaultConfigDir(), now) {
189
+ assertFiniteNow(now);
190
+ const { lastUsedAt } = usageOf(skillName, configDir);
191
+ const idle = now - lastUsedAt;
192
+ if (idle < STALE_MS) return 'active';
193
+ if (idle < ARCHIVE_MS) return 'stale';
194
+ return 'archived';
195
+ }
196
+
197
+ // Read a skill's created_by frontmatter without surfacing read errors —
198
+ // an unreadable skill is treated as having no author, which keeps it out
199
+ // of the agent-only archival path (we never auto-archive what we can't
200
+ // positively identify as agent-authored).
201
+ function createdByOf(name, configDir) {
202
+ try {
203
+ const { meta } = parseFrontmatter(fs.readFileSync(skillPath(name, configDir), 'utf8'));
204
+ return meta.created_by || '';
205
+ } catch {
206
+ return '';
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Sweep the live skills store and age skills out by freshness. Skills
212
+ * that have been idle for 90+ days AND were agent-authored
213
+ * (created_by: agent) are physically moved into
214
+ * <configDir>/skills/.archive/<name>.md — recoverable, but no longer
215
+ * surfaced by listSkills/skillsIndex. Human-authored skills are never
216
+ * touched, even when long idle (they're reported under `stale` so the
217
+ * caller can still see them).
218
+ *
219
+ * Each skill's body runs in its own try/catch so a single malformed
220
+ * entry (e.g. a leading-dot name that skillPath rejects) is skipped and
221
+ * collected under `invalid` rather than aborting the whole sweep.
222
+ *
223
+ * @param {string} [configDir]
224
+ * @param {number} now epoch-ms, injected
225
+ * @returns {{archived:string[], stale:string[], active:string[], invalid:string[]}}
226
+ */
227
+ export function curate(configDir = defaultConfigDir(), now) {
228
+ assertFiniteNow(now);
229
+ const result = { archived: [], stale: [], active: [], invalid: [] };
230
+
231
+ // listSkills already filters to top-level .md files, so the archive
232
+ // subdir is naturally excluded from this sweep.
233
+ for (const skill of listSkills(configDir)) {
234
+ try {
235
+ const bucket = classify(skill.name, configDir, now);
236
+ if (bucket === 'active') {
237
+ result.active.push(skill.name);
238
+ continue;
239
+ }
240
+ if (bucket === 'stale') {
241
+ result.stale.push(skill.name);
242
+ continue;
243
+ }
244
+ // bucket === 'archived': only physically archive agent-authored
245
+ // skills. Human-authored idle skills are reported as stale instead
246
+ // of being moved — people curate those, we don't.
247
+ const createdBy = skill.createdBy || createdByOf(skill.name, configDir);
248
+ if (createdBy !== 'agent') {
249
+ result.stale.push(skill.name);
250
+ continue;
251
+ }
252
+ moveToArchive(skill.name, configDir, now);
253
+ // Drop the stale usage record so a later same-name skill starts
254
+ // from a clean slate instead of inheriting this lastUsedAt (which
255
+ // would get it re-archived before its first real use).
256
+ dropUsageRecord(skill.name, configDir);
257
+ result.archived.push(skill.name);
258
+ } catch {
259
+ // A single bad skill (invalid name, unreadable file, failed move)
260
+ // must never abort curation of the rest. Collect it and move on.
261
+ result.invalid.push(skill.name);
262
+ }
263
+ }
264
+
265
+ return result;
266
+ }
267
+
268
+ // Move a single skill file from the live store into .archive/, creating
269
+ // the archive dir on demand. rename is atomic on the same filesystem;
270
+ // fall back to copy+unlink for cross-device stores (e.g. tmpfs vs disk).
271
+ //
272
+ // On a destination collision (a prior archived copy of the same name)
273
+ // the new copy is disambiguated as <name>.<archivedAtMs>.md so the
274
+ // earlier, recoverable archive is never clobbered.
275
+ function moveToArchive(name, configDir, archivedAtMs) {
276
+ const src = skillPath(name, configDir);
277
+ const destDir = archiveDir(configDir);
278
+ fs.mkdirSync(destDir, { recursive: true });
279
+ const dest = pickArchiveDest(destDir, name, archivedAtMs);
280
+ try {
281
+ fs.renameSync(src, dest);
282
+ } catch (err) {
283
+ if (err && err.code === 'EXDEV') {
284
+ fs.copyFileSync(src, dest);
285
+ fs.unlinkSync(src);
286
+ } else {
287
+ throw err;
288
+ }
289
+ }
290
+ return dest;
291
+ }
292
+
293
+ // Choose a non-colliding archive destination. The preferred path is
294
+ // <name>.md; if that already holds a prior archive, fall back to
295
+ // <name>.<archivedAtMs>.md, and if even that is taken (same name
296
+ // archived twice at the same instant) append an incrementing suffix.
297
+ function pickArchiveDest(destDir, name, archivedAtMs) {
298
+ const preferred = path.join(destDir, `${name}${SKILL_EXT}`);
299
+ if (!fs.existsSync(preferred)) return preferred;
300
+ const stampBase = `${name}.${archivedAtMs}`;
301
+ let candidate = path.join(destDir, `${stampBase}${SKILL_EXT}`);
302
+ for (let i = 1; fs.existsSync(candidate) && i < 1000; i++) {
303
+ candidate = path.join(destDir, `${stampBase}.${i}${SKILL_EXT}`);
304
+ }
305
+ // Never return a path that still exists — clobbering a prior archived
306
+ // copy would break the "recoverable" guarantee. curate() surfaces this
307
+ // skill under `invalid` instead.
308
+ if (fs.existsSync(candidate)) {
309
+ throw new Error(`archive destination exhausted for "${name}"`);
310
+ }
311
+ return candidate;
312
+ }
313
+
314
+ // Remove a skill's usage record (used after archival so a re-created
315
+ // same-name skill does not inherit a stale lastUsedAt). No-op when the
316
+ // skill has no recorded usage.
317
+ function dropUsageRecord(name, configDir) {
318
+ const key = assertSkillKey(name);
319
+ const store = readUsageStore(configDir);
320
+ if (!Object.hasOwn(store, key)) return;
321
+ delete store[key];
322
+ writeUsageStore(store, configDir);
323
+ }
package/workspace.mjs CHANGED
@@ -22,9 +22,10 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
24
  const FILES = [
25
- { name: 'AGENTS.md', heading: 'AGENTS — what to do' },
26
- { name: 'SOUL.md', heading: 'SOUL — how to behave' },
27
- { name: 'TOOLS.md', heading: 'TOOLS — what is available' },
25
+ { name: 'AGENTS.md', heading: 'AGENTS — what to do' },
26
+ { name: 'SOUL.md', heading: 'SOUL — how to behave' },
27
+ { name: 'TOOLS.md', heading: 'TOOLS — what is available' },
28
+ { name: 'HEARTBEAT.md', heading: 'HEARTBEAT — proactive routines' },
28
29
  ];
29
30
 
30
31
  export function workspaceRoot(cfgDir) {
@@ -109,6 +110,20 @@ What the assistant can reach for, and how to invoke each one.
109
110
  - \`lazyclaw agent ...\` — one-shot LLM call
110
111
 
111
112
  Add project-specific tools below.
113
+ `,
114
+ 'HEARTBEAT.md':
115
+ `# Heartbeat
116
+
117
+ Proactive routines the assistant runs on a schedule — the things it
118
+ should do WITHOUT being asked. Wire each to a real trigger with
119
+ \`lazyclaw cron add <name> --schedule "<cron>"\` (or a goal's --cron).
120
+
121
+ - Every morning: summarise overnight changes and post to #standup.
122
+ - Hourly: check the deploy queue; alert on a stuck job.
123
+ - When idle: review recently-used skills (\`lazyclaw skills curate\`).
124
+
125
+ Keep routines reversible and cheap; a heartbeat that mutates state
126
+ should ask first or operate on a dry-run by default.
112
127
  `,
113
128
  };
114
129
  for (const [name, body] of Object.entries(stubs)) {