osborn 0.9.236 → 0.9.238

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.
@@ -120,10 +120,17 @@ function extractSpeedMarker(text) {
120
120
  }
121
121
  function stripMarkdownForTTS(text) {
122
122
  return text
123
- // Remove code blocks (``` ... ```)
123
+ // Remove PAIRED code blocks (``` ... ```)
124
124
  .replace(/```[\s\S]*?```/g, ' [code block] ')
125
+ // Remove an UNCLOSED/partial fence to end-of-string. Assistant text streams as multiple
126
+ // blocks per turn and each block is stripped in isolation, so a fence that opens in one
127
+ // block (or is never closed) would otherwise leak raw code + literal backticks to TTS.
128
+ .replace(/```[\s\S]*$/g, ' [code block] ')
125
129
  // Remove inline code (` ... `)
126
130
  .replace(/`([^`]+)`/g, '$1')
131
+ // Sweep any remaining stray backtick runs (unbalanced ticks from split chunks) so they're
132
+ // never spoken aloud.
133
+ .replace(/`+/g, ' ')
127
134
  // Remove bold (**text** or __text__)
128
135
  .replace(/\*\*([^*]+)\*\*/g, '$1')
129
136
  .replace(/__([^_]+)__/g, '$1')
package/dist/env-keys.js CHANGED
@@ -35,6 +35,7 @@ export const AGENT_ENV_KEYS = [
35
35
  { key: 'OSBORN_CWD', required: false, explicit: true, description: 'Agent working directory.' },
36
36
  { key: 'OSBORN_SYNC_TOKEN', required: false, explicit: true, description: 'Session export/import auth token.' },
37
37
  { key: 'OSBORN_FRONTEND_URL', required: false, explicit: true, description: 'Frontend base URL for artifact upload.' },
38
+ { key: 'OSBORN_HOUSEKEEPING_DRYRUN', required: false, explicit: true, description: 'Set "0" to enable live housekeeping deletion; any other value / unset = dry-run (log only).' },
38
39
  { key: 'DEV_DOMAIN', required: false, explicit: true, description: 'Wildcard dev-routing domain.' },
39
40
  ];
40
41
  /** Keys the agent treats as required — absence should be surfaced at startup. */
@@ -0,0 +1,16 @@
1
+ export interface HousekeepingResult {
2
+ subagentsRemoved: string[];
3
+ corruptSlugsRemoved: string[];
4
+ scannedSlugs: number;
5
+ }
6
+ /**
7
+ * Prune stale sub-agent transcripts (>ttl) and empty/corrupt slug dirs.
8
+ * SAFE BY DESIGN: never deletes a {sessionId}.jsonl, never touches the active session's
9
+ * subtree, and (unless dryRun=false) deletes nothing at all. Conservative — only removes
10
+ * sub-agent artifacts older than the TTL and slug dirs that contain NO conversation jsonl.
11
+ */
12
+ export declare function cleanupHousekeeping(opts: {
13
+ dryRun: boolean;
14
+ activeSessionId?: string | null;
15
+ subagentTtlMs?: number;
16
+ }): HousekeepingResult;
@@ -0,0 +1,151 @@
1
+ // Housekeeping: prune stale sub-agent transcripts and empty/corrupt slug dirs from the Claude
2
+ // projects tree. Runs on the agent (boot + periodic). Ships DRY-RUN by default so we can eyeball
3
+ // what it WOULD delete in fly logs before enabling real deletion (OSBORN_HOUSEKEEPING_DRYRUN=0).
4
+ //
5
+ // Layout (see session-access.ts for the canonical map):
6
+ // ~/.claude/projects/{slug}/
7
+ // ├── {sessionId}.jsonl ← main conversation (the source of truth — NEVER deleted here)
8
+ // ├── {sessionId}/subagents/agent-*.jsonl ← SDK sub-agent transcripts (TTL target)
9
+ // ├── agent-*.jsonl ← project-level native Task transcripts (TTL target)
10
+ // └── osb/{sessionId}/session.db ← recall store, SLUG-LEVEL sibling of the jsonl (NEVER deleted here)
11
+ import { existsSync, readdirSync, statSync, lstatSync, rmSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { getClaudeProjectsDir } from './config.js';
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+ /**
17
+ * Prune stale sub-agent transcripts (>ttl) and empty/corrupt slug dirs.
18
+ * SAFE BY DESIGN: never deletes a {sessionId}.jsonl, never touches the active session's
19
+ * subtree, and (unless dryRun=false) deletes nothing at all. Conservative — only removes
20
+ * sub-agent artifacts older than the TTL and slug dirs that contain NO conversation jsonl.
21
+ */
22
+ export function cleanupHousekeeping(opts) {
23
+ const { dryRun, activeSessionId } = opts;
24
+ const ttl = opts.subagentTtlMs ?? DAY_MS;
25
+ const now = Date.now();
26
+ const tag = dryRun ? '🧹[dry-run] WOULD remove' : '🧹 removed';
27
+ const result = { subagentsRemoved: [], corruptSlugsRemoved: [], scannedSlugs: 0 };
28
+ let projectsDir;
29
+ try {
30
+ projectsDir = getClaudeProjectsDir();
31
+ }
32
+ catch {
33
+ return result;
34
+ }
35
+ let slugs;
36
+ try {
37
+ slugs = readdirSync(projectsDir);
38
+ }
39
+ catch {
40
+ return result;
41
+ }
42
+ const rm = (p) => { if (!dryRun) {
43
+ try {
44
+ rmSync(p, { recursive: true, force: true });
45
+ }
46
+ catch { }
47
+ } };
48
+ for (const slug of slugs) {
49
+ if (slug.startsWith('.'))
50
+ continue;
51
+ const slugPath = join(projectsDir, slug);
52
+ let slugStat;
53
+ try {
54
+ slugStat = lstatSync(slugPath);
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ if (slugStat.isSymbolicLink())
60
+ continue; // never follow or delete symlinked entries
61
+ // Stray non-directory entry directly under projects/ — garbage. Only if old enough,
62
+ // to avoid racing a write-in-progress.
63
+ if (!slugStat.isDirectory()) {
64
+ if (now - slugStat.mtimeMs > ttl) {
65
+ console.log(`${tag} stray projects entry: ${slugPath}`);
66
+ result.corruptSlugsRemoved.push(slugPath);
67
+ rm(slugPath);
68
+ }
69
+ continue;
70
+ }
71
+ result.scannedSlugs++;
72
+ let entries;
73
+ try {
74
+ entries = readdirSync(slugPath);
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ // Which session ids have a live top-level {sessionId}.jsonl in this slug?
80
+ const liveSessionIds = new Set();
81
+ for (const e of entries) {
82
+ if (e.endsWith('.jsonl')) {
83
+ const base = e.slice(0, -6);
84
+ if (UUID_RE.test(base))
85
+ liveSessionIds.add(base);
86
+ }
87
+ }
88
+ const slugContainsActive = !!activeSessionId && liveSessionIds.has(activeSessionId);
89
+ // (a) Sub-agent TTL sweep.
90
+ for (const e of entries) {
91
+ const full = join(slugPath, e);
92
+ // Project-level native SDK Task transcripts. Skip the active project's — a long-running
93
+ // active session may have legitimately old task transcripts still relevant to it.
94
+ if (e.startsWith('agent-') && e.endsWith('.jsonl')) {
95
+ if (slugContainsActive)
96
+ continue;
97
+ try {
98
+ if (now - statSync(full).mtimeMs > ttl) {
99
+ console.log(`${tag} subagent transcript: ${full}`);
100
+ result.subagentsRemoved.push(full);
101
+ rm(full);
102
+ }
103
+ }
104
+ catch { /* ignore per-entry */ }
105
+ continue;
106
+ }
107
+ // Per-session subdir → its subagents/ transcripts. Never the active session's subtree.
108
+ if (UUID_RE.test(e)) {
109
+ if (activeSessionId && e === activeSessionId)
110
+ continue;
111
+ const subagentsDir = join(full, 'subagents');
112
+ if (!existsSync(subagentsDir))
113
+ continue;
114
+ let subFiles = [];
115
+ try {
116
+ subFiles = readdirSync(subagentsDir);
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ for (const sf of subFiles) {
122
+ const sfull = join(subagentsDir, sf);
123
+ try {
124
+ if (now - statSync(sfull).mtimeMs > ttl) {
125
+ console.log(`${tag} subagent transcript: ${sfull}`);
126
+ result.subagentsRemoved.push(sfull);
127
+ rm(sfull);
128
+ }
129
+ }
130
+ catch { /* ignore per-entry */ }
131
+ }
132
+ }
133
+ }
134
+ // (b) Corrupt/empty slug: ONLY a TRULY EMPTY directory (zero entries) that is stale.
135
+ // We deliberately do NOT delete a slug that merely lacks a top-level *.jsonl — such a dir
136
+ // can still hold a slug-level `osb/{sessionId}/session.db` recall store or orphan subdirs,
137
+ // and a directory's mtime does NOT reflect nested writes, so "no jsonl + stale dir-mtime"
138
+ // is not a safe delete signal. An empty dir has nothing nested to lose.
139
+ if (entries.length === 0 && now - slugStat.mtimeMs > ttl) {
140
+ console.log(`${tag} empty slug dir: ${slugPath}`);
141
+ result.corruptSlugsRemoved.push(slugPath);
142
+ rm(slugPath);
143
+ }
144
+ }
145
+ const verb = dryRun ? 'would remove' : 'removed';
146
+ console.log(`🧹 housekeeping: scanned ${result.scannedSlugs} slug(s); ${verb} ` +
147
+ `${result.subagentsRemoved.length} stale sub-agent artifact(s), ` +
148
+ `${result.corruptSlugsRemoved.length} empty/corrupt slug(s)` +
149
+ (dryRun ? ' (dry-run — set OSBORN_HOUSEKEEPING_DRYRUN=0 to enable deletion)' : ''));
150
+ return result;
151
+ }
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ const __filename = fileURLToPath(import.meta.url);
34
34
  const __dirname = dirname(__filename);
35
35
  import { createPatch } from 'diff';
36
36
  import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getDirectConfig, listSessions, listAllClaudeSessions, invalidateSessionListCache, getMostRecentSessionId, sessionExists, getSessionSummary, getConversationHistory, ensureSessionWorkspace, getMcpServerStatusList, buildMcpServersForKeys, listWorkspaceArtifacts } from './config.js';
37
+ import { cleanupHousekeeping } from './housekeeping.js';
37
38
  import { createSTT, createTTS } from './voice-io.js';
38
39
  import { createClaudeLLM, NAMED_AGENTS, applyTurbo } from './claude-llm.js';
39
40
  import { clearPipelineFastBrainSession, prewarmBM25Index } from './pipeline-fastbrain.js';
@@ -2785,6 +2786,28 @@ async function main() {
2785
2786
  // Track the active resume session ID across scopes (ParticipantConnected + DataReceived)
2786
2787
  // Updated by resume_session, session_selected, continue_session, switch_session handlers
2787
2788
  let currentResumeSessionId;
2789
+ // Housekeeping: prune stale sub-agent transcripts (>24h) + empty/corrupt slug dirs. Ships
2790
+ // DRY-RUN by default (OSBORN_HOUSEKEEPING_DRYRUN != '0') so it only LOGS what it would remove
2791
+ // until we flip the env. Reads the active session live so it never touches the in-use subtree.
2792
+ const runHousekeeping = () => {
2793
+ try {
2794
+ const envDryRun = process.env.OSBORN_HOUSEKEEPING_DRYRUN !== '0';
2795
+ const activeSessionId = currentLLM?.sessionId ?? currentResumeSessionId ?? null;
2796
+ // Force dry-run whenever NO session is active (e.g. the boot run): with activeSessionId
2797
+ // null the in-use-session guards are inert, so only LOG then — real deletion waits until
2798
+ // a session is established (the periodic 6h runs), when the guards are meaningful.
2799
+ const dryRun = envDryRun || !activeSessionId;
2800
+ const res = cleanupHousekeeping({ dryRun, activeSessionId });
2801
+ if (!dryRun && (res.subagentsRemoved.length > 0 || res.corruptSlugsRemoved.length > 0)) {
2802
+ invalidateSessionListCache();
2803
+ }
2804
+ }
2805
+ catch (e) {
2806
+ console.warn('🧹 housekeeping failed:', e);
2807
+ }
2808
+ };
2809
+ runHousekeeping();
2810
+ setInterval(runHousekeeping, 6 * 60 * 60 * 1000);
2788
2811
  // Claude auth code submission handler (set during OAuth flow, cleared after)
2789
2812
  let pendingAuthSubmitCode = null;
2790
2813
  // Task deduplication guard - prevents Gemini re-execution loops
@@ -255,6 +255,16 @@ function buildSystemPrompt(chatHistory, researchContext) {
255
255
  `Greetings/thanks/confirmations: respond directly, no search.`,
256
256
  `Tasks needing live code analysis or new research: respond with [RESEARCH_NEEDED]`,
257
257
  ``,
258
+ `== DECISIONS: DEFER, DON'T ASSERT ==`,
259
+ `The grounded main agent answers the SAME question in parallel with full context — it owns`,
260
+ `decisions. For questions asking what was DECIDED / CHOSEN / SCOPED / AGREED / "what were the`,
261
+ `options" / "what did we settle on" / "remind me what we decided": do NOT state a definitive`,
262
+ `answer from one keyword hit. A single matched line is often a passing mention, not the`,
263
+ `decision (e.g. a file that "covers WhatsApp and LinkedIn" is NOT the scoping decision).`,
264
+ `Only assert a decision if MULTIPLE recent hits corroborate it AND one is clearly the decision`,
265
+ `itself. Otherwise hedge briefly and defer — e.g. "I think it involved X, but let the main`,
266
+ `agent confirm the exact decision" — or emit [RESEARCH_NEEDED]. Never contradict-with-confidence.`,
267
+ ``,
258
268
  `== STYLE ==`,
259
269
  `1-3 sentences. Grounded in results. Never fabricate.`,
260
270
  `If not found after thorough searching: "I didn't find that in the session history."`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.236",
3
+ "version": "0.9.238",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {