codehere 0.5.0-alpha.3 → 0.5.0-alpha.5

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,140 @@
1
+ /**
2
+ * Project thread reader — v0.6.0 M1 (UI-prep slice).
3
+ *
4
+ * Reads ~/.codehere/projects/<slug>/thread.jsonl files and reconstructs
5
+ * the per-project thread state for API consumers (the new UI rendering
6
+ * the persistent project thread shape).
7
+ *
8
+ * Reconstruction rules:
9
+ * - One file per project, identified by slug (sha1(cwd)[:12])
10
+ * - First line is `{_meta: 'project', ...}` — project header
11
+ * - Subsequent lines are `{_meta: 'project-update', ...}` (latest wins per
12
+ * field — Title agent uses this to set the title) OR `{type: 'task', ...}`
13
+ * (per-session entries; later entries with same sessionId override
14
+ * earlier — covers spawn entry → completion entry update)
15
+ *
16
+ * NOT in scope of this module:
17
+ * - Writing (handled by project-thread-mirror + project-thread-migration)
18
+ * - UI rendering (the new thread view will consume this reader's output)
19
+ * - Live SSE updates (polling is fine for the alpha; SSE in v0.6.x patch)
20
+ */
21
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { homedir } from 'os';
24
+ const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
25
+ /** List all known projects with summary stats. Cheap; no full task content. */
26
+ export function listProjects(options) {
27
+ const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
28
+ if (!existsSync(root))
29
+ return [];
30
+ let stat;
31
+ try {
32
+ stat = statSync(root);
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ if (!stat.isDirectory())
38
+ return [];
39
+ const slugs = readdirSync(root).filter((entry) => {
40
+ const p = join(root, entry);
41
+ try {
42
+ return statSync(p).isDirectory();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ });
48
+ const summaries = [];
49
+ for (const slug of slugs) {
50
+ const thread = readProjectThread(slug, { projectsDir: root, taskLimit: 0 });
51
+ if (thread) {
52
+ summaries.push({
53
+ slug: thread.slug,
54
+ cwd: thread.cwd,
55
+ title: thread.title,
56
+ taskCount: thread.taskCount,
57
+ lastActivityAt: thread.lastActivityAt,
58
+ });
59
+ }
60
+ }
61
+ // Sort by recency (most recent first)
62
+ summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
63
+ return summaries;
64
+ }
65
+ /** Read a single project thread by slug. Returns null if not found. */
66
+ export function readProjectThread(slug, options) {
67
+ const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
68
+ const file = join(root, slug, 'thread.jsonl');
69
+ if (!existsSync(file))
70
+ return null;
71
+ let content;
72
+ try {
73
+ content = readFileSync(file, 'utf-8');
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ let cwd = null;
79
+ let title = null;
80
+ const tasksById = new Map();
81
+ for (const line of content.split('\n')) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed)
84
+ continue;
85
+ let rec;
86
+ try {
87
+ rec = JSON.parse(trimmed);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (rec._meta === 'project') {
93
+ if (typeof rec.cwd === 'string')
94
+ cwd = rec.cwd;
95
+ if (typeof rec.title === 'string')
96
+ title = rec.title;
97
+ }
98
+ else if (rec._meta === 'project-update') {
99
+ if (typeof rec.title === 'string')
100
+ title = rec.title;
101
+ if (typeof rec.cwd === 'string' && cwd === null)
102
+ cwd = rec.cwd;
103
+ }
104
+ else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
105
+ const existing = tasksById.get(rec.sessionId) ?? {
106
+ sessionId: rec.sessionId,
107
+ agent: '',
108
+ prompt: '',
109
+ cwd: '',
110
+ startedAt: 0,
111
+ };
112
+ // Merge fields — later entries override earlier (covers
113
+ // spawn → completion update pattern from forward-mirror)
114
+ for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
115
+ if (rec[k] !== undefined) {
116
+ existing[k] = rec[k];
117
+ }
118
+ }
119
+ tasksById.set(rec.sessionId, existing);
120
+ }
121
+ }
122
+ const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
123
+ const lastActivityAt = tasks.reduce((acc, t) => {
124
+ const ts = t.completedAt ?? t.startedAt;
125
+ if (ts && (acc === null || ts > acc))
126
+ return ts;
127
+ return acc;
128
+ }, null);
129
+ const limit = options?.taskLimit;
130
+ const cappedTasks = limit === undefined ? tasks : limit === 0 ? [] : tasks.slice(-limit);
131
+ return {
132
+ slug,
133
+ cwd,
134
+ title,
135
+ taskCount: tasks.length,
136
+ tasks: cappedTasks,
137
+ lastActivityAt,
138
+ };
139
+ }
140
+ //# sourceMappingURL=project-thread-reader.js.map
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Title backfill — v0.6.0 follow-up to M9.
3
+ *
4
+ * The M9 hidden Title agent (generate-thread-title.ts) only fires on the FIRST
5
+ * task entry written to a project's thread.jsonl. Projects seeded by the v0.6
6
+ * migration (project-thread-migration.ts) bypass that path — they get a
7
+ * project-meta header but no Title agent invocation. Result: 5/7 projects on
8
+ * a typical dogfood machine show "(untitled)" in the /threads switcher even
9
+ * though their first task carries enough signal to title from.
10
+ *
11
+ * This backfill closes that gap. For every project with title=null and at
12
+ * least one task entry, it fires the Title agent on the first task's prompt +
13
+ * cwd and appends a `{_meta: 'project-update', title: ...}` line. Reader
14
+ * (project-thread-reader.ts) takes the latest project-update title.
15
+ *
16
+ * Idempotent: skips projects that already have a title (or that have NO tasks
17
+ * to title from). Safe to run on every daemon boot. Best-effort: any LLM
18
+ * failure (provider missing, timeout, rate limit) skips that project silently.
19
+ *
20
+ * Cost budget: ~1 LLM call per untitled project on first run after this lands.
21
+ * Subsequent boots are no-ops since titles persist.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync, appendFileSync, statSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { homedir } from 'os';
26
+ import { generateThreadTitle } from '../../application/use-cases/generate-thread-title.js';
27
+ const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
28
+ function scanThread(slug, file) {
29
+ let title = null;
30
+ let firstPrompt = null;
31
+ let firstCwd = null;
32
+ let firstStartedAt = Number.MAX_SAFE_INTEGER;
33
+ let content;
34
+ try {
35
+ content = readFileSync(file, 'utf-8');
36
+ }
37
+ catch {
38
+ return { slug, title, firstPrompt, firstCwd };
39
+ }
40
+ for (const line of content.split('\n')) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed)
43
+ continue;
44
+ let rec;
45
+ try {
46
+ rec = JSON.parse(trimmed);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (rec._meta === 'project') {
52
+ if (typeof rec.title === 'string')
53
+ title = rec.title;
54
+ }
55
+ else if (rec._meta === 'project-update') {
56
+ if (typeof rec.title === 'string')
57
+ title = rec.title;
58
+ }
59
+ else if (rec.type === 'task') {
60
+ const startedAt = typeof rec.startedAt === 'number' ? rec.startedAt : Number.MAX_SAFE_INTEGER;
61
+ if (startedAt < firstStartedAt) {
62
+ firstStartedAt = startedAt;
63
+ if (typeof rec.prompt === 'string')
64
+ firstPrompt = rec.prompt;
65
+ if (typeof rec.cwd === 'string')
66
+ firstCwd = rec.cwd;
67
+ }
68
+ }
69
+ }
70
+ return { slug, title, firstPrompt, firstCwd };
71
+ }
72
+ /**
73
+ * Backfill missing titles. Returns counts; never throws (best-effort per project).
74
+ *
75
+ * Disable via CODEHERE_DISABLE_TITLE_AGENT=1 (same env var that gates M9 itself —
76
+ * keeps the toggle surface minimal).
77
+ */
78
+ export async function backfillThreadTitles(options) {
79
+ const start = Date.now();
80
+ const result = {
81
+ scanned: 0,
82
+ alreadyTitled: 0,
83
+ noTasks: 0,
84
+ titlesGenerated: 0,
85
+ titleAgentFailed: 0,
86
+ durationMs: 0,
87
+ };
88
+ if (process.env.CODEHERE_DISABLE_TITLE_AGENT === '1') {
89
+ result.durationMs = Date.now() - start;
90
+ return result;
91
+ }
92
+ const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
93
+ if (!existsSync(root)) {
94
+ result.durationMs = Date.now() - start;
95
+ return result;
96
+ }
97
+ let stat;
98
+ try {
99
+ stat = statSync(root);
100
+ }
101
+ catch {
102
+ result.durationMs = Date.now() - start;
103
+ return result;
104
+ }
105
+ if (!stat.isDirectory()) {
106
+ result.durationMs = Date.now() - start;
107
+ return result;
108
+ }
109
+ let slugs;
110
+ try {
111
+ slugs = readdirSync(root);
112
+ }
113
+ catch {
114
+ result.durationMs = Date.now() - start;
115
+ return result;
116
+ }
117
+ const cap = options?.maxProjects ?? Number.MAX_SAFE_INTEGER;
118
+ let backfilledCount = 0;
119
+ for (const slug of slugs) {
120
+ if (backfilledCount >= cap)
121
+ break;
122
+ const projectDir = join(root, slug);
123
+ let s;
124
+ try {
125
+ s = statSync(projectDir);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (!s.isDirectory())
131
+ continue;
132
+ const file = join(projectDir, 'thread.jsonl');
133
+ if (!existsSync(file))
134
+ continue;
135
+ result.scanned++;
136
+ const scanned = scanThread(slug, file);
137
+ if (scanned.title) {
138
+ result.alreadyTitled++;
139
+ continue;
140
+ }
141
+ if (!scanned.firstPrompt || !scanned.firstCwd) {
142
+ // Nothing to title from — skip. (Migration-seeded headers without task
143
+ // entries fall here, as do projects where the first task lost its prompt
144
+ // somehow.)
145
+ result.noTasks++;
146
+ continue;
147
+ }
148
+ let title = null;
149
+ try {
150
+ title = await generateThreadTitle(scanned.firstPrompt, scanned.firstCwd);
151
+ }
152
+ catch {
153
+ // Title agent should never throw (it returns null on failure), but guard
154
+ // anyway so one bad call doesn't sink the whole backfill.
155
+ title = null;
156
+ }
157
+ if (!title) {
158
+ result.titleAgentFailed++;
159
+ continue;
160
+ }
161
+ try {
162
+ const updateLine = JSON.stringify({
163
+ _meta: 'project-update',
164
+ slug,
165
+ title,
166
+ cwd: scanned.firstCwd,
167
+ backfilledAt: Date.now(),
168
+ updatedAt: Date.now(),
169
+ });
170
+ appendFileSync(file, updateLine + '\n', 'utf-8');
171
+ result.titlesGenerated++;
172
+ backfilledCount++;
173
+ }
174
+ catch {
175
+ // Append failure is logged via counter but doesn't sink the loop.
176
+ result.titleAgentFailed++;
177
+ }
178
+ }
179
+ result.durationMs = Date.now() - start;
180
+ return result;
181
+ }
182
+ //# sourceMappingURL=project-thread-title-backfill.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.5.0-alpha.3",
3
+ "version": "0.5.0-alpha.5",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -65,7 +65,9 @@
65
65
  "cli-table3": "^0.6.5",
66
66
  "commander": "^11.1.0",
67
67
  "dotenv": "^16.3.1",
68
- "ora": "^7.0.1"
68
+ "gray-matter": "^4.0.3",
69
+ "ora": "^7.0.1",
70
+ "yaml": "^2.8.4"
69
71
  },
70
72
  "devDependencies": {
71
73
  "@eslint/js": "^10.0.1",