agent-companion 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.
@@ -0,0 +1,504 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const SESSION_STATES = new Set(["RUNNING", "WAITING_INPUT", "COMPLETED", "FAILED", "CANCELLED"]);
6
+ const EVENT_CATEGORIES = new Set(["INFO", "ACTION", "INPUT", "ERROR"]);
7
+ const TURN_ROLES = new Set(["USER", "ASSISTANT"]);
8
+ const TURN_KINDS = new Set(["MESSAGE", "FINAL_OUTPUT", "APPROVAL_ACTION"]);
9
+
10
+ function safeNumber(value, fallback = 0) {
11
+ const parsed = Number(value);
12
+ return Number.isFinite(parsed) ? parsed : fallback;
13
+ }
14
+
15
+ function safeTrimmedText(value, maxLength = 200) {
16
+ if (typeof value !== "string") return "";
17
+ const trimmed = value.trim();
18
+ if (!trimmed) return "";
19
+ return trimmed.slice(0, maxLength);
20
+ }
21
+
22
+ function normalizeAgentType(value) {
23
+ return value === "CLAUDE" ? "CLAUDE" : "CODEX";
24
+ }
25
+
26
+ function normalizeLookupText(value) {
27
+ if (typeof value !== "string") return "";
28
+ return value
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/\s+/g, " ");
32
+ }
33
+
34
+ function normalizeTitleText(value) {
35
+ return normalizeLookupText(value)
36
+ .replace(/[^a-z0-9 ]+/g, " ")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ }
40
+
41
+ function normalizeSessionState(value) {
42
+ return SESSION_STATES.has(value) ? value : "RUNNING";
43
+ }
44
+
45
+ function normalizePriority(value) {
46
+ return value === "HIGH" || value === "LOW" ? value : "MEDIUM";
47
+ }
48
+
49
+ function normalizeEventCategory(value) {
50
+ return EVENT_CATEGORIES.has(value) ? value : "INFO";
51
+ }
52
+
53
+ function normalizeTurnRole(value) {
54
+ return TURN_ROLES.has(value) ? value : "USER";
55
+ }
56
+
57
+ function normalizeTurnKind(value) {
58
+ return TURN_KINDS.has(value) ? value : "MESSAGE";
59
+ }
60
+
61
+ function resolveDefaultWorkspaceRoot() {
62
+ const preferred = [path.join(os.homedir(), "Documents"), path.join(os.homedir(), "Desktop"), process.cwd()];
63
+ for (const candidate of preferred) {
64
+ try {
65
+ const absolute = path.resolve(candidate);
66
+ const real = fs.realpathSync(absolute);
67
+ const stat = fs.statSync(real);
68
+ if (stat.isDirectory()) return real;
69
+ } catch {
70
+ continue;
71
+ }
72
+ }
73
+ return process.cwd();
74
+ }
75
+
76
+ function normalizeWorkspaceRoot(value, fallback = "") {
77
+ const fromInput = safeTrimmedText(value, 1000);
78
+ if (!fromInput) return fallback;
79
+ try {
80
+ const absolute = path.resolve(fromInput);
81
+ const real = fs.realpathSync(absolute);
82
+ const stat = fs.statSync(real);
83
+ if (!stat.isDirectory()) return fallback;
84
+ return real;
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ function sanitizeTokenUsage(input, fallback = null) {
91
+ const baseline = fallback && typeof fallback === "object" ? fallback : {};
92
+ const promptTokens = safeNumber(input?.promptTokens, safeNumber(baseline.promptTokens, 0));
93
+ const completionTokens = safeNumber(input?.completionTokens, safeNumber(baseline.completionTokens, 0));
94
+ const totalTokens =
95
+ safeNumber(input?.totalTokens, safeNumber(baseline.totalTokens, promptTokens + completionTokens)) ||
96
+ promptTokens + completionTokens;
97
+ const costUsd = safeNumber(input?.costUsd, safeNumber(baseline.costUsd, 0));
98
+
99
+ return {
100
+ promptTokens,
101
+ completionTokens,
102
+ totalTokens,
103
+ costUsd
104
+ };
105
+ }
106
+
107
+ function sanitizeSession(input, fallback = null) {
108
+ const now = Date.now();
109
+ const baseline = fallback && typeof fallback === "object" ? fallback : {};
110
+ const title = safeTrimmedText(input?.title, 140) || safeTrimmedText(baseline.title, 140) || "Untitled session";
111
+ const repo = safeTrimmedText(input?.repo, 120) || safeTrimmedText(baseline.repo, 120) || "unknown-repo";
112
+ const branch = safeTrimmedText(input?.branch, 120) || safeTrimmedText(baseline.branch, 120) || "main";
113
+ const progress = Math.max(0, Math.min(100, safeNumber(input?.progress, safeNumber(baseline.progress, 0))));
114
+
115
+ return {
116
+ id:
117
+ safeTrimmedText(input?.id, 160) ||
118
+ safeTrimmedText(baseline.id, 160) ||
119
+ `session_${now}_${Math.floor(Math.random() * 1000)}`,
120
+ agentType: normalizeAgentType(input?.agentType || baseline.agentType),
121
+ title,
122
+ repo,
123
+ branch,
124
+ state: normalizeSessionState(input?.state || baseline.state),
125
+ lastUpdated: safeNumber(input?.lastUpdated, safeNumber(baseline.lastUpdated, now)),
126
+ progress,
127
+ tokenUsage: sanitizeTokenUsage(input?.tokenUsage, baseline.tokenUsage)
128
+ };
129
+ }
130
+
131
+ function sanitizePendingInput(input) {
132
+ const now = Date.now();
133
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
134
+ if (!sessionId) return null;
135
+
136
+ return {
137
+ id:
138
+ safeTrimmedText(input?.id, 160) ||
139
+ `p_${now}_${Math.floor(Math.random() * 1000)}`,
140
+ sessionId,
141
+ prompt: safeTrimmedText(input?.prompt, 1000) || "Input requested",
142
+ requestedAt: safeNumber(input?.requestedAt, now),
143
+ priority: normalizePriority(input?.priority),
144
+ actionable: typeof input?.actionable === "boolean" ? input.actionable : undefined,
145
+ source: safeTrimmedText(input?.source, 32) || undefined,
146
+ meta: input?.meta && typeof input.meta === "object" ? input.meta : null
147
+ };
148
+ }
149
+
150
+ function sanitizeEvent(input) {
151
+ const now = Date.now();
152
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
153
+ if (!sessionId) return null;
154
+
155
+ return {
156
+ id:
157
+ safeTrimmedText(input?.id, 160) ||
158
+ `e_${now}_${Math.floor(Math.random() * 1000)}`,
159
+ sessionId,
160
+ summary: safeTrimmedText(input?.summary, 300) || "Event",
161
+ timestamp: safeNumber(input?.timestamp, now),
162
+ category: normalizeEventCategory(input?.category)
163
+ };
164
+ }
165
+
166
+ function buildSessionThreadLookupKey(input) {
167
+ const agentType = normalizeAgentType(input?.agentType);
168
+ const normalizedTitle = normalizeTitleText(input?.title) || "untitled";
169
+ const workspacePath = safeTrimmedText(input?.workspacePath, 500);
170
+ const normalizedWorkspace = workspacePath
171
+ ? workspacePath.toLowerCase()
172
+ : `repo:${normalizeLookupText(input?.repo) || "unknown"}`;
173
+ return `${agentType}|${normalizedWorkspace}|${normalizedTitle}`;
174
+ }
175
+
176
+ function sanitizeSessionThread(input, fallback = null) {
177
+ const now = Date.now();
178
+ const baseline = fallback && typeof fallback === "object" ? fallback : {};
179
+ const title = safeTrimmedText(input?.title, 140) || safeTrimmedText(baseline.title, 140) || "Untitled session";
180
+ const repo = safeTrimmedText(input?.repo, 120) || safeTrimmedText(baseline.repo, 120) || "unknown-repo";
181
+ const branch = safeTrimmedText(input?.branch, 120) || safeTrimmedText(baseline.branch, 120) || "main";
182
+ const workspacePath = safeTrimmedText(input?.workspacePath, 500) || safeTrimmedText(baseline.workspacePath, 500);
183
+ const normalizedTitle = normalizeTitleText(input?.normalizedTitle || title) || "untitled";
184
+ const lookupKey =
185
+ safeTrimmedText(input?.lookupKey, 640) ||
186
+ safeTrimmedText(input?.key, 640) ||
187
+ safeTrimmedText(baseline.lookupKey, 640) ||
188
+ safeTrimmedText(baseline.key, 640) ||
189
+ buildSessionThreadLookupKey({
190
+ agentType: input?.agentType || baseline.agentType,
191
+ workspacePath,
192
+ repo,
193
+ title: normalizedTitle
194
+ });
195
+
196
+ return {
197
+ id:
198
+ safeTrimmedText(input?.id, 160) ||
199
+ safeTrimmedText(baseline.id, 160) ||
200
+ `session_${now}_${Math.floor(Math.random() * 1000)}`,
201
+ key: safeTrimmedText(input?.key, 640) || safeTrimmedText(baseline.key, 640) || lookupKey,
202
+ lookupKey,
203
+ agentType: normalizeAgentType(input?.agentType || baseline.agentType),
204
+ workspacePath,
205
+ repo,
206
+ branch,
207
+ title,
208
+ normalizedTitle,
209
+ createdAt: safeNumber(input?.createdAt, safeNumber(baseline.createdAt, now)),
210
+ updatedAt: safeNumber(input?.updatedAt, safeNumber(baseline.updatedAt, now)),
211
+ lastRunId: safeTrimmedText(input?.lastRunId, 160) || safeTrimmedText(baseline.lastRunId, 160) || null,
212
+ runCount: Math.max(0, Math.floor(safeNumber(input?.runCount, safeNumber(baseline.runCount, 0)))),
213
+ lastMessageAt: safeNumber(input?.lastMessageAt, safeNumber(baseline.lastMessageAt, 0)),
214
+ codexThreadId: safeTrimmedText(input?.codexThreadId, 120) || safeTrimmedText(baseline.codexThreadId, 120) || null,
215
+ claudeSessionId:
216
+ safeTrimmedText(input?.claudeSessionId, 120).toLowerCase() ||
217
+ safeTrimmedText(baseline.claudeSessionId, 120).toLowerCase() ||
218
+ null
219
+ };
220
+ }
221
+
222
+ function sanitizeChatTurn(input) {
223
+ const now = Date.now();
224
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
225
+ if (!sessionId) return null;
226
+
227
+ const text = String(input?.text ?? "")
228
+ .replace(/\u0000/g, "")
229
+ .trim()
230
+ .slice(0, 12_000);
231
+ if (!text) return null;
232
+
233
+ return {
234
+ id:
235
+ safeTrimmedText(input?.id, 180) ||
236
+ `turn_${now}_${Math.floor(Math.random() * 1000)}`,
237
+ sessionId,
238
+ role: normalizeTurnRole(input?.role),
239
+ kind: normalizeTurnKind(input?.kind),
240
+ text,
241
+ createdAt: safeNumber(input?.createdAt, now),
242
+ runId: safeTrimmedText(input?.runId, 160) || null,
243
+ approvalId: safeTrimmedText(input?.approvalId, 160) || null,
244
+ source: safeTrimmedText(input?.source, 48) || "LEGACY"
245
+ };
246
+ }
247
+
248
+ function createSession(id, agentType, title, repo, branch, state, lastUpdatedOffsetMs, progress, tokenUsage) {
249
+ return sanitizeSession({
250
+ id,
251
+ agentType,
252
+ title,
253
+ repo,
254
+ branch,
255
+ state,
256
+ lastUpdated: Date.now() - lastUpdatedOffsetMs,
257
+ progress,
258
+ tokenUsage
259
+ });
260
+ }
261
+
262
+ function buildDefaultThreads(sessions) {
263
+ return sessions.map((session) =>
264
+ sanitizeSessionThread({
265
+ id: session.id,
266
+ key: buildSessionThreadLookupKey({
267
+ agentType: session.agentType,
268
+ workspacePath: "",
269
+ repo: session.repo,
270
+ title: session.title
271
+ }),
272
+ lookupKey: buildSessionThreadLookupKey({
273
+ agentType: session.agentType,
274
+ workspacePath: "",
275
+ repo: session.repo,
276
+ title: session.title
277
+ }),
278
+ agentType: session.agentType,
279
+ workspacePath: "",
280
+ repo: session.repo,
281
+ branch: session.branch,
282
+ title: session.title,
283
+ normalizedTitle: normalizeTitleText(session.title),
284
+ createdAt: session.lastUpdated,
285
+ updatedAt: session.lastUpdated,
286
+ runCount: 1,
287
+ lastMessageAt: session.lastUpdated
288
+ })
289
+ );
290
+ }
291
+
292
+ export function buildDefaultState() {
293
+ const now = Date.now();
294
+
295
+ const sessions = [
296
+ createSession(
297
+ "s_codex_live_01",
298
+ "CODEX",
299
+ "Refactor notification pipeline",
300
+ "agent-control-plane",
301
+ "feature/queue-replay",
302
+ "RUNNING",
303
+ 16_000,
304
+ 64,
305
+ {
306
+ promptTokens: 27120,
307
+ completionTokens: 18340,
308
+ totalTokens: 45460,
309
+ costUsd: 0.62
310
+ }
311
+ ),
312
+ createSession(
313
+ "s_claude_live_01",
314
+ "CLAUDE",
315
+ "Fix websocket reconnect",
316
+ "agent-bridge",
317
+ "bugfix/retry-loop",
318
+ "WAITING_INPUT",
319
+ 75_000,
320
+ 79,
321
+ {
322
+ promptTokens: 18110,
323
+ completionTokens: 11040,
324
+ totalTokens: 29150,
325
+ costUsd: 0.43
326
+ }
327
+ ),
328
+ createSession(
329
+ "s_codex_live_02",
330
+ "CODEX",
331
+ "Token analytics API",
332
+ "agent-api",
333
+ "feat/token-metrics",
334
+ "COMPLETED",
335
+ 4_300_000,
336
+ 100,
337
+ {
338
+ promptTokens: 32002,
339
+ completionTokens: 22994,
340
+ totalTokens: 54996,
341
+ costUsd: 0.78
342
+ }
343
+ )
344
+ ];
345
+
346
+ const pendingInputs = [
347
+ sanitizePendingInput({
348
+ id: "p_live_01",
349
+ sessionId: "s_claude_live_01",
350
+ prompt: "Can I cap backoff at 45s and ship this patch?",
351
+ requestedAt: now - 75_000,
352
+ priority: "HIGH"
353
+ })
354
+ ].filter(Boolean);
355
+
356
+ const events = [
357
+ sanitizeEvent({
358
+ id: "e_live_101",
359
+ sessionId: "s_codex_live_01",
360
+ summary: "Running integration tests on queue replay logic.",
361
+ timestamp: now - 16_000,
362
+ category: "INFO"
363
+ }),
364
+ sanitizeEvent({
365
+ id: "e_live_201",
366
+ sessionId: "s_claude_live_01",
367
+ summary: "Input requested: confirm retry cap before patch.",
368
+ timestamp: now - 75_000,
369
+ category: "INPUT"
370
+ }),
371
+ sanitizeEvent({
372
+ id: "e_live_301",
373
+ sessionId: "s_codex_live_02",
374
+ summary: "Session completed: endpoint + tests merged.",
375
+ timestamp: now - 4_300_000,
376
+ category: "ACTION"
377
+ })
378
+ ].filter(Boolean);
379
+
380
+ const settings = {
381
+ criticalRealtime: true,
382
+ digest: true,
383
+ pairingHealthy: true,
384
+ metadataOnly: true,
385
+ darkLocked: true,
386
+ networkOnline: true,
387
+ workspaceRoot: resolveDefaultWorkspaceRoot()
388
+ };
389
+
390
+ const sessionThreads = buildDefaultThreads(sessions);
391
+ const chatTurns = [
392
+ sanitizeChatTurn({
393
+ id: "turn_live_01",
394
+ sessionId: "s_claude_live_01",
395
+ role: "ASSISTANT",
396
+ kind: "FINAL_OUTPUT",
397
+ text: "Can I cap backoff at 45s and ship this patch?",
398
+ createdAt: now - 75_000,
399
+ source: "LEGACY"
400
+ })
401
+ ].filter(Boolean);
402
+
403
+ return {
404
+ source: "bridge",
405
+ sessions,
406
+ sessionThreads,
407
+ chatTurns,
408
+ pendingInputs,
409
+ events,
410
+ pendingHandledAt: {},
411
+ settings
412
+ };
413
+ }
414
+
415
+ function sanitizePendingHandledAt(input) {
416
+ if (!input || typeof input !== "object") return {};
417
+ const out = {};
418
+ for (const [key, value] of Object.entries(input)) {
419
+ const id = safeTrimmedText(key, 180);
420
+ if (!id) continue;
421
+ const ts = safeNumber(value, 0);
422
+ if (ts <= 0) continue;
423
+ out[id] = ts;
424
+ }
425
+ return out;
426
+ }
427
+
428
+ export function sanitizeState(raw) {
429
+ const fallback = buildDefaultState();
430
+
431
+ if (!raw || typeof raw !== "object") {
432
+ return fallback;
433
+ }
434
+
435
+ const candidate = raw;
436
+
437
+ const sessions = (
438
+ Array.isArray(candidate.sessions) ? candidate.sessions : fallback.sessions
439
+ )
440
+ .map((session) => sanitizeSession(session))
441
+ .filter(Boolean);
442
+
443
+ const pendingInputs = (
444
+ Array.isArray(candidate.pendingInputs) ? candidate.pendingInputs : fallback.pendingInputs
445
+ )
446
+ .map((item) => sanitizePendingInput(item))
447
+ .filter(Boolean);
448
+
449
+ const events = (Array.isArray(candidate.events) ? candidate.events : fallback.events)
450
+ .map((event) => sanitizeEvent(event))
451
+ .filter(Boolean);
452
+
453
+ const sessionThreadMap = new Map();
454
+ const rawThreads = Array.isArray(candidate.sessionThreads) ? candidate.sessionThreads : [];
455
+ for (const item of rawThreads) {
456
+ const sanitized = sanitizeSessionThread(item);
457
+ if (!sanitized) continue;
458
+ sessionThreadMap.set(sanitized.id, sanitized);
459
+ }
460
+
461
+ for (const session of sessions) {
462
+ if (sessionThreadMap.has(session.id)) continue;
463
+ const derived = sanitizeSessionThread({
464
+ id: session.id,
465
+ agentType: session.agentType,
466
+ workspacePath: "",
467
+ repo: session.repo,
468
+ branch: session.branch,
469
+ title: session.title,
470
+ normalizedTitle: normalizeTitleText(session.title),
471
+ createdAt: session.lastUpdated,
472
+ updatedAt: session.lastUpdated,
473
+ runCount: 0,
474
+ lastMessageAt: session.lastUpdated
475
+ });
476
+ sessionThreadMap.set(derived.id, derived);
477
+ }
478
+
479
+ const sessionThreads = [...sessionThreadMap.values()].sort((a, b) => b.updatedAt - a.updatedAt);
480
+ const knownSessionIds = new Set(sessionThreads.map((thread) => thread.id));
481
+
482
+ const chatTurns = (Array.isArray(candidate.chatTurns) ? candidate.chatTurns : fallback.chatTurns)
483
+ .map((turn) => sanitizeChatTurn(turn))
484
+ .filter((turn) => Boolean(turn && knownSessionIds.has(turn.sessionId)))
485
+ .sort((a, b) => a.createdAt - b.createdAt);
486
+
487
+ return {
488
+ settings:
489
+ candidate.settings && typeof candidate.settings === "object"
490
+ ? {
491
+ ...fallback.settings,
492
+ ...candidate.settings,
493
+ workspaceRoot: normalizeWorkspaceRoot(candidate.settings.workspaceRoot, fallback.settings.workspaceRoot)
494
+ }
495
+ : fallback.settings,
496
+ source: "bridge",
497
+ sessions,
498
+ sessionThreads,
499
+ chatTurns,
500
+ pendingInputs,
501
+ events,
502
+ pendingHandledAt: sanitizePendingHandledAt(candidate.pendingHandledAt)
503
+ };
504
+ }