@animalabs/connectome-host 0.6.0 → 0.6.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.1 — 2026-07-26
6
+
7
+ ### Fixed
8
+
9
+ - **Pin id picker sourced the wrong ids.** It read `/debug/context/curve`, which
10
+ looked right but isn't: on a live store 0 of 208 raw entries carried a
11
+ `sourceMessageId`, and the 26 entries that *did* have an `id` were summaries,
12
+ whose ids (`L3-544`) are not message ids. Pinning with one would have created a
13
+ pin matching no message and silently done nothing. The picker now uses the
14
+ client's own message list, where `WelcomeMessageEntry.id` is the store id and
15
+ server-sourced rows are exactly those carrying a store `index`; it also gains a
16
+ text/id filter. Caught by checking the endpoint against a real store before
17
+ anyone used the panel.
18
+
19
+ ## Unreleased
20
+
5
21
  ## 0.6.0 — 2026-07-26
6
22
 
7
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
package/web/src/App.tsx CHANGED
@@ -11,7 +11,7 @@ import { LessonsPanel, type LessonRow } from './Lessons';
11
11
  import { McplPanel, type McplServerRow } from './Mcpl';
12
12
  import { SettingsPanel, type SettingsState } from './Settings';
13
13
  import { DryContext, type DryContextData } from './DryContext';
14
- import { PinsPanel, type PinsState } from './Pins';
14
+ import { PinsPanel, type PinsState, type PinCandidate } from './Pins';
15
15
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
16
16
  import { ContextPanel } from './Context';
17
17
  import { ContextDocument } from './ContextDocument';
@@ -1271,6 +1271,11 @@ export function App() {
1271
1271
  loaded={pinsLoaded()}
1272
1272
  state={pinsState()}
1273
1273
  agent={pinsState()?.agent}
1274
+ candidates={messages
1275
+ .filter((m) => m.index !== undefined && m.id)
1276
+ .map<PinCandidate>((m) => ({
1277
+ id: m.id, index: m.index!, participant: m.participant, text: m.text ?? '',
1278
+ }))}
1274
1279
  onRefresh={refreshPins}
1275
1280
  onAdd={(input) => wire.send({ type: 'pin-add', ...input })}
1276
1281
  onRemove={(pinId) => wire.send({ type: 'pin-remove', pinId })}
package/web/src/Pins.tsx CHANGED
@@ -12,8 +12,8 @@
12
12
  * degrades to raw — a safe superset, but not what was asked for — so the panel
13
13
  * warns rather than letting it pass silently.
14
14
  *
15
- * Message ids come from /debug/context/curve (cheap: ~14ms) so the operator
16
- * picks from the live context instead of pasting ids.
15
+ * Ids are picked from the client's own message list (real store ids) rather than
16
+ * typed. See PinCandidate for why /debug/context/curve is NOT a usable source.
17
17
  */
18
18
 
19
19
  import { createSignal, For, Show } from 'solid-js';
@@ -37,14 +37,22 @@ export interface PinsState {
37
37
  deepestLevel?: number;
38
38
  }
39
39
 
40
- interface CurveEntry {
41
- i: number;
42
- kind: string;
43
- id: string | null;
44
- participant?: string;
45
- rendered?: number;
46
- msgCount?: number;
47
- text?: string;
40
+ /**
41
+ * A pinnable message.
42
+ *
43
+ * MUST be a real message-store id. /debug/context/curve looked like the natural
44
+ * source but is not: on a live store its raw entries carry no `sourceMessageId`
45
+ * (0 of 208 had one) and the entries that DO have an `id` are summaries, whose
46
+ * ids (`L3-544`) are not message ids at all. Pinning with one would create a pin
47
+ * matching no message and silently do nothing. The client's own message list is
48
+ * authoritative — `WelcomeMessageEntry.id` is the store id, and server-sourced
49
+ * rows are exactly those with a store `index`.
50
+ */
51
+ export interface PinCandidate {
52
+ id: string;
53
+ index: number;
54
+ participant: string;
55
+ text: string;
48
56
  }
49
57
 
50
58
  const ago = (ms: number) => {
@@ -67,6 +75,8 @@ export function PinsPanel(props: {
67
75
  loaded: boolean;
68
76
  state: PinsState | null;
69
77
  agent?: string;
78
+ /** Pinnable messages — server-sourced rows only (real store ids). */
79
+ candidates?: PinCandidate[];
70
80
  onRefresh(): void;
71
81
  onAdd(input: {
72
82
  kind: 'pin' | 'document';
@@ -85,26 +95,16 @@ export function PinsPanel(props: {
85
95
  const [lvl, setLvl] = createSignal('1');
86
96
  const [asDoc, setAsDoc] = createSignal(false);
87
97
 
88
- const [curve, setCurve] = createSignal<CurveEntry[] | null>(null);
89
- const [curveErr, setCurveErr] = createSignal<string | null>(null);
90
- const [picking, setPicking] = createSignal(false);
98
+ const [showPicker, setShowPicker] = createSignal(false);
99
+ const [filter, setFilter] = createSignal('');
91
100
 
92
- /** /debug/context/curve is the cheap endpoint (~14ms) and the only one that
93
- * exposes per-entry store ids alongside a text preview. */
94
- const loadCurve = async () => {
95
- setPicking(true);
96
- setCurveErr(null);
97
- try {
98
- const qs = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
99
- const res = await fetch(`/debug/context/curve${qs}`, { credentials: 'same-origin' });
100
- const body = await res.json();
101
- if (!res.ok) { setCurveErr(body?.error ?? `HTTP ${res.status}`); return; }
102
- setCurve((body.entries ?? []) as CurveEntry[]);
103
- } catch (e) {
104
- setCurveErr(e instanceof Error ? e.message : String(e));
105
- } finally {
106
- setPicking(false);
107
- }
101
+ const candidates = () => {
102
+ const q = filter().trim().toLowerCase();
103
+ const all = props.candidates ?? [];
104
+ const hit = q ? all.filter((c) => c.text.toLowerCase().includes(q) || c.id.includes(q)) : all;
105
+ // Newest last is how the operator reads the conversation; cap the list so a
106
+ // long history doesn't build thousands of rows.
107
+ return hit.slice(-200);
108
108
  };
109
109
 
110
110
  const submit = () => {
@@ -273,43 +273,46 @@ export function PinsPanel(props: {
273
273
  </button>
274
274
  <button type="button"
275
275
  class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700 text-neutral-300"
276
- onClick={() => void loadCurve()}>
277
- {picking() ? 'loading…' : curve() ? 'reload ids' : 'pick from context'}
276
+ onClick={() => setShowPicker((v) => !v)}>
277
+ {showPicker() ? 'hide messages' : `pick from messages (${(props.candidates ?? []).length})`}
278
278
  </button>
279
279
  </div>
280
280
 
281
- <Show when={curveErr()}>
282
- <div class="text-[10px] text-amber-400/90 mb-2">{curveErr()}</div>
283
- </Show>
284
-
285
- {/* ---- id picker ---- */}
286
- <Show when={curve()}>
281
+ <Show when={showPicker()}>
282
+ <input value={filter()} onInput={(e) => setFilter(e.currentTarget.value)}
283
+ placeholder="filter by text or id"
284
+ class="w-full mb-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
285
+ font-mono text-[10px] text-neutral-100" />
286
+ <Show when={candidates().length === 0}>
287
+ <div class="text-[10px] text-neutral-600 italic">
288
+ No pinnable messages loaded. Pins need a real store id, so only
289
+ server-sourced rows qualify — scroll the chat to load history.
290
+ </div>
291
+ </Show>
287
292
  <div class="max-h-64 overflow-y-auto border border-neutral-800 rounded">
288
- <For each={curve()!.filter((e) => e.id)}>
289
- {(e) => (
293
+ <For each={candidates()}>
294
+ {(c) => (
290
295
  <div class="flex items-start gap-1.5 px-1.5 py-1 border-b border-neutral-900
291
296
  hover:bg-neutral-900/60">
292
- <span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{e.i}</span>
293
- <span class={`text-[9px] font-mono w-8 shrink-0 ${
294
- e.kind === 'raw' ? 'text-emerald-400/80' : 'text-orange-400/80'
295
- }`}>{e.kind}</span>
296
- <span class="text-[10px] text-neutral-400 flex-1 truncate"
297
- title={e.text ?? ''}>{(e.text ?? '').slice(0, 90)}</span>
297
+ <span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{c.index}</span>
298
+ <span class="text-[9px] font-mono text-cyan-400/70 w-12 shrink-0 truncate">
299
+ {c.participant}
300
+ </span>
301
+ <span class="text-[10px] text-neutral-400 flex-1 truncate" title={c.text}>
302
+ {c.text.slice(0, 90)}
303
+ </span>
298
304
  <button type="button"
299
305
  class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
300
306
  text-neutral-400 shrink-0"
301
- onClick={() => setFirst(e.id!)}>from</button>
307
+ onClick={() => setFirst(c.id)}>from</button>
302
308
  <button type="button" disabled={asDoc()}
303
309
  class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
304
310
  text-neutral-400 shrink-0 disabled:opacity-30"
305
- onClick={() => setLast(e.id!)}>to</button>
311
+ onClick={() => setLast(c.id)}>to</button>
306
312
  </div>
307
313
  )}
308
314
  </For>
309
315
  </div>
310
- <div class="text-[10px] text-neutral-600 mt-1">
311
- Entries without a store id (merged summaries) are omitted — a pin needs a message id.
312
- </div>
313
316
  </Show>
314
317
  </div>
315
318
  </Show>