diff --git a/src/cli/commands/brain.ts b/src/cli/commands/brain.ts index 67d13801..221b940f 100644 --- a/src/cli/commands/brain.ts +++ b/src/cli/commands/brain.ts @@ -120,12 +120,12 @@ experiment conclusions so mutual thinking compounds. See docs/guides/BRAIN.md. const ctx = openBrain(); try { const q: BrainQuery = { - text: query, org: !!options.org, - agent: options.agent, - kind: options.kind as BrainKind | undefined, limit: parseInt(options.limit, 10), includeSuperseded: !!options.all, + ...(query ? { text: query } : {}), + ...(options.agent ? { agent: options.agent } : {}), + ...(options.kind ? { kind: options.kind as BrainKind } : {}), }; const results = ctx.store.recall(q); if (options.json) { @@ -266,8 +266,8 @@ experiment conclusions so mutual thinking compounds. See docs/guides/BRAIN.md. return cmd; } -function splitList(v?: string): string[] | undefined { - if (!v) return undefined; +function splitList(v?: string): string[] { + if (!v) return []; return v .split(',') .map((s) => s.trim()) diff --git a/src/cli/commands/vision.ts b/src/cli/commands/vision.ts index 6fbcbad8..7c50c9e2 100644 --- a/src/cli/commands/vision.ts +++ b/src/cli/commands/vision.ts @@ -177,15 +177,16 @@ limits (maxIterations, maxConsecutiveFailures, …). See docs/guides/VISION.md. .action((text, options) => { const p = paths(process.cwd()); const inbox = new SignalInbox(p.signalsPath); + const refs = options.refs + ? String(options.refs) + .split(',') + .map((r: string) => r.trim()) + : undefined; const s = inbox.add({ text, severity: options.severity as SignalSeverity, source: options.source, - refs: options.refs - ? String(options.refs) - .split(',') - .map((r: string) => r.trim()) - : undefined, + ...(refs ? { refs } : {}), }); console.log( chalk.green('✓ signal queued'), @@ -228,15 +229,16 @@ limits (maxIterations, maxConsecutiveFailures, …). See docs/guides/VISION.md. ) ); } + const max = options.once + ? 1 + : options.max + ? parseInt(options.max, 10) + : undefined; await runLoop({ dryRun, - max: options.once - ? 1 - : options.max - ? parseInt(options.max, 10) - : undefined, - delegateCmd: options.delegateCmd, timeoutMs: parseInt(options.timeout, 10) * 1000, + ...(max !== undefined ? { max } : {}), + ...(options.delegateCmd ? { delegateCmd: options.delegateCmd } : {}), }); }); @@ -284,7 +286,7 @@ async function runLoop(opts: { const result = await loop.run({ dryRun: opts.dryRun, - maxIterations: opts.max, + ...(opts.max !== undefined ? { maxIterations: opts.max } : {}), }); for (const d of result.decisions) console.log(fmtDecision(d)); diff --git a/src/cli/hermes-sm.ts b/src/cli/hermes-sm.ts index 80d92f42..1f1434a0 100644 --- a/src/cli/hermes-sm.ts +++ b/src/cli/hermes-sm.ts @@ -59,7 +59,10 @@ const DEFAULT_CONFIG: HermesSMConfig = { function loadConfig(): HermesSMConfig { try { if (fs.existsSync(HERMES_CONFIG_PATH)) { - return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(HERMES_CONFIG_PATH, 'utf8')) }; + return { + ...DEFAULT_CONFIG, + ...JSON.parse(fs.readFileSync(HERMES_CONFIG_PATH, 'utf8')), + }; } } catch { // Use defaults @@ -121,9 +124,13 @@ function ensureDaemon(): void { function writeSessionHeartbeat(instanceId: string): NodeJS.Timeout { const sessionsDir = path.join(SM_DIR, 'sessions'); - if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true }); + if (!fs.existsSync(sessionsDir)) + fs.mkdirSync(sessionsDir, { recursive: true }); - const heartbeatFile = path.join(sessionsDir, `session-${Date.now()}.heartbeat`); + const heartbeatFile = path.join( + sessionsDir, + `session-${Date.now()}.heartbeat` + ); fs.writeFileSync(heartbeatFile, instanceId); // Update heartbeat every 60s @@ -154,7 +161,11 @@ class HermesSM { const { instanceId, tracingEnabled, verboseTracing } = this.config; console.log(chalk.cyan('╭─ hermes-sm ─────────────────────────────╮')); - console.log(chalk.cyan(`│ Instance: ${instanceId.slice(0, 8)} │`)); + console.log( + chalk.cyan( + `│ Instance: ${instanceId.slice(0, 8)} │` + ) + ); console.log(chalk.cyan('╰──────────────────────────────────────────╯')); // 1. Ensure daemon is running @@ -193,7 +204,11 @@ class HermesSM { const handoff = store.getLatestHandoff(projectId); if (handoff) { handoffContext = handoff.content || ''; - console.log(chalk.dim(` ↳ Restored handoff: ${handoff.summary?.slice(0, 60) || 'previous session'}`)); + console.log( + chalk.dim( + ` ↳ Restored handoff: ${handoff.summary?.slice(0, 60) || 'previous session'}` + ) + ); } } catch { // No handoff available @@ -282,7 +297,9 @@ const smConfig = loadConfig(); program .name('hermes-smd') - .description('Hermes with StackMemory context persistence, daemon auto-start, and desire-path tracking') + .description( + 'Hermes with StackMemory context persistence, daemon auto-start, and desire-path tracking' + ) .argument('[prompt...]', 'Initial prompt for hermes') .option('--resume ', 'Resume a Hermes session by ID') .option('-m, --model ', 'Model to use') diff --git a/src/core/brain/__tests__/brain.test.ts b/src/core/brain/__tests__/brain.test.ts index aac6166a..6e702e7f 100644 --- a/src/core/brain/__tests__/brain.test.ts +++ b/src/core/brain/__tests__/brain.test.ts @@ -37,8 +37,8 @@ describe('BrainStore', () => { const results = store.recall({ text: 'jitter' }); expect(results).toHaveLength(1); - expect(results[0].conclusion).toBe('errors dropped 60%'); - expect(results[0].agent).toBe('codex'); + expect(results[0]?.conclusion).toBe('errors dropped 60%'); + expect(results[0]?.agent).toBe('codex'); }); it('does not leak entries across repos by default', () => { diff --git a/src/core/brain/brain-store.ts b/src/core/brain/brain-store.ts index 96c7f874..b60c4411 100644 --- a/src/core/brain/brain-store.ts +++ b/src/core/brain/brain-store.ts @@ -207,7 +207,7 @@ export class BrainStore { } function rowToEntry(row: BrainRow): BrainEntry { - return { + const entry: BrainEntry = { entryId: row.entry_id, workspaceId: row.workspace_id, projectId: row.project_id, @@ -220,10 +220,11 @@ function rowToEntry(row: BrainRow): BrainEntry { refs: safeParse(row.refs), confidence: row.confidence, status: row.status as BrainEntry['status'], - supersededBy: row.superseded_by ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; + if (row.superseded_by) entry.supersededBy = row.superseded_by; + return entry; } function safeParse(json: string): string[] { diff --git a/src/core/brain/brain-sync.ts b/src/core/brain/brain-sync.ts index 1965c943..f4ba10e1 100644 --- a/src/core/brain/brain-sync.ts +++ b/src/core/brain/brain-sync.ts @@ -187,12 +187,13 @@ export class BrainSync { async sync(): Promise { const pushed = await this.push(); const pulled = await this.pull(); + const error = pushed.error ?? pulled.error; return { success: pushed.success && pulled.success, pushed: pushed.pushed, pulled: pulled.pulled, applied: pulled.applied, - error: pushed.error ?? pulled.error, + ...(error ? { error } : {}), }; } @@ -229,7 +230,7 @@ function toWireEntry(row: Record): BrainEntry { return []; } }; - return { + const entry: BrainEntry = { entryId: String(row['entry_id']), workspaceId: String(row['workspace_id'] ?? ''), projectId: String(row['project_id']), @@ -242,10 +243,11 @@ function toWireEntry(row: Record): BrainEntry { refs: parse(row['refs']), confidence: Number(row['confidence'] ?? 0.7), status: String(row['status'] ?? 'active') as BrainEntry['status'], - supersededBy: (row['superseded_by'] as string | null) ?? undefined, createdAt: Number(row['created_at']), updatedAt: Number(row['updated_at']), }; + if (row['superseded_by']) entry.supersededBy = String(row['superseded_by']); + return entry; } function errMsg(err: unknown): string { diff --git a/src/core/vision/__tests__/vision.test.ts b/src/core/vision/__tests__/vision.test.ts index 0d480945..bfaa584f 100644 --- a/src/core/vision/__tests__/vision.test.ts +++ b/src/core/vision/__tests__/vision.test.ts @@ -57,8 +57,8 @@ describe('parseVision', () => { ]); expect(v.scope).toEqual(['src/**']); expect(v.objectives).toHaveLength(3); - expect(v.objectives[1].done).toBe(true); - expect(v.objectives[0].done).toBe(false); + expect(v.objectives[1]?.done).toBe(true); + expect(v.objectives[0]?.done).toBe(false); expect(v.limits.maxIterations).toBe(5); expect(v.limits.maxConsecutiveFailures).toBe(2); expect(v.limits.requireApproval).toBe(false); @@ -85,6 +85,7 @@ describe('scaffold + toggle', () => { writeFileSync(p, SAMPLE); const v = parseVision(SAMPLE); const target = v.objectives[0]; + if (!target) throw new Error('expected an objective'); expect(setObjectiveDone(p, target.id, true)).toBe(true); const after = parseVision(readFileSync(p, 'utf-8')); expect(after.objectives.find((o) => o.id === target.id)?.done).toBe(true); @@ -98,7 +99,7 @@ describe('SignalInbox', () => { inbox.add({ text: 'critical thing', severity: 'critical' }); inbox.add({ text: 'medium thing', severity: 'medium' }); const pending = inbox.pending(); - expect(pending[0].text).toBe('critical thing'); + expect(pending[0]?.text).toBe('critical thing'); expect(pending).toHaveLength(3); }); diff --git a/src/core/vision/signals.ts b/src/core/vision/signals.ts index 8d4ed0e0..adcef862 100644 --- a/src/core/vision/signals.ts +++ b/src/core/vision/signals.ts @@ -41,8 +41,8 @@ export class SignalInbox { source: input.source ?? 'manual', severity: input.severity ?? 'medium', text: input.text, - refs: input.refs, createdAt: Date.now(), + ...(input.refs ? { refs: input.refs } : {}), }; appendFileSync(this.path, JSON.stringify(signal) + '\n'); return signal; diff --git a/src/core/vision/vision-file.ts b/src/core/vision/vision-file.ts index f2ebb299..548ec824 100644 --- a/src/core/vision/vision-file.ts +++ b/src/core/vision/vision-file.ts @@ -52,7 +52,7 @@ function splitSections(text: string): { for (const line of lines) { const h2 = line.match(/^##\s+(.+?)\s*$/); - if (h2) { + if (h2?.[1]) { current = { body: [] }; sections.set(h2[1].toLowerCase(), current); continue; @@ -69,9 +69,8 @@ function splitSections(text: string): { function bulletLines(body: string[]): string[] { return body - .map((l) => l.match(/^\s*[-*]\s+(.*\S)\s*$/)) - .filter((m): m is RegExpMatchArray => !!m) - .map((m) => m[1].trim()) + .map((l) => l.match(/^\s*[-*]\s+(.*\S)\s*$/)?.[1]?.trim()) + .filter((s): s is string => !!s) .filter((s) => !/^\[[ xX]\]/.test(s)); // checklist handled separately } @@ -79,12 +78,12 @@ function parseObjectives(body: string[]): Objective[] { const objectives: Objective[] = []; for (const line of body) { const m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*\S)\s*$/); - if (!m) continue; + if (!m?.[2]) continue; const text = m[2].trim(); objectives.push({ id: objectiveId(text), text, - done: m[1].toLowerCase() === 'x', + done: (m[1] ?? '').toLowerCase() === 'x', }); } return objectives; @@ -94,7 +93,7 @@ function parseLimits(body: string[]): VisionLimits { const limits: VisionLimits = { ...DEFAULT_LIMITS }; for (const line of body) { const m = line.match(/^\s*([a-zA-Z]+)\s*:\s*(.+?)\s*$/); - if (!m) continue; + if (!m?.[1] || m[2] === undefined) continue; const key = m[1] as keyof VisionLimits; const raw = m[2]; if (!(key in limits)) continue; @@ -142,10 +141,12 @@ export function setObjectiveDone( const lines = readFileSync(path, 'utf-8').split(/\r?\n/); let changed = false; for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(/^(\s*[-*]\s+)\[([ xX])\]\s+(.*\S)\s*$/); - if (!m) continue; + const line = lines[i]; + if (line === undefined) continue; + const m = line.match(/^(\s*[-*]\s+)\[([ xX])\]\s+(.*\S)\s*$/); + if (!m?.[3]) continue; if (objectiveId(m[3].trim()) === objId) { - lines[i] = `${m[1]}[${done ? 'x' : ' '}] ${m[3].trim()}`; + lines[i] = `${m[1] ?? ''}[${done ? 'x' : ' '}] ${m[3].trim()}`; changed = true; break; } diff --git a/src/core/vision/vision-loop.ts b/src/core/vision/vision-loop.ts index 7ce9e506..ac34666d 100644 --- a/src/core/vision/vision-loop.ts +++ b/src/core/vision/vision-loop.ts @@ -128,8 +128,8 @@ export class VisionLoop { }; } const idx = vision.objectives.findIndex((o) => !o.done); - if (idx >= 0) { - const o = vision.objectives[idx]; + const o = idx >= 0 ? vision.objectives[idx] : undefined; + if (o) { return { kind: 'objective', id: o.id, diff --git a/src/daemon/daemon-config.ts b/src/daemon/daemon-config.ts index 681f1ef8..6da58a3a 100644 --- a/src/daemon/daemon-config.ts +++ b/src/daemon/daemon-config.ts @@ -169,9 +169,18 @@ export const DEFAULT_DAEMON_CONFIG: DaemonConfig = { enabled: true, // opt-out via STACKMEMORY_RESEARCH_STREAM=0 interval: 360, // every 6 hours keywords: [ - 'agent', 'ai', 'llm', 'mcp', 'context', 'memory', - 'orchestration', 'skill', 'workflow', 'automation', - 'browser agent', 'coding assistant', + 'agent', + 'ai', + 'llm', + 'mcp', + 'context', + 'memory', + 'orchestration', + 'skill', + 'workflow', + 'automation', + 'browser agent', + 'coding assistant', ], maxSignalsPerScan: 50, }, diff --git a/src/daemon/services/desire-path-service.ts b/src/daemon/services/desire-path-service.ts index 2c472c9e..4b5e555b 100644 --- a/src/daemon/services/desire-path-service.ts +++ b/src/daemon/services/desire-path-service.ts @@ -51,32 +51,37 @@ export interface DesirePathConfig extends DaemonServiceConfig { } export interface ActionEntry { - ts: string; // ISO timestamp - sid: string; // session ID - tool: string; // tool name (Read, Edit, Bash, Grep, etc.) - target: string; // sanitized first arg (file path pattern, command prefix) - dur?: number; // duration ms + ts: string; // ISO timestamp + sid: string; // session ID + tool: string; // tool name (Read, Edit, Bash, Grep, etc.) + target: string; // sanitized first arg (file path pattern, command prefix) + dur?: number; // duration ms } export interface DetectedPattern { id: string; - sequence: string[]; // e.g. ["Read:src/runtime/*.js", "Edit:src/runtime/*.js", "Bash:npx jest*"] - frequency: number; // how many times observed - sessions: number; // across how many distinct sessions - avg_steps: number; // average total steps in sessions containing this pattern - first_seen: string; // ISO - last_seen: string; // ISO - score: number; // frequency × sessions (simple ranking) + sequence: string[]; // e.g. ["Read:src/runtime/*.js", "Edit:src/runtime/*.js", "Bash:npx jest*"] + frequency: number; // how many times observed + sessions: number; // across how many distinct sessions + avg_steps: number; // average total steps in sessions containing this pattern + first_seen: string; // ISO + last_seen: string; // ISO + score: number; // frequency × sessions (simple ranking) } export interface SkillSuggestion { name: string; description: string; - inputs: Array<{ name: string; type: string; required: boolean; description: string }>; + inputs: Array<{ + name: string; + type: string; + required: boolean; + description: string; + }>; outputs: Array<{ name: string; type: string; description: string }>; steps: string[]; pattern_id: string; - confidence: number; // 0-1 based on pattern strength + confidence: number; // 0-1 based on pattern strength generated_at: string; } @@ -120,7 +125,7 @@ function sanitizeCommand(cmd: string): string { const parts = cmd.trim().split(/\s+/); const command = parts[0]; // Keep first meaningful arg (skip flags) - const firstArg = parts.slice(1).find(p => !p.startsWith('-')); + const firstArg = parts.slice(1).find((p) => !p.startsWith('-')); if (firstArg) { return `${command} ${firstArg.length > 30 ? firstArg.slice(0, 30) + '*' : firstArg}`; } @@ -145,8 +150,8 @@ export class DaemonDesirePathService { private scanTimeout?: NodeJS.Timeout; private isRunning = false; private onLog: (level: string, message: string, data?: unknown) => void; - private lastActivityTime = 0; // last time an action was logged - private consecutiveIdleScans = 0; // scans with no new actions + private lastActivityTime = 0; // last time an action was logged + private consecutiveIdleScans = 0; // scans with no new actions constructor( config: DesirePathConfig, @@ -202,12 +207,20 @@ export class DaemonDesirePathService { } /** Parse a hook event into an ActionEntry. */ - static parseHookEvent(toolName: string, firstArg: string, sessionId: string, durationMs?: number): ActionEntry { + static parseHookEvent( + toolName: string, + firstArg: string, + sessionId: string, + durationMs?: number + ): ActionEntry { let target: string; if (TOOL_TARGET_SENSITIVE.has(toolName)) { target = sanitizeCommand(firstArg); - } else if (firstArg && (firstArg.includes('/') || firstArg.includes('\\'))) { + } else if ( + firstArg && + (firstArg.includes('/') || firstArg.includes('\\')) + ) { target = sanitizePath(firstArg); } else { target = firstArg ? firstArg.slice(0, 50) : '*'; @@ -233,7 +246,13 @@ export class DaemonDesirePathService { try { const lines = readFileSync(STREAM_FILE, 'utf-8').trim().split('\n'); entries = lines - .map(line => { try { return JSON.parse(line); } catch { return null; } }) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) .filter(Boolean) as ActionEntry[]; } catch { return []; @@ -254,7 +273,15 @@ export class DaemonDesirePathService { // Extract subsequences from each session const maxLen = this.config.maxSequenceLength || 8; const minLen = 2; - const sequenceCounts = new Map; firstSeen: string; lastSeen: string }>(); + const sequenceCounts = new Map< + string, + { + count: number; + sessions: Set; + firstSeen: string; + lastSeen: string; + } + >(); for (const [sid, actions] of sessions) { const keys = actions.map(actionKey); @@ -335,7 +362,14 @@ export class DaemonDesirePathService { // Persist try { - writeFileSync(PATTERNS_FILE, JSON.stringify({ patterns: topPatterns, updated_at: new Date().toISOString() }, null, 2)); + writeFileSync( + PATTERNS_FILE, + JSON.stringify( + { patterns: topPatterns, updated_at: new Date().toISOString() }, + null, + 2 + ) + ); } catch (err) { this.addError(String(err)); } @@ -411,7 +445,7 @@ export class DaemonDesirePathService { if (suggestion.confidence < threshold) continue; // Check session count from the pattern - const pattern = patterns.find(p => p.id === suggestion.pattern_id); + const pattern = patterns.find((p) => p.id === suggestion.pattern_id); if (!pattern || pattern.sessions < minSessions) continue; // Check if already promoted @@ -436,7 +470,9 @@ export class DaemonDesirePathService { dest: destFile, }); } catch (err) { - this.addError(`Auto-promote failed for ${suggestion.name}: ${String(err)}`); + this.addError( + `Auto-promote failed for ${suggestion.name}: ${String(err)}` + ); } } } @@ -470,21 +506,28 @@ export class DaemonDesirePathService { return null; } - private patternToSuggestion(pattern: DetectedPattern): SkillSuggestion | null { + private patternToSuggestion( + pattern: DetectedPattern + ): SkillSuggestion | null { if (pattern.sequence.length < 2) return null; // Extract dominant tools and targets - const tools = pattern.sequence.map(s => { + const tools = pattern.sequence.map((s) => { const [tool, target] = s.split(':', 2); return { tool, target: target || '*' }; }); // Derive name from tools + dominant target directory - const toolNames = [...new Set(tools.map(t => t.tool.toLowerCase()))]; - const targets = tools.map(t => t.target).filter(t => t !== '*'); - const dominantDir = targets.length > 0 - ? targets[0].split('/').slice(0, 3).join('-').replace(/[^a-zA-Z0-9-]/g, '') - : ''; + const toolNames = [...new Set(tools.map((t) => t.tool.toLowerCase()))]; + const targets = tools.map((t) => t.target).filter((t) => t !== '*'); + const dominantDir = + targets.length > 0 + ? targets[0] + .split('/') + .slice(0, 3) + .join('-') + .replace(/[^a-zA-Z0-9-]/g, '') + : ''; const nameSuffix = dominantDir ? `-${dominantDir}` : ''; const name = `auto-${toolNames.join('-')}${nameSuffix}`; @@ -502,16 +545,18 @@ export class DaemonDesirePathService { // Infer outputs from last step const lastTool = tools[tools.length - 1]; - const outputs: SkillSuggestion['outputs'] = [{ - name: 'result', - type: 'string', - description: `Output from ${lastTool.tool}`, - }]; + const outputs: SkillSuggestion['outputs'] = [ + { + name: 'result', + type: 'string', + description: `Output from ${lastTool.tool}`, + }, + ]; // Build steps const steps = tools.map((t, i) => `${i + 1}. ${t.tool}: ${t.target}`); - const confidence = Math.min(1, (pattern.score / 20)); + const confidence = Math.min(1, pattern.score / 20); return { name, @@ -526,45 +571,56 @@ export class DaemonDesirePathService { } private renderSkillMarkdown(suggestion: SkillSuggestion): string { - const inputsYaml = suggestion.inputs.length > 0 - ? suggestion.inputs.map(i => - ` - name: ${i.name}\n type: ${i.type}\n required: ${i.required}\n description: "${i.description}"` - ).join('\n') - : ''; - - const outputsYaml = suggestion.outputs.map(o => - ` - name: ${o.name}\n type: ${o.type}\n description: "${o.description}"` - ).join('\n'); - - return [ - '---', - `name: ${suggestion.name}`, - `description: "${suggestion.description}"`, - `status: suggested`, - `pattern_id: ${suggestion.pattern_id}`, - `confidence: ${suggestion.confidence.toFixed(2)}`, - `generated_at: ${suggestion.generated_at}`, - suggestion.inputs.length > 0 ? `inputs:\n${inputsYaml}` : '', - `outputs:\n${outputsYaml}`, - '---', - '', - `# ${suggestion.name}`, - '', - '## Auto-Detected Workflow', - '', - `> This skill was auto-generated from ${suggestion.pattern_id} detected patterns.`, - '> Review and edit before promoting to an active skill.', - '', - '## Steps', - '', - ...suggestion.steps, - '', - '## Notes', - '', - '- Edit this file to refine the workflow', - '- Move to your `skills/` directory to activate', - `- Confidence: ${(suggestion.confidence * 100).toFixed(0)}%`, - ].filter(line => line !== '').join('\n') + '\n'; + const inputsYaml = + suggestion.inputs.length > 0 + ? suggestion.inputs + .map( + (i) => + ` - name: ${i.name}\n type: ${i.type}\n required: ${i.required}\n description: "${i.description}"` + ) + .join('\n') + : ''; + + const outputsYaml = suggestion.outputs + .map( + (o) => + ` - name: ${o.name}\n type: ${o.type}\n description: "${o.description}"` + ) + .join('\n'); + + return ( + [ + '---', + `name: ${suggestion.name}`, + `description: "${suggestion.description}"`, + `status: suggested`, + `pattern_id: ${suggestion.pattern_id}`, + `confidence: ${suggestion.confidence.toFixed(2)}`, + `generated_at: ${suggestion.generated_at}`, + suggestion.inputs.length > 0 ? `inputs:\n${inputsYaml}` : '', + `outputs:\n${outputsYaml}`, + '---', + '', + `# ${suggestion.name}`, + '', + '## Auto-Detected Workflow', + '', + `> This skill was auto-generated from ${suggestion.pattern_id} detected patterns.`, + '> Review and edit before promoting to an active skill.', + '', + '## Steps', + '', + ...suggestion.steps, + '', + '## Notes', + '', + '- Edit this file to refine the workflow', + '- Move to your `skills/` directory to activate', + `- Confidence: ${(suggestion.confidence * 100).toFixed(0)}%`, + ] + .filter((line) => line !== '') + .join('\n') + '\n' + ); } // ─── Lifecycle (adaptive backoff) ────────────────────────── @@ -573,7 +629,7 @@ export class DaemonDesirePathService { // Idle (no actions): backoff 1h → 2h → 4h → 8h → 12h (cap) // New activity resets to 1h immediately - private static readonly BASE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + private static readonly BASE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour private static readonly MAX_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours private static readonly IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 min = idle @@ -582,13 +638,18 @@ export class DaemonDesirePathService { const timeSinceActivity = now - this.lastActivityTime; // If recent activity, scan hourly - if (this.lastActivityTime > 0 && timeSinceActivity < DaemonDesirePathService.IDLE_THRESHOLD_MS) { + if ( + this.lastActivityTime > 0 && + timeSinceActivity < DaemonDesirePathService.IDLE_THRESHOLD_MS + ) { this.consecutiveIdleScans = 0; return DaemonDesirePathService.BASE_INTERVAL_MS; } // Backoff: 1h × 2^idle_scans, capped at 12h - const backoff = DaemonDesirePathService.BASE_INTERVAL_MS * Math.pow(2, this.consecutiveIdleScans); + const backoff = + DaemonDesirePathService.BASE_INTERVAL_MS * + Math.pow(2, this.consecutiveIdleScans); return Math.min(backoff, DaemonDesirePathService.MAX_INTERVAL_MS); } @@ -603,7 +664,10 @@ export class DaemonDesirePathService { this.isRunning = true; mkdirSync(DP_DIR, { recursive: true }); - this.onLog('INFO', 'Desire-path service started (adaptive backoff: 1h active, up to 12h idle)'); + this.onLog( + 'INFO', + 'Desire-path service started (adaptive backoff: 1h active, up to 12h idle)' + ); // First scan after 2 minutes this.scanTimeout = setTimeout(() => { @@ -684,33 +748,37 @@ export class DaemonDesirePathService { getSuggestions(): SkillSuggestion[] { try { if (!existsSync(SUGGESTIONS_DIR)) return []; - const files = readdirSync(SUGGESTIONS_DIR).filter(f => f.endsWith('.skill.md')); - return files.map(f => { - const content = readFileSync(join(SUGGESTIONS_DIR, f), 'utf-8'); - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) return null; - try { - // Parse frontmatter minimally - const lines = match[1].split('\n'); - const meta: Record = {}; - for (const line of lines) { - const kv = line.match(/^(\w[\w_-]*):\s*(.*)/); - if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); + const files = readdirSync(SUGGESTIONS_DIR).filter((f) => + f.endsWith('.skill.md') + ); + return files + .map((f) => { + const content = readFileSync(join(SUGGESTIONS_DIR, f), 'utf-8'); + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + try { + // Parse frontmatter minimally + const lines = match[1].split('\n'); + const meta: Record = {}; + for (const line of lines) { + const kv = line.match(/^(\w[\w_-]*):\s*(.*)/); + if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); + } + return { + name: meta.name || basename(f, '.skill.md'), + description: meta.description || '', + pattern_id: meta.pattern_id || '', + confidence: parseFloat(meta.confidence || '0'), + generated_at: meta.generated_at || '', + inputs: [], + outputs: [], + steps: [], + } as SkillSuggestion; + } catch { + return null; } - return { - name: meta.name || basename(f, '.skill.md'), - description: meta.description || '', - pattern_id: meta.pattern_id || '', - confidence: parseFloat(meta.confidence || '0'), - generated_at: meta.generated_at || '', - inputs: [], - outputs: [], - steps: [], - } as SkillSuggestion; - } catch { - return null; - } - }).filter(Boolean) as SkillSuggestion[]; + }) + .filter(Boolean) as SkillSuggestion[]; } catch { return []; } diff --git a/src/daemon/services/research-stream-service.ts b/src/daemon/services/research-stream-service.ts index bdde0c48..b59382e0 100644 --- a/src/daemon/services/research-stream-service.ts +++ b/src/daemon/services/research-stream-service.ts @@ -76,7 +76,7 @@ const RATE_LIMIT_MS = 1100; // 1.1s between requests (safe for GitHub) /** Sleep for ms. */ function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** Calculate ISO week string (e.g. "2026-W19"). */ @@ -85,12 +85,22 @@ function isoWeek(date: Date): string { d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); const week1 = new Date(d.getFullYear(), 0, 4); - const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); + const weekNum = + 1 + + Math.round( + ((d.getTime() - week1.getTime()) / 86400000 - + 3 + + ((week1.getDay() + 6) % 7)) / + 7 + ); return `${d.getFullYear()}-W${String(weekNum).padStart(2, '0')}`; } /** Score relevance of a title against keyword list. Returns 0-1. */ -function scoreRelevance(title: string, keywords: string[]): { score: number; matched: string[] } { +function scoreRelevance( + title: string, + keywords: string[] +): { score: number; matched: string[] } { const lower = title.toLowerCase(); const matched: string[] = []; @@ -103,12 +113,15 @@ function scoreRelevance(title: string, keywords: string[]): { score: number; mat if (matched.length === 0) return { score: 0, matched: [] }; // Base score from match count, diminishing returns - const score = Math.min(1, 0.3 + (matched.length * 0.2)); + const score = Math.min(1, 0.3 + matched.length * 0.2); return { score, matched }; } /** Safe fetch with timeout. Returns null on any error. */ -async function safeFetch(url: string, timeoutMs = 10_000): Promise { +async function safeFetch( + url: string, + timeoutMs = 10_000 +): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -162,7 +175,7 @@ export class ResearchStreamService { private async fetchHackerNews(): Promise { const signals: ResearchSignal[] = []; - const topIds = await safeFetch(HN_TOP_URL) as number[] | null; + const topIds = (await safeFetch(HN_TOP_URL)) as number[] | null; if (!topIds || !Array.isArray(topIds)) { this.onLog('WARN', 'HN top stories fetch failed'); return signals; @@ -172,7 +185,7 @@ export class ResearchStreamService { for (const id of ids) { await sleep(200); // gentle rate limit for HN - const item = await safeFetch(`${HN_ITEM_URL}/${id}.json`) as { + const item = (await safeFetch(`${HN_ITEM_URL}/${id}.json`)) as { title?: string; url?: string; score?: number; @@ -180,7 +193,10 @@ export class ResearchStreamService { if (!item || !item.title) continue; - const { score: relevance, matched } = scoreRelevance(item.title, this.config.keywords); + const { score: relevance, matched } = scoreRelevance( + item.title, + this.config.keywords + ); if (relevance === 0) continue; signals.push({ @@ -208,7 +224,7 @@ export class ResearchStreamService { const url = `${GH_SEARCH_URL}?q=created:>${dateStr}&sort=stars&order=desc&per_page=10`; await sleep(RATE_LIMIT_MS); - const data = await safeFetch(url) as { + const data = (await safeFetch(url)) as { items?: Array<{ full_name?: string; html_url?: string; @@ -224,7 +240,10 @@ export class ResearchStreamService { for (const repo of data.items) { const text = `${repo.full_name || ''} ${repo.description || ''}`; - const { score: relevance, matched } = scoreRelevance(text, this.config.keywords); + const { score: relevance, matched } = scoreRelevance( + text, + this.config.keywords + ); if (relevance === 0) continue; signals.push({ @@ -271,11 +290,12 @@ export class ResearchStreamService { // Deduplicate against existing stream (by URL) const existingUrls = this.loadExistingUrls(); - const newSignals = capped.filter(s => !existingUrls.has(s.url)); + const newSignals = capped.filter((s) => !existingUrls.has(s.url)); // Append to JSONL if (newSignals.length > 0) { - const lines = newSignals.map(s => JSON.stringify(s)).join('\n') + '\n'; + const lines = + newSignals.map((s) => JSON.stringify(s)).join('\n') + '\n'; appendFileSync(STREAM_FILE, lines, 'utf-8'); this.state.signalsCollected += newSignals.length; } @@ -345,7 +365,9 @@ export class ResearchStreamService { if (weekSignals.length === 0) return; // Sort by relevance, take top 20 - weekSignals.sort((a, b) => b.relevance - a.relevance || b.score - a.score); + weekSignals.sort( + (a, b) => b.relevance - a.relevance || b.score - a.score + ); const topSignals = weekSignals.slice(0, 20); // Extract themes from keyword frequency @@ -435,8 +457,15 @@ export class ResearchStreamService { try { if (!existsSync(STREAM_FILE)) return []; const lines = readFileSync(STREAM_FILE, 'utf-8').trim().split('\n'); - return lines.slice(-(this.state.signalsCollected - before)) - .map(l => { try { return JSON.parse(l); } catch { return null; } }) + return lines + .slice(-(this.state.signalsCollected - before)) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) .filter(Boolean) as ResearchSignal[]; } catch { return []; diff --git a/src/daemon/services/telemetry-service.ts b/src/daemon/services/telemetry-service.ts index 36b24a29..3c6e4941 100644 --- a/src/daemon/services/telemetry-service.ts +++ b/src/daemon/services/telemetry-service.ts @@ -8,7 +8,14 @@ * Opt out: STACKMEMORY_TELEMETRY=0 or telemetry.enabled: false in config. */ -import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs'; +import { + existsSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, + mkdirSync, +} from 'fs'; import { join, dirname } from 'path'; import { homedir, platform } from 'os'; import { randomBytes } from 'crypto'; @@ -74,7 +81,10 @@ export class DaemonTelemetryService { } private isOptedOut(): boolean { - if (process.env.STACKMEMORY_TELEMETRY === '0' || process.env.STACKMEMORY_TELEMETRY === 'false') { + if ( + process.env.STACKMEMORY_TELEMETRY === '0' || + process.env.STACKMEMORY_TELEMETRY === 'false' + ) { return true; } return !this.config.enabled; @@ -99,8 +109,11 @@ export class DaemonTelemetryService { private countSessions(): { total_heartbeats: number; active_now: number } { try { - if (!existsSync(SESSIONS_DIR)) return { total_heartbeats: 0, active_now: 0 }; - const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.heartbeat')); + if (!existsSync(SESSIONS_DIR)) + return { total_heartbeats: 0, active_now: 0 }; + const files = readdirSync(SESSIONS_DIR).filter((f) => + f.endsWith('.heartbeat') + ); const now = Date.now(); let active = 0; for (const file of files) { @@ -131,7 +144,7 @@ export class DaemonTelemetryService { try { const handoffsDir = join(SM_DIR, 'handoffs'); if (!existsSync(handoffsDir)) return 0; - return readdirSync(handoffsDir).filter(f => f.endsWith('.md')).length; + return readdirSync(handoffsDir).filter((f) => f.endsWith('.md')).length; } catch { return 0; } @@ -148,13 +161,17 @@ export class DaemonTelemetryService { collected_at: new Date().toISOString(), platform: platform(), node_version: process.version, - daemon: daemonState ? { - uptime_s: Math.round((daemonState.uptime || 0) / 1000), - context_saves: daemonState.services?.context?.saveCount || 0, - memory_triggers: daemonState.services?.memory?.triggerCount || 0, - ram_percent: Math.round((daemonState.services?.memory?.currentRamPercent || 0) * 100), - errors: (daemonState.errors || []).length, - } : null, + daemon: daemonState + ? { + uptime_s: Math.round((daemonState.uptime || 0) / 1000), + context_saves: daemonState.services?.context?.saveCount || 0, + memory_triggers: daemonState.services?.memory?.triggerCount || 0, + ram_percent: Math.round( + (daemonState.services?.memory?.currentRamPercent || 0) * 100 + ), + errors: (daemonState.errors || []).length, + } + : null, sessions, skills: { audit_entries: this.countSkillAudit() }, handoffs: { total: this.countHandoffs() }, @@ -182,10 +199,15 @@ export class DaemonTelemetryService { try { const dir = dirname(TELEMETRY_FILE); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(TELEMETRY_FILE, JSON.stringify({ version: 1, snapshots: history }, null, 2), 'utf-8'); + writeFileSync( + TELEMETRY_FILE, + JSON.stringify({ version: 1, snapshots: history }, null, 2), + 'utf-8' + ); } catch (err) { this.state.errors.push(String(err)); - if (this.state.errors.length > 5) this.state.errors = this.state.errors.slice(-5); + if (this.state.errors.length > 5) + this.state.errors = this.state.errors.slice(-5); this.onLog('ERROR', 'Failed to save telemetry', { error: String(err) }); return null; } @@ -206,18 +228,26 @@ export class DaemonTelemetryService { this.isRunning = true; const intervalMs = (this.config.interval || 1440) * 60 * 1000; // default 24h - this.onLog('INFO', 'Telemetry service started', { interval_min: this.config.interval }); + this.onLog('INFO', 'Telemetry service started', { + interval_min: this.config.interval, + }); // First snapshot after 30s setTimeout(() => { if (!this.isRunning) return; const snap = this.save(); - if (snap) this.onLog('INFO', 'Telemetry snapshot saved', { sessions: snap.sessions.active_now }); + if (snap) + this.onLog('INFO', 'Telemetry snapshot saved', { + sessions: snap.sessions.active_now, + }); }, 30_000); this.intervalId = setInterval(() => { const snap = this.save(); - if (snap) this.onLog('INFO', 'Telemetry snapshot saved', { sessions: snap.sessions.active_now }); + if (snap) + this.onLog('INFO', 'Telemetry snapshot saved', { + sessions: snap.sessions.active_now, + }); }, intervalMs); if (this.intervalId.unref) this.intervalId.unref();