@adhdev/daemon-core 0.9.82-rc.267 → 0.9.82-rc.268

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.267",
3
+ "version": "0.9.82-rc.268",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.267",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.268",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -33,6 +33,13 @@ import { filterUserFacingChatMessages, normalizeChatMessages } from '../provider
33
33
 
34
34
  const RECENT_SEND_WINDOW_MS = 1200;
35
35
  export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
36
+ // Minimum tail floor for hot-path history/mirror reads. The dashboard requests a
37
+ // bounded tail (~60); we keep a small floor so a tiny requested tailLimit still
38
+ // has enough surrounding context for seed/mirror dedup correctness, but it must
39
+ // NOT dominate the hot subscribe/poll path the way the previous 200 floor did.
40
+ // readChatHistory now serves this as an O(tail) bounded read, so the cost scales
41
+ // with this floor, not with total accumulated history.
42
+ const HOT_TAIL_MIN_LIMIT = 60;
36
43
  const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
37
44
  // (A2.2) CLI_NATIVE_HISTORY_FRESH_MS removed with isNativeHistoryFreshEnough.
38
45
  // Hardcoded native-transcript provider allow-list. Deprecated. Kept only as a
@@ -963,7 +970,7 @@ function readExactRuntimeMirrorMessages(args: {
963
970
  const history = readChatHistory(
964
971
  args.providerType,
965
972
  0,
966
- Math.max(args.tailLimit || 0, 200),
973
+ Math.max(args.tailLimit || 0, HOT_TAIL_MIN_LIMIT),
967
974
  targetSessionId,
968
975
  0,
969
976
  args.historyBehavior,
@@ -2206,7 +2213,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2206
2213
  const nativeHistoryLimit = Math.max(
2207
2214
  normalizeReadChatTailLimit(args) || 0,
2208
2215
  returnedMessages.length,
2209
- 200,
2216
+ HOT_TAIL_MIN_LIMIT,
2210
2217
  );
2211
2218
  const nativeHistorySessionId = supportsNative
2212
2219
  ? resolveCliNativeHistorySessionId(args, historySessionId, providerSessionId)
@@ -58,6 +58,40 @@ const savedHistoryFileSummaryCache = new Map<string, SavedHistoryFileSummaryCach
58
58
  const savedHistoryBackgroundRefresh = new Set<string>();
59
59
  const savedHistoryRollupInFlight = new Set<string>();
60
60
 
61
+ // Bounded-tail read cache. The dashboard re-subscribes and polls hot sessions
62
+ // every ~2.5s; without a cache each poll re-reads/parses/sorts the whole
63
+ // conversation just to slice a small tail. We key on (type, sessionId,
64
+ // pagination args) plus the on-disk size+mtime signature so an UNCHANGED
65
+ // session returns the previously computed tail in O(1) and only re-reads when a
66
+ // new message is appended (signature changes). The map is bounded by a small
67
+ // LRU to keep memory flat regardless of how many sessions are touched.
68
+ interface BoundedTailCacheEntry {
69
+ signature: string;
70
+ result: { messages: HistoryMessage[]; hasMore: boolean };
71
+ }
72
+
73
+ const BOUNDED_TAIL_CACHE_MAX_ENTRIES = 64;
74
+ const boundedTailReadCache = new Map<string, BoundedTailCacheEntry>();
75
+
76
+ function readBoundedTailCache(key: string, signature: string): { messages: HistoryMessage[]; hasMore: boolean } | null {
77
+ const cached = boundedTailReadCache.get(key);
78
+ if (!cached || cached.signature !== signature) return null;
79
+ // Refresh LRU recency.
80
+ boundedTailReadCache.delete(key);
81
+ boundedTailReadCache.set(key, cached);
82
+ return cached.result;
83
+ }
84
+
85
+ function writeBoundedTailCache(key: string, signature: string, result: { messages: HistoryMessage[]; hasMore: boolean }): void {
86
+ boundedTailReadCache.delete(key);
87
+ boundedTailReadCache.set(key, { signature, result });
88
+ while (boundedTailReadCache.size > BOUNDED_TAIL_CACHE_MAX_ENTRIES) {
89
+ const oldest = boundedTailReadCache.keys().next().value;
90
+ if (oldest === undefined) break;
91
+ boundedTailReadCache.delete(oldest);
92
+ }
93
+ }
94
+
61
95
  interface HistoryMessage {
62
96
  ts: string; // ISO timestamp
63
97
  receivedAt: number; // epoch ms
@@ -1180,6 +1214,79 @@ function pageHistoryRecords(
1180
1214
  return { messages: sliced, hasMore: startInclusive > 0 };
1181
1215
  }
1182
1216
 
1217
+ // A finite tail request can be served by reading only the newest files instead
1218
+ // of the whole conversation. Treat very large limits (e.g. MAX_SAFE_INTEGER, or
1219
+ // anything past a generous ceiling) as a full-history request so restore/seed
1220
+ // callers keep their existing behavior.
1221
+ const BOUNDED_TAIL_MAX_LIMIT = 5_000;
1222
+ // Slack added to the requested window before sorting/dedup/collapse so the
1223
+ // boundary message at the top of the tail dedupes/collapses identically to a
1224
+ // full read. Modest and bounded — it only widens the parse window, not output.
1225
+ const BOUNDED_TAIL_SLACK = 50;
1226
+
1227
+ function isBoundedTailRequest(limit: number, offset: number, excludeRecentCount: number): boolean {
1228
+ const numericLimit = Number(limit);
1229
+ if (!Number.isFinite(numericLimit) || numericLimit <= 0) return false;
1230
+ if (numericLimit > BOUNDED_TAIL_MAX_LIMIT) return false;
1231
+ const numericOffset = Number(offset);
1232
+ const numericExclude = Number(excludeRecentCount);
1233
+ if (!Number.isFinite(numericOffset) || !Number.isFinite(numericExclude)) return false;
1234
+ return true;
1235
+ }
1236
+
1237
+ // Read newest-first only as many files as needed to cover the requested window
1238
+ // plus slack. listHistoryFiles already returns files reversed (newest-first), so
1239
+ // we accumulate (de-duped) candidates from the end and stop once we have enough,
1240
+ // then hand the bounded window to pageHistoryRecords in chronological order.
1241
+ function readBoundedTailRecords(
1242
+ agentType: string,
1243
+ dir: string,
1244
+ files: string[],
1245
+ needed: number,
1246
+ ): { records: HistoryMessage[]; readAllFiles: boolean } {
1247
+ const collected: HistoryMessage[] = [];
1248
+ const seen = new Set<string>();
1249
+ let readAllFiles = true;
1250
+
1251
+ for (let f = 0; f < files.length; f++) {
1252
+ const filePath = path.join(dir, files[f]);
1253
+ let content: string;
1254
+ try {
1255
+ content = fs.readFileSync(filePath, 'utf-8');
1256
+ } catch {
1257
+ continue;
1258
+ }
1259
+ const lines = content.trim().split('\n').filter(Boolean);
1260
+ // Walk this file's lines newest-first so we fill the tail window from the
1261
+ // bottom. seen-dedup keeps the same first-wins-by-newest semantics the
1262
+ // full read produced (files are processed newest-first there too).
1263
+ for (let i = lines.length - 1; i >= 0; i--) {
1264
+ try {
1265
+ const parsed = JSON.parse(lines[i]) as HistoryMessage;
1266
+ const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
1267
+ if (!sanitizedMessage) continue;
1268
+ const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
1269
+ if (seen.has(hash)) continue;
1270
+ seen.add(hash);
1271
+ collected.push(sanitizedMessage);
1272
+ } catch { /* skip invalid lines */ }
1273
+ }
1274
+ // Stop once we have the window AND there is at least one more file (so a
1275
+ // potential older boundary message exists). If this is the last file we
1276
+ // fall through and mark the whole history as read.
1277
+ if (collected.length >= needed && f < files.length - 1) {
1278
+ readAllFiles = false;
1279
+ break;
1280
+ }
1281
+ }
1282
+
1283
+ // collected is newest-first across the bounded window; restore chronological
1284
+ // (oldest-first) order before paging. pageHistoryRecords re-sorts by
1285
+ // receivedAt regardless, so this is purely for stable input ordering.
1286
+ collected.reverse();
1287
+ return { records: collected, readAllFiles };
1288
+ }
1289
+
1183
1290
  export function readChatHistory(
1184
1291
  agentType: string,
1185
1292
  offset: number = 0,
@@ -1196,6 +1303,33 @@ export function readChatHistory(
1196
1303
  // JSONL file list — filter by persistent history key when specified
1197
1304
  const files = listHistoryFiles(dir, historySessionId);
1198
1305
 
1306
+ const bounded = isBoundedTailRequest(limit, offset, excludeRecentCount);
1307
+
1308
+ if (bounded) {
1309
+ const fileSignatures = buildSavedHistoryFileSignatureMap(dir, files);
1310
+ const cacheKey = `${sanitized}\0${historySessionId || ''}\0${offset}\0${limit}\0${excludeRecentCount}\0${historyBehavior?.collapseConsecutiveAssistantTurns ? '1' : '0'}`;
1311
+ const signature = buildSavedHistoryCacheSignature(files, fileSignatures);
1312
+ const cached = readBoundedTailCache(cacheKey, signature);
1313
+ if (cached) return cached;
1314
+
1315
+ // Window large enough that the top boundary dedupes/collapses the same
1316
+ // as a full read. hasMore reflects whether older messages exist beyond
1317
+ // the window we actually read.
1318
+ const numericLimit = Math.max(1, Number(limit));
1319
+ const numericOffset = Math.max(0, Number(offset));
1320
+ const numericExclude = Math.max(0, Number(excludeRecentCount));
1321
+ const needed = numericLimit + numericOffset + numericExclude + Math.max(BOUNDED_TAIL_SLACK, numericLimit);
1322
+ const { records, readAllFiles } = readBoundedTailRecords(agentType, dir, files, needed);
1323
+ const result = pageHistoryRecords(agentType, records, offset, limit, excludeRecentCount, historyBehavior);
1324
+ // If we read every file, the conversation is fully represented in the
1325
+ // window and pageHistoryRecords' hasMore is authoritative. If we
1326
+ // stopped early there are older messages we never read, so hasMore
1327
+ // must stay true regardless of the in-window slice position.
1328
+ const boundedResult = readAllFiles ? result : { messages: result.messages, hasMore: true };
1329
+ writeBoundedTailCache(cacheKey, signature, boundedResult);
1330
+ return boundedResult;
1331
+ }
1332
+
1199
1333
  const allMessages: HistoryMessage[] = [];
1200
1334
  const seen = new Set<string>();
1201
1335
 
@@ -2135,6 +2135,14 @@ export class CliProviderInstance implements ProviderInstance {
2135
2135
  if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
2136
2136
  return false;
2137
2137
  }
2138
+ // Full read is intentional: lastPersistedHistoryMessages is the COMPLETE
2139
+ // session transcript — emitted as statusMessages and used as the
2140
+ // prefix-comparison base for incremental appends — so a bounded tail
2141
+ // would both truncate output and break prefix dedup. This is gated to
2142
+ // once-per-2s (cache key above) for resume/manual launches only, so it
2143
+ // does not run on the per-subscribe/per-poll dashboard tail path (that
2144
+ // path goes through handleReadChat → readChatHistory with a bounded
2145
+ // tailLimit, which is now O(tail)).
2138
2146
  const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
2139
2147
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
2140
2148
  role: message.role,
@@ -346,11 +346,32 @@ export function buildClaudeInteractiveTuiAnswerSteps(
346
346
  if (response.promptId !== prompt.promptId) throw new Error('Interactive prompt response does not match active prompt');
347
347
  const steps: string[] = [];
348
348
  for (const question of prompt.questions) {
349
- if (question.multiSelect) throw new Error('Claude TUI multi-select prompts are not supported yet');
350
349
  const answer = response.answers[question.questionId];
351
350
  if (!answer) throw new Error(`Missing answer for ${question.questionId}`);
352
351
  const freeformText = answer.freeformText?.trim() ?? '';
353
- if (freeformText) {
352
+
353
+ if (question.multiSelect) {
354
+ // Multi-select: Claude TUI renders each option as a checkbox and the
355
+ // footer reads "Space to select". A numeric digit jumps the cursor to
356
+ // that option; Space toggles its checkbox. So for every selected label
357
+ // emit `[digit, ' ']` to land on it and toggle it on. After toggling all
358
+ // boxes for this question, Enter advances to the next question (or to the
359
+ // final confirm screen for the last question).
360
+ const labels = answer.selectedLabels;
361
+ if (labels.length === 0) {
362
+ throw new Error(`Expected at least one selected label for ${question.questionId}`);
363
+ }
364
+ for (const label of labels) {
365
+ const selectedIndex = question.options.findIndex(option => option.label === label);
366
+ if (selectedIndex < 0) throw new Error(`Unknown option for ${question.questionId}: ${label}`);
367
+ steps.push(String(selectedIndex + 1));
368
+ steps.push(' ');
369
+ }
370
+ // Confirm this question's checked set and move on. Unlike single-select
371
+ // (where the digit auto-advances), multi-select stays on the page until an
372
+ // explicit Enter so the user can toggle multiple boxes.
373
+ steps.push('\r');
374
+ } else if (freeformText) {
354
375
  // Freeform: select the "Type something." option (always the last visible
355
376
  // option before "Chat about this"), then type the text and confirm.
356
377
  const typeOptionIndex = question.options.findIndex(o => /^Type something\.?$/i.test(o.label));