@bongos/core 1.19.818 → 1.19.820

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.
Files changed (39) hide show
  1. package/.bongos-core.json +85 -35
  2. package/.claude/skills/strand-fix/SKILL.md +11 -2
  3. package/.gitattributes +9 -0
  4. package/clients/bongos-client/index.d.ts +1 -1
  5. package/docs/api/openapi.json +4 -2
  6. package/docs/copy-inventory.md +23 -18
  7. package/docs/copy-registry.json +76 -31
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/recipes/gds-db-backup.md +46 -0
  10. package/migrations/core_246_session_type.sql +49 -0
  11. package/modules/discord/bug-attachments.js +36 -1
  12. package/modules/hall-ui/public/sessions.css +19 -0
  13. package/modules/hall-ui/public/sessions.html +16 -1
  14. package/modules/hall-ui/public/sessions.js +135 -6
  15. package/modules/sessions/db.js +92 -6
  16. package/modules/sessions/routes/sessions.js +18 -1
  17. package/modules/sessions/session-type.js +132 -0
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/backfill-session-types.js +87 -0
  21. package/scripts/gds/bug-attachment-audit.js +143 -0
  22. package/scripts/gds/capture.js +19 -0
  23. package/scripts/gds/db-backup-nightly.sh +37 -1
  24. package/scripts/gds/fitness.js +12 -1
  25. package/scripts/gds/home-isolation-guard.js +126 -0
  26. package/scripts/gds/ship-merge.js +5 -1
  27. package/scripts/gds/ship-regen.js +29 -0
  28. package/scripts/gds/skill-preflight.js +28 -4
  29. package/src/module-api.js +1 -1
  30. package/tests/bug_attachment_audit.mjs +122 -0
  31. package/tests/cli_package.mjs +18 -2
  32. package/tests/cli_surface.mjs +31 -2
  33. package/tests/hall_roster_world.mjs +10 -5
  34. package/tests/home_isolation_guard.mjs +95 -0
  35. package/tests/module_api_lazy.mjs +32 -1
  36. package/tests/routine_capture_kind.mjs +147 -0
  37. package/tests/session_type.mjs +215 -0
  38. package/tests/ship_regen_copy_inventory.mjs +69 -0
  39. package/tests/skill_preflight.mjs +45 -0
@@ -42,6 +42,39 @@
42
42
 
43
43
  const shortSid = (sid) => String(sid || '').slice(0, 8) || '—';
44
44
 
45
+ // task 1003975 — what each session type is CALLED, and what it means on hover.
46
+ // The keys come from the server (`session_types` on the search response); this
47
+ // map only supplies the words. A type the server adds before this map learns it
48
+ // still renders — as its raw key — because a missing label must never make a
49
+ // real row look blank.
50
+ const KIND_LABEL = {
51
+ build: 'Build',
52
+ automated: 'Automated run',
53
+ exploration: 'Exploring',
54
+ conversation: 'Conversation',
55
+ unknown: 'Unclear',
56
+ };
57
+ const KIND_TITLE = {
58
+ build: 'Files were written or edited in this session.',
59
+ automated: 'One prompt and no edits — a grader, a subagent or a scripted run, not someone working.',
60
+ exploration: 'Someone steered it through several prompts, reading and running things without editing.',
61
+ conversation: 'Several prompts and no tools at all — a question or a discussion.',
62
+ unknown: 'The recorded transcript does not say — usually a session that predates this measure.',
63
+ };
64
+ const kindLabel = (t) => KIND_LABEL[t] || String(t);
65
+
66
+ // Emits the whole <td>. A row with no type yet (the backfill has not reached it)
67
+ // is a dash, NOT 'Unclear' — "not classified" and "classified as undecidable"
68
+ // are different facts and the ledger should not merge them.
69
+ function kindCell(s) {
70
+ const t = s.session_type;
71
+ if (!t) {
72
+ return '<td class="ledger-kind" title="Not yet classified.">—</td>';
73
+ }
74
+ const title = KIND_TITLE[t] || '';
75
+ return `<td class="ledger-kind" title="${escapeHtml(title)}">${escapeHtml(kindLabel(t))}</td>`;
76
+ }
77
+
45
78
  // task 1002516 — the transcript accessibility dimension (criterion
46
79
  // `accessibility-fluency-profile`, clause 4). Headed "Plain speech" rather than
47
80
  // "Accessibility" because this page already lives under a shell whose only
@@ -105,8 +138,12 @@
105
138
  function renderTable(sessions) {
106
139
  const body = $('#sessions-body');
107
140
  if (!sessions.length) {
108
- body.innerHTML =
109
- '<tr><td colspan="10" class="ledger__empty">No sessions recorded yet. They land here as builders ship and end sessions.</td></tr>';
141
+ // The empty line has to answer the question the reader is actually asking,
142
+ // and with a filter on that question is "did I filter everything away?",
143
+ // not "why has nobody ever worked here".
144
+ body.innerHTML = state.sessionType
145
+ ? `<tr><td colspan="11" class="ledger__empty">No ${escapeHtml(kindLabel(state.sessionType).toLowerCase())} sessions recorded. Choose another kind to widen the ledger.</td></tr>`
146
+ : '<tr><td colspan="11" class="ledger__empty">No sessions recorded yet. They land here as builders ship and end sessions.</td></tr>';
110
147
  return;
111
148
  }
112
149
  body.innerHTML = sessions
@@ -126,6 +163,7 @@
126
163
  `<td>${whoHtml}` +
127
164
  (s.rank ? ` <span class="ledger-rank">${escapeHtml(rankLabel(s.rank))}</span>` : '') +
128
165
  '</td>' +
166
+ kindCell(s) +
129
167
  `<td>${tasks}</td>` +
130
168
  `<td class="ledger__num">${fmtInt(s.drachmae_earned)}</td>` +
131
169
  `<td class="ledger__num">${fmtInt(s.assistant_turns)}</td>` +
@@ -186,7 +224,24 @@
186
224
  // pager first and calls back second, so a failed fetch has to put it back to these —
187
225
  // otherwise the bar goes on claiming a page whose rows never arrived. Keeping both
188
226
  // (not just the page) means a failed per-page change rolls back cleanly too.
189
- const state = { total: 0, shown: 0, page: 1, perPage: pager?.perPage ?? 0 };
227
+ // `sessionType` is the live filter (null = every kind). It sits in the same
228
+ // roll-back state as page/perPage because a failed fetch has to put ALL THREE
229
+ // back — a bar claiming page 1 of "Exploring" whose rows never arrived is the
230
+ // same lie whichever control moved last.
231
+ const state = { total: 0, shown: 0, page: 1, perPage: pager?.perPage ?? 0, sessionType: null };
232
+ // Remembered per viewer, like the pager's per-page choice: a monitoring page is
233
+ // returned to, and re-picking the same lens every visit is a tax. Wrapped
234
+ // because storage throws in a private window, where the page must still work.
235
+ const TYPE_KEY = 'otb.sessions.kind';
236
+ function readStoredType() {
237
+ try { return window.localStorage.getItem(TYPE_KEY) || null; } catch { return null; }
238
+ }
239
+ function storeType(t) {
240
+ try {
241
+ if (t) window.localStorage.setItem(TYPE_KEY, t);
242
+ else window.localStorage.removeItem(TYPE_KEY);
243
+ } catch { /* no-op: the filter still works for this visit */ }
244
+ }
190
245
  let loading = false;
191
246
  // Two clicks can be in flight at once; only the newest may paint (the profile
192
247
  // activity-log idiom, task 1003281).
@@ -201,6 +256,11 @@
201
256
  const clamped = kit.renderServerPage(pager, state.total, state.shown);
202
257
  const perPage = $(`#${PAGER_PREFIX}-per-page`);
203
258
  if (perPage) perPage.disabled = loading;
259
+ // The kind filter is disabled for the same reason the pager is: a second
260
+ // change mid-fetch would put a third request in flight behind two that are
261
+ // already racing to paint.
262
+ const kind = $('#session-type-filter');
263
+ if (kind) kind.disabled = loading;
204
264
  if (loading) {
205
265
  for (const id of ['prev', 'next']) {
206
266
  const el = $(`#${PAGER_PREFIX}-${id}`);
@@ -210,6 +270,52 @@
210
270
  return clamped;
211
271
  }
212
272
 
273
+ // ---- the kind filter -----------------------------------------------------
274
+
275
+ // Fill the control from the vocabulary the SERVER sent, once. Re-stamping it on
276
+ // every paint would reset the open dropdown under a reader's cursor, so it
277
+ // returns early when the options already match what arrived.
278
+ let typeOptionsFrom = null;
279
+ function syncTypeOptions(types) {
280
+ const el = $('#session-type-filter');
281
+ if (!el || !Array.isArray(types) || types.length === 0) return;
282
+ const key = types.join(',');
283
+ if (typeOptionsFrom === key) return;
284
+ typeOptionsFrom = key;
285
+ el.innerHTML =
286
+ '<option value="">All kinds</option>' +
287
+ types.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(kindLabel(t))}</option>`).join('');
288
+ // A remembered kind is only honoured once the server has confirmed it is
289
+ // still a kind — otherwise the control would show a value no query accepts.
290
+ if (state.sessionType && !types.includes(state.sessionType)) {
291
+ setTypeFilter(null, { reload: false });
292
+ }
293
+ el.value = state.sessionType || '';
294
+ }
295
+
296
+ // Move the filter. `reload:false` is the internal correction (a stale stored
297
+ // value being dropped) — it must not fire a fetch from inside one.
298
+ function setTypeFilter(type, { reload = true } = {}) {
299
+ state.sessionType = type || null;
300
+ storeType(state.sessionType);
301
+ const el = $('#session-type-filter');
302
+ if (el) el.value = state.sessionType || '';
303
+ if (!reload) return;
304
+ // A narrower corpus makes the open page meaningless — page 7 of Build is not
305
+ // page 7 of everything — so every change starts at the first page.
306
+ pager.page = 1;
307
+ state.page = 1;
308
+ fetchPage().then((ok) => {
309
+ if (ok) window.scrollTo({ top: 0, behavior: 'smooth' });
310
+ });
311
+ }
312
+
313
+ function wireTypeFilter() {
314
+ const el = $('#session-type-filter');
315
+ if (!el) return;
316
+ el.addEventListener('change', () => setTypeFilter(el.value || null));
317
+ }
318
+
213
319
  // Paint one page. Returns true when the pager clamped — the corpus shrank under an
214
320
  // open page, so the caller must re-ask rather than leave a range that lies.
215
321
  function showData(data) {
@@ -222,9 +328,15 @@
222
328
  $('#sessions-scroll').hidden = false;
223
329
  renderStats(totals);
224
330
  renderTable(sessions);
331
+ syncTypeOptions(data.session_types);
225
332
  const clamped = renderPager();
333
+ // With a kind chosen, the count IS the filtered count (the tally shares the
334
+ // filter clause with the page query), so the line has to say which — a
335
+ // headline reading "57 sessions" under a control set to Build would otherwise
336
+ // look like the whole corpus had shrunk.
337
+ const scope = state.sessionType ? ` ${kindLabel(state.sessionType).toLowerCase()}` : '';
226
338
  $('#ledger-sub').textContent =
227
- `${fmtInt(totals.total)} session${num(totals.total) === 1 ? '' : 's'} across ${fmtInt(totals.builders)} builder${num(totals.builders) === 1 ? '' : 's'}.`;
339
+ `${fmtInt(totals.total)}${scope} session${num(totals.total) === 1 ? '' : 's'} across ${fmtInt(totals.builders)} builder${num(totals.builders) === 1 ? '' : 's'}.`;
228
340
  $('#ledger-foot').textContent =
229
341
  'Most recent first. Each load of this page is itself recorded in the audit log.';
230
342
  return clamped;
@@ -238,14 +350,17 @@
238
350
  // then calls back — so a failure restores `state.page`/`state.perPage`, the last
239
351
  // values that painted. That roll-back is what the old hand-roll did with
240
352
  // `state.offset`, and it is the one piece of it worth carrying over.
241
- async function fetchPage({ reclamps = 0 } = {}) {
353
+ async function fetchPage({ reclamps = 0, droppedFilter = false } = {}) {
242
354
  const seq = ++inFlight;
243
355
  loading = true;
244
356
  renderPager();
245
357
  try {
358
+ const typeParam = state.sessionType
359
+ ? `&session_type=${encodeURIComponent(state.sessionType)}`
360
+ : '';
246
361
  const res = await api.request(
247
362
  'GET',
248
- `${API}/sessions/search?order=recent&limit=${pager.perPage}&offset=${kit.pageOffset(pager)}`
363
+ `${API}/sessions/search?order=recent&limit=${pager.perPage}&offset=${kit.pageOffset(pager)}${typeParam}`
249
364
  );
250
365
  // A newer click already went out; painting this answer would show a page nobody
251
366
  // is on any more.
@@ -255,6 +370,15 @@
255
370
  showSealed('You do not have access to the session log.');
256
371
  return false;
257
372
  }
373
+ // A remembered kind the server no longer accepts (a type retired between
374
+ // visits) comes back as a named 400. Drop the stale lens and re-ask once,
375
+ // rather than showing a load failure the reader cannot act on — their only
376
+ // clue would be a control still displaying the dead value.
377
+ if (res.status === 400 && state.sessionType && !droppedFilter) {
378
+ setTypeFilter(null, { reload: false });
379
+ loading = false;
380
+ return fetchPage({ reclamps, droppedFilter: true });
381
+ }
258
382
  if (!res.ok) throw new Error(`search → ${res.status}`);
259
383
  const clamped = showData(res.data);
260
384
  // The corpus shrank under this page (sessions pruned between loads):
@@ -329,6 +453,11 @@
329
453
 
330
454
  // 2. Fetch the first page (Archon-gated + audited server-side).
331
455
  mountPager();
456
+ // The remembered kind is applied BEFORE the first fetch so a returning reader
457
+ // lands on their own lens rather than seeing the whole corpus paint and then
458
+ // snap to a narrower one.
459
+ state.sessionType = readStoredType();
460
+ wireTypeFilter();
332
461
  await fetchPage();
333
462
  }
334
463
 
@@ -28,6 +28,11 @@ const { pool } = api;
28
28
  // file, not the doorway: it touches no kernel surface, and keeping the numbers
29
29
  // in one place is what stops the route and the poller disagreeing.
30
30
  const uploadHealth = require('./upload-health');
31
+ // The PURE session-type verdict (task 1003975). Same reasoning as upload-health:
32
+ // the classifier touches no kernel surface, and keeping the vocabulary in one
33
+ // file is what stops the upload path, the search filter and the backfill from
34
+ // disagreeing about what a type is.
35
+ const sessionType = require('./session-type');
31
36
  // `seams` is the doorway's kernel-mediated seam surface (resolveOptional/resolve).
32
37
  // upsertSessionRecordWithReward resolves the economy `reward` port through it —
33
38
  // the module↔module seam, never a sibling-module require (BV1.R77 / ADR 0093 §2).
@@ -64,11 +69,19 @@ async function upsertSessionRecordTx(executor, {
64
69
  const ids = Array.isArray(taskIds)
65
70
  ? taskIds.map((n) => Number(n)).filter(Number.isFinite)
66
71
  : [];
72
+ // task 1003975 — the session's shape, derived here and NEVER taken from the
73
+ // body. Computed on the same digest the row stores, so the column can always be
74
+ // re-derived from what is beside it; an upload that re-posts a session (the ship
75
+ // path uploads once per shipped task) re-classifies it from the newest digest,
76
+ // which is the same rule detail/metrics already follow on conflict.
77
+ const type = sessionType.classifySession({ detail, metrics });
67
78
  const { rows } = await executor.query(
68
79
  `INSERT INTO session_records
69
80
  (session_id, builder_id, task_ids, drachmae_earned, total_tokens,
70
- duration_seconds, started_at, ended_at, detail, metrics, model_usage)
71
- VALUES ($1, $2, $3::bigint[], $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb)
81
+ duration_seconds, started_at, ended_at, detail, metrics, model_usage,
82
+ session_type)
83
+ VALUES ($1, $2, $3::bigint[], $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb,
84
+ $12)
72
85
  ON CONFLICT (session_id) DO UPDATE SET
73
86
  task_ids = (
74
87
  SELECT COALESCE(array_agg(DISTINCT e ORDER BY e), '{}')
@@ -84,12 +97,13 @@ async function upsertSessionRecordTx(executor, {
84
97
  detail = EXCLUDED.detail,
85
98
  metrics = EXCLUDED.metrics,
86
99
  model_usage = EXCLUDED.model_usage,
100
+ session_type = EXCLUDED.session_type,
87
101
  uploaded_at = now()
88
102
  -- Ownership guard: a row can only ever be updated by its own builder, so a
89
103
  -- builder who guesses another's session_id cannot overwrite their record.
90
104
  WHERE session_records.builder_id = EXCLUDED.builder_id
91
105
  RETURNING id, session_id, builder_id, task_ids, drachmae_earned,
92
- total_tokens, duration_seconds, uploaded_at`,
106
+ total_tokens, duration_seconds, session_type, uploaded_at`,
93
107
  [
94
108
  sessionId,
95
109
  builderId,
@@ -102,6 +116,7 @@ async function upsertSessionRecordTx(executor, {
102
116
  JSON.stringify(detail ?? []),
103
117
  JSON.stringify(metrics ?? {}),
104
118
  JSON.stringify(modelUsage ?? {}),
119
+ type,
105
120
  ]
106
121
  );
107
122
  return rows[0] || null; // null only on the ownership-guard no-op (foreign session_id)
@@ -521,9 +536,14 @@ function clampSessionOffset(n) {
521
536
 
522
537
  // sessionFilterClause — the shared parameterized WHERE used by BOTH searchSessions
523
538
  // (the page) and summarizeSessions (the corpus-wide tally), so the two can never
524
- // drift out of sync. Every filter is a bound parameter ($1..$7); nothing from the
539
+ // drift out of sync. Every filter is a bound parameter ($1..$8); nothing from the
525
540
  // caller is interpolated. Returns { clause, params } where clause is the body of
526
541
  // a WHERE (no leading 'WHERE'). Pure.
542
+ //
543
+ // task 1003975 added $8 (sessionType). It goes through the SHARED clause like
544
+ // every other facet precisely so the Tally cannot disagree with the rows: a
545
+ // filter applied to the page query alone would show "4,595 sessions" above a
546
+ // table holding 57.
527
547
  function sessionFilterClause(f = {}) {
528
548
  const clause = `($1::text IS NULL OR b.rank = $1)
529
549
  AND ($2::int IS NULL OR b.karma >= $2)
@@ -531,7 +551,8 @@ function sessionFilterClause(f = {}) {
531
551
  AND ($4::bigint IS NULL OR sr.total_tokens >= $4)
532
552
  AND ($5::int IS NULL OR COALESCE((sr.metrics->>'tool_error_count')::int, 0) >= $5)
533
553
  AND ($6::int IS NULL OR sr.duration_seconds >= $6)
534
- AND ($7::int IS NULL OR (now() - b.created_at) <= ($7 * interval '1 day'))`;
554
+ AND ($7::int IS NULL OR (now() - b.created_at) <= ($7 * interval '1 day'))
555
+ AND ($8::text IS NULL OR sr.session_type = $8)`;
535
556
  const params = [
536
557
  f.rank ?? null,
537
558
  f.minKarma ?? null,
@@ -540,6 +561,7 @@ function sessionFilterClause(f = {}) {
540
561
  f.minToolErrors ?? null,
541
562
  f.minDurationSec ?? null,
542
563
  f.maxTenureDays ?? null,
564
+ f.sessionType ?? null,
543
565
  ];
544
566
  return { clause, params };
545
567
  }
@@ -559,6 +581,10 @@ const SESSION_SUMMARY_COLS = `
559
581
  -- the ledger does not have to re-derive the thresholds in the browser.
560
582
  (sr.metrics->'accessibility'->>'score')::numeric AS accessibility_score,
561
583
  (sr.metrics->'accessibility'->>'band') AS accessibility_band,
584
+ -- task 1003975: the session's derived shape. NOT coalesced to a type — a NULL
585
+ -- here means the row predates the classifier and the backfill has not reached
586
+ -- it, which the ledger renders as a dash rather than claiming a verdict.
587
+ sr.session_type,
562
588
  sr.uploaded_at`;
563
589
 
564
590
  // searchSessions — cross-builder search over the corpus, filtered by session
@@ -574,7 +600,7 @@ async function searchSessions(filters = {}) {
574
600
  const { clause, params } = sessionFilterClause(f);
575
601
  // limit/offset are clamped integers, never raw input — safe to interpolate;
576
602
  // keeping them out of the param list keeps the $-indices aligned with the
577
- // shared clause (which owns $1..$7).
603
+ // shared clause (which owns $1..$8).
578
604
  const { rows } = await pool.query(
579
605
  `SELECT ${SESSION_SUMMARY_COLS}
580
606
  FROM session_records sr
@@ -772,6 +798,62 @@ async function sessionUploadHealth({ days, windowHours, sampleLimit } = {}) {
772
798
  };
773
799
  }
774
800
 
801
+ // -------------------------------------------------------------------------
802
+ // Session-type backfill (task 1003975)
803
+ // -------------------------------------------------------------------------
804
+ //
805
+ // The two halves the CLI needs, and NOTHING else. They are here rather than in
806
+ // the script because every other query in this domain is, and because the write
807
+ // half has to be narrow in a way a script cannot enforce for itself: it SETs one
808
+ // column on one id. It cannot touch task_ids, drachmae_earned or any reward
809
+ // column even by accident, which is the lesson the windowed attribution sweep
810
+ // paid for (see the warning in this module's CLAUDE.md) — a backfill that also
811
+ // moves money is the wrong shape whatever its intent.
812
+
813
+ // unclassifiedSessions — a page of rows still missing a type, oldest first, with
814
+ // the two jsonb fields the classifier reads. Ordered by id so a resumed run walks
815
+ // the corpus deterministically rather than re-drawing a shuffled window.
816
+ async function unclassifiedSessions(limit = 500) {
817
+ const { rows } = await pool.query(
818
+ `SELECT id, detail, metrics
819
+ FROM session_records
820
+ WHERE session_type IS NULL
821
+ ORDER BY id ASC
822
+ LIMIT $1`,
823
+ [Math.max(1, Math.min(5000, Math.trunc(Number(limit) || 500)))]
824
+ );
825
+ return rows;
826
+ }
827
+
828
+ // setSessionType — write one row's verdict. Refuses anything outside the closed
829
+ // set, so a caller that invents a type gets an error instead of a column full of
830
+ // values no filter will ever match.
831
+ async function setSessionType(id, type) {
832
+ if (!sessionType.isSessionType(type)) {
833
+ const err = new Error(`unknown session_type '${type}'`);
834
+ err.code = 'BAD_SESSION_TYPE';
835
+ throw err;
836
+ }
837
+ const { rowCount } = await pool.query(
838
+ 'UPDATE session_records SET session_type = $2 WHERE id = $1',
839
+ [Number(id), type]
840
+ );
841
+ return rowCount;
842
+ }
843
+
844
+ // sessionTypeCounts — the corpus tally per type, including the NULL backlog. The
845
+ // backfill prints it as its own progress, and it answers "what is this corpus
846
+ // actually made of" in one query.
847
+ async function sessionTypeCounts() {
848
+ const { rows } = await pool.query(
849
+ `SELECT COALESCE(session_type, 'unclassified') AS type, COUNT(*)::int AS n
850
+ FROM session_records
851
+ GROUP BY 1
852
+ ORDER BY 2 DESC`
853
+ );
854
+ return rows;
855
+ }
856
+
775
857
  module.exports = {
776
858
  // write side (6D.1)
777
859
  upsertSessionRecord,
@@ -793,4 +875,8 @@ module.exports = {
793
875
  resolveSessionOrder,
794
876
  clampSessionLimit,
795
877
  clampSessionOffset,
878
+ // session type (task 1003975) — the backfill's two halves + the corpus tally
879
+ unclassifiedSessions,
880
+ setSessionType,
881
+ sessionTypeCounts,
796
882
  };
@@ -46,6 +46,10 @@ const { scoreAccessibility } = require('../accessibility');
46
46
  // `days=99999` for a read that ran at 365 is a record of something that did not
47
47
  // happen (task 1003865).
48
48
  const uploadHealth = require('../upload-health');
49
+ // The closed set of session types + its guard (task 1003975). The route validates
50
+ // the ?session_type filter against the SAME list the classifier writes, so a type
51
+ // can never be filterable on one surface and unwritable on the other.
52
+ const sessionType = require('../session-type');
49
53
  const seams = api; // resolveOptional('reward' | 'builder-settings') — kernel-mediated ports
50
54
  const { validateOrRespond, parseId } = api;
51
55
 
@@ -321,6 +325,13 @@ module.exports = function buildSessionsRouter() {
321
325
  if (rank && !VALID_RANKS.has(rank)) {
322
326
  return res.fail('bad_rank', 400, { valid: Array.from(VALID_RANKS) });
323
327
  }
328
+ // task 1003975 — validated against the classifier's own closed set, so a
329
+ // typo is a named 400 rather than a query that quietly matches nothing and
330
+ // reads as "the corpus holds no exploration sessions".
331
+ const sessionTypeFilter = q.session_type ? String(q.session_type) : null;
332
+ if (sessionTypeFilter && !sessionType.isSessionType(sessionTypeFilter)) {
333
+ return res.fail('bad_session_type', 400, { valid: sessionType.SESSION_TYPES });
334
+ }
324
335
  const numOrNull = (v) => {
325
336
  if (v == null) return null;
326
337
  const n = Number(v);
@@ -334,6 +345,7 @@ module.exports = function buildSessionsRouter() {
334
345
  minToolErrors: numOrNull(q.min_tool_errors),
335
346
  minDurationSec: numOrNull(q.min_duration_seconds),
336
347
  maxTenureDays: numOrNull(q.max_tenure_days),
348
+ sessionType: sessionTypeFilter,
337
349
  order: q.order ? String(q.order) : 'recent',
338
350
  limit: q.limit,
339
351
  offset: q.offset,
@@ -345,7 +357,7 @@ module.exports = function buildSessionsRouter() {
345
357
  await recordSessionRead(req, {
346
358
  route: '/api/bongos/sessions/search',
347
359
  targetBuilderId: null,
348
- target: `search rank=${rank || '*'} order=${filters.order} offset=${db.clampSessionOffset(filters.offset)}`,
360
+ target: `search rank=${rank || '*'} type=${sessionTypeFilter || '*'} order=${filters.order} offset=${db.clampSessionOffset(filters.offset)}`,
349
361
  });
350
362
  // Page + corpus-wide tally in parallel. `totals` reflects EVERY session
351
363
  // matching the filter (not just this page), so the Ledger's stats stay
@@ -361,6 +373,11 @@ module.exports = function buildSessionsRouter() {
361
373
  totals,
362
374
  limit: db.clampSessionLimit(filters.limit),
363
375
  offset: db.clampSessionOffset(filters.offset),
376
+ // task 1003975 — the vocabulary travels WITH the results so the ledger
377
+ // builds its filter from what this route actually accepts. A page that
378
+ // hardcoded the list would keep offering a type the server had dropped,
379
+ // and would hide one it had added.
380
+ session_types: sessionType.SESSION_TYPES,
364
381
  });
365
382
  } catch (err) {
366
383
  console.error('[gds] GET /sessions/search', err);
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ // modules/sessions/session-type.js — WHAT A SESSION WAS, FOR A LEDGER THAT ONLY
4
+ // KNEW WHETHER IT SHIPPED A TASK (task 1003975, from idea 1001033).
5
+ //
6
+ // THE PROBLEM THIS ANSWERS. The session ledger shows a task chip per row, and a
7
+ // dash when there is none. 54% of the corpus has an empty `task_ids` (the figure
8
+ // upload-health.js already records), so more than half the ledger reads as a
9
+ // column of dashes — every one of them some real session, none of them
10
+ // distinguishable from the next. The owner's words: user behaviour is hard to
11
+ // decipher at present.
12
+ //
13
+ // WHAT THE CORPUS ACTUALLY CONTAINS, MEASURED — and it is not what the ledger's
14
+ // shape implies. 247 sessions were sampled through GET /sessions/:id across
15
+ // offsets 0…3600 of the recent ordering (2026-09-17, corpus of 4595):
16
+ //
17
+ // automated 179 (72%) build 57 (23%) exploration 11 (4%) conversation 0
18
+ //
19
+ // Two findings drove the design:
20
+ //
21
+ // * MOST ROWS ARE NOT HUMAN BEHAVIOUR AT ALL. 188 of 247 sessions received one
22
+ // prompt or fewer — grader workers, panel subagents and hook one-shots, each
23
+ // a separate `claude -p` process with its own session id and its own
24
+ // SessionEnd upload. Reading the ledger as a record of what PEOPLE did is
25
+ // what makes it undecipherable.
26
+ // * `task_ids` DOES NOT MEAN "the session that built the task". 82 of those 179
27
+ // automated rows carry a task id, because a grader worker runs inside a ship
28
+ // and inherits it. So the task chip cannot be the primary signal, and this
29
+ // classifier deliberately never reads `task_ids`.
30
+ //
31
+ // THE SIGNAL THAT SEPARATES A PERSON FROM A SCRIPT is the prompt count:
32
+ // `user_turns - tool_result_count`. Every tool result is recorded as a user turn,
33
+ // so subtracting them leaves the turns a human (or an orchestrating skill)
34
+ // actually sent. Measured over the sample it is strikingly bimodal — 184 rows sit
35
+ // at exactly 1 and 4 at 0, against a long tail from 2 to 78 — which is why the
36
+ // boundary is `<= 1` and not a tuned percentile. It is the difference between
37
+ // "invoked once" and "steered", not a threshold anyone chose.
38
+ //
39
+ // PURE, AND DELIBERATELY SO. No DB, no clock, no network — the same shape as
40
+ // upload-health.js's verdict. It takes the digest a session already uploads and
41
+ // returns one string. Nothing here calls an LLM: a paid inference per session
42
+ // would cost more than the corpus is worth, and a deterministic read of stored
43
+ // data is reproducible when someone disputes a label.
44
+
45
+ // The CLOSED set of types. Anything outside it is a bug, not a new category —
46
+ // the column, the route filter and the ledger's control all validate against
47
+ // this one list, so a type cannot exist on one surface and not another.
48
+ const SESSION_TYPES = ['build', 'automated', 'exploration', 'conversation', 'unknown'];
49
+
50
+ // The tools that CHANGE something. Kept as a named set rather than inlined
51
+ // because it is the one judgement in the file that will need revisiting when the
52
+ // harness grows a new editor — a new write tool missing from here silently
53
+ // demotes real build sessions to exploration, which is the failure mode to watch.
54
+ // Bash is NOT here on purpose: it reads at least as often as it writes (1314 of
55
+ // the sample's 1761 tool calls), so counting it as a write would make almost
56
+ // every session a build and the label would stop meaning anything.
57
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
58
+
59
+ // The prompt count above which a session counts as steered rather than invoked.
60
+ // Measured, not chosen — see the header.
61
+ const ONE_SHOT_PROMPT_MAX = 1;
62
+
63
+ // humanPrompts — the turns a person actually sent: user turns minus the tool
64
+ // results that are also recorded as user turns. Clamped at 0, because a digest
65
+ // whose counters disagree (an old row, a truncated transcript) must not produce a
66
+ // negative that then reads as a one-shot.
67
+ function humanPrompts(metrics) {
68
+ const m = metrics || {};
69
+ const userTurns = Number(m.user_turns) || 0;
70
+ const toolResults = Number(m.tool_result_count) || 0;
71
+ return Math.max(0, userTurns - toolResults);
72
+ }
73
+
74
+ // usedWriteTool — did any turn call a tool that changes a file? Reads `detail`,
75
+ // which is the only place tool NAMES survive (metrics counts calls, not what they
76
+ // were). Tolerant of every shape an old row might hold — a missing detail, a
77
+ // turn with no tools, a tools entry that is not a string — because this runs over
78
+ // a corpus written by several versions of the digest builder.
79
+ function usedWriteTool(detail) {
80
+ if (!Array.isArray(detail)) return false;
81
+ for (const turn of detail) {
82
+ if (!turn || !Array.isArray(turn.tools)) continue;
83
+ for (const name of turn.tools) {
84
+ if (WRITE_TOOLS.has(String(name))) return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ // classifySession — the verdict: one of SESSION_TYPES, from a session's own
91
+ // digest. Takes `{ detail, metrics }` — exactly what the upload body carries and
92
+ // what a stored row holds — so the upload path and the backfill classify through
93
+ // the same call and cannot drift.
94
+ //
95
+ // The order of the tests is the meaning:
96
+ // 1. It wrote something → `build`, whatever else it did.
97
+ // 2. It was invoked, not steered → `automated` (the grader worker, the hook).
98
+ // 3. It used tools → `exploration` (someone reading the codebase).
99
+ // 4. It used none → `conversation` (someone asking, not building).
100
+ // A digest with no usable counters at all returns `unknown` rather than guessing:
101
+ // an empty transcript is not evidence of a quiet conversation.
102
+ function classifySession({ detail, metrics } = {}) {
103
+ const m = metrics && typeof metrics === 'object' ? metrics : null;
104
+ const hasDetail = Array.isArray(detail) && detail.length > 0;
105
+ if (!m && !hasDetail) return 'unknown';
106
+ if (usedWriteTool(detail)) return 'build';
107
+ const turns = Number(m && m.assistant_turns) || 0;
108
+ const toolUse = Number(m && m.tool_use_count) || 0;
109
+ // Nothing in the digest describes a session: no turns, no tools, no prompts.
110
+ // Older rows predating these counters land here, and so does a row whose
111
+ // upload was truncated. Saying `unknown` keeps them out of every real bucket
112
+ // instead of inflating `conversation` with rows nobody can account for.
113
+ if (!turns && !toolUse && !hasDetail) return 'unknown';
114
+ if (humanPrompts(m) <= ONE_SHOT_PROMPT_MAX) return 'automated';
115
+ return toolUse > 0 ? 'exploration' : 'conversation';
116
+ }
117
+
118
+ // isSessionType — the guard the route filter uses, so an unknown ?session_type
119
+ // is a clean 400 rather than a query that silently matches nothing.
120
+ function isSessionType(value) {
121
+ return typeof value === 'string' && SESSION_TYPES.includes(value);
122
+ }
123
+
124
+ module.exports = {
125
+ SESSION_TYPES,
126
+ WRITE_TOOLS,
127
+ ONE_SHOT_PROMPT_MAX,
128
+ humanPrompts,
129
+ usedWriteTool,
130
+ classifySession,
131
+ isSessionType,
132
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.818",
3
+ "version": "1.19.820",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.818",
9
+ "version": "1.19.820",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.818",
3
+ "version": "1.19.820",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",