@inflexa-ai/harness 0.16.0 → 0.17.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.
Files changed (93) hide show
  1. package/dist/agents/conversation-agent.d.ts +3 -0
  2. package/dist/agents/conversation-agent.js +5 -2
  3. package/dist/agents/sandbox/shared.d.ts +3 -0
  4. package/dist/agents/sandbox/shared.js +5 -0
  5. package/dist/agents/sandbox/types.d.ts +1 -1
  6. package/dist/app/chat-turn.d.ts +2 -0
  7. package/dist/app/chat-turn.js +11 -2
  8. package/dist/app/synthesize-run.d.ts +3 -0
  9. package/dist/app/synthesize-run.js +2 -1
  10. package/dist/citations/__fixtures__/resolver.d.ts +3 -0
  11. package/dist/citations/__fixtures__/resolver.js +9 -0
  12. package/dist/citations/aggregate.d.ts +3 -0
  13. package/dist/citations/aggregate.js +44 -0
  14. package/dist/citations/clients/arxiv.d.ts +6 -0
  15. package/dist/citations/clients/arxiv.js +34 -0
  16. package/dist/citations/clients/common.d.ts +11 -0
  17. package/dist/citations/clients/common.js +43 -0
  18. package/dist/citations/clients/crossref.d.ts +8 -0
  19. package/dist/citations/clients/crossref.js +98 -0
  20. package/dist/citations/clients/doi-registry.d.ts +3 -0
  21. package/dist/citations/clients/doi-registry.js +95 -0
  22. package/dist/citations/clients/pubmed.d.ts +6 -0
  23. package/dist/citations/clients/pubmed.js +83 -0
  24. package/dist/citations/clients/semantic-scholar.d.ts +6 -0
  25. package/dist/citations/clients/semantic-scholar.js +60 -0
  26. package/dist/citations/compare.d.ts +9 -0
  27. package/dist/citations/compare.js +72 -0
  28. package/dist/citations/match.d.ts +11 -0
  29. package/dist/citations/match.js +173 -0
  30. package/dist/citations/normalize.d.ts +31 -0
  31. package/dist/citations/normalize.js +187 -0
  32. package/dist/citations/plan.d.ts +2 -0
  33. package/dist/citations/plan.js +59 -0
  34. package/dist/citations/resolve.d.ts +28 -0
  35. package/dist/citations/resolve.js +238 -0
  36. package/dist/citations/types.d.ts +583 -0
  37. package/dist/citations/types.js +129 -0
  38. package/dist/contracts/schemas/chat-parts.d.ts +8 -8
  39. package/dist/contracts/target-dossier.d.ts +20 -20
  40. package/dist/execution/run-synthesis.d.ts +3 -0
  41. package/dist/execution/run-synthesis.js +1 -0
  42. package/dist/index.d.ts +6 -1
  43. package/dist/index.js +3 -0
  44. package/dist/lib/cache.d.ts +8 -0
  45. package/dist/lib/cache.js +35 -0
  46. package/dist/lib/rate-limit.d.ts +35 -0
  47. package/dist/lib/rate-limit.js +121 -0
  48. package/dist/literature/sources/arxiv.d.ts +22 -0
  49. package/dist/literature/sources/arxiv.js +103 -0
  50. package/dist/literature/sources/http.d.ts +28 -0
  51. package/dist/literature/sources/http.js +97 -0
  52. package/dist/literature/sources/pubmed.d.ts +68 -0
  53. package/dist/literature/sources/pubmed.js +266 -0
  54. package/dist/literature/sources/semantic-scholar.d.ts +24 -0
  55. package/dist/literature/sources/semantic-scholar.js +71 -0
  56. package/dist/memory/thread-store.d.ts +142 -25
  57. package/dist/memory/thread-store.js +228 -40
  58. package/dist/prompts/literature-reviewer.d.ts +1 -1
  59. package/dist/prompts/literature-reviewer.js +2 -0
  60. package/dist/runtime/assemble.d.ts +50 -4
  61. package/dist/runtime/assemble.js +47 -3
  62. package/dist/runtime/boot.js +7 -1
  63. package/dist/state/init.js +46 -1
  64. package/dist/state/schema.d.ts +2 -2
  65. package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
  66. package/dist/tools/bio/keys.d.ts +1 -0
  67. package/dist/tools/bio/pubmed.js +4 -4
  68. package/dist/tools/lib/ncbi-utils.d.ts +3 -58
  69. package/dist/tools/lib/ncbi-utils.js +2 -286
  70. package/dist/tools/lib/pubmed-client.d.ts +5 -12
  71. package/dist/tools/lib/pubmed-client.js +20 -79
  72. package/dist/tools/research/generate-analogy-report.js +2 -2
  73. package/dist/tools/research/index.d.ts +1 -0
  74. package/dist/tools/research/index.js +1 -0
  75. package/dist/tools/research/inspect-run.d.ts +1 -1
  76. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  77. package/dist/tools/research/literature-reviewer.js +2 -0
  78. package/dist/tools/research/resolve-citation.d.ts +3 -0
  79. package/dist/tools/research/resolve-citation.js +15 -0
  80. package/dist/tools/research/search-arxiv.d.ts +3 -19
  81. package/dist/tools/research/search-arxiv.js +9 -105
  82. package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
  83. package/dist/tools/research/search-semantic-scholar.js +29 -88
  84. package/dist/tools/sandbox/list-available-refs.js +1 -2
  85. package/dist/workflows/execute-analysis.d.ts +3 -0
  86. package/dist/workflows/execute-analysis.js +1 -0
  87. package/dist/workflows/sandbox-step.d.ts +5 -0
  88. package/dist/workflows/sandbox-step.js +1 -0
  89. package/dist/workflows/target-assessment/phase4-assemble.d.ts +2 -2
  90. package/dist/workflows/target-assessment/schemas.d.ts +2 -2
  91. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -1
  92. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
  93. package/package.json +1 -1
@@ -10,20 +10,24 @@
10
10
  * the vocabulary is conversation-shaped on purpose — reaching for it inside
11
11
  * a workflow step feels immediately wrong.
12
12
  *
13
- * Three lifecycle verbs with distinct guarantees. `archiveThread` is
14
- * recoverable: it stamps `deleted_at`, and because `getThread` and the default
13
+ * A thread may name another as its parent, so the rows under one analysis form
14
+ * a forest rather than a flat list. The three lifecycle verbs each answer that
15
+ * edge differently, and the differences are the substance of this module.
16
+ *
17
+ * `archiveThread` is recoverable: it stamps `deleted_at` on the named thread
18
+ * and on every descendant, and because `getThread` and the default
15
19
  * `listThreads` filter `deleted_at IS NULL` an archived thread is
16
20
  * indistinguishable from an absent one while its row and every one of its
17
- * `messages` rows stay in storage. `unarchiveThread` clears the stamp and the
18
- * thread reads as it did; a listing widened with `includeArchived` is the only
19
- * way to obtain the id it takes, so the recovery is reachable by a host that
20
- * holds no thread ids it obtained elsewhere.
21
+ * `messages` rows stay in storage. `unarchiveThread` clears the stamp on ONE
22
+ * row and the thread reads as it did; a listing widened with `includeArchived`
23
+ * is the only way to obtain the id it takes, so the recovery is reachable by a
24
+ * host that holds no thread ids it obtained elsewhere.
21
25
  * `purgeThread` is not recoverable: it removes the `messages` rows and the
22
- * metadata row in one transaction, so a failure partway leaves both — never a
23
- * thread stripped of its transcript, nor a transcript with nothing naming it.
24
- * It is the thread-scoped member of this package's reclamation vocabulary —
25
- * `purgeAnalysis` reclaims an analysis's whole persisted footprint,
26
- * `purgeThread` one thread's.
26
+ * metadata rows of the whole subtree in one transaction, so a failure partway
27
+ * leaves both — never a thread stripped of its transcript, nor a transcript
28
+ * with nothing naming it. It is the thread-scoped member of this package's
29
+ * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
30
+ * persisted footprint, `purgeThread` one subtree's.
27
31
  *
28
32
  * A purge creates no orphan of its own, but it is NOT serialized against a
29
33
  * concurrent `appendTurn`: `messages` carries no foreign key to
@@ -31,17 +35,51 @@
31
35
  * metadata row. A turn committing after the purge therefore persists messages
32
36
  * under a `thread_id` that no longer resolves to an analysis, leaving them
33
37
  * unreachable by any later thread- or analysis-scoped reclamation. Stop writes
34
- * to a thread — unbind it from any live conversation — before purging it; this
35
- * store cannot observe a host's in-flight turns, so it does not enforce that.
38
+ * to every thread in the subtree — unbind each from any live conversation —
39
+ * before purging its root; this store cannot observe a host's in-flight turns,
40
+ * so it does not enforce that.
36
41
  */
37
42
  import { type ResultAsync } from "neverthrow";
38
43
  import type { Pool } from "pg";
39
44
  import { type DbError } from "../lib/db-result.js";
45
+ /**
46
+ * The kind of session a thread holds. `conversation` is the analysis
47
+ * conversation the product has always had; `report` is a report session spawned
48
+ * from one.
49
+ *
50
+ * The set is closed rather than free-form because a thread's type is what
51
+ * selects the agent that runs it. That resolution is a separate capability, and
52
+ * it can only be exhaustive over a membership something can enumerate — a
53
+ * free-form column pushes an unmatched value into a fallback no reader has the
54
+ * list to write.
55
+ *
56
+ * The array is the single declaration and the union is derived from it, so the
57
+ * run-time check `createThread` needs — for a value that reached the store from
58
+ * outside the compiler's reach — cannot drift from the type callers program
59
+ * against.
60
+ */
61
+ declare const THREAD_TYPES: readonly ["conversation", "report"];
62
+ export type ThreadType = (typeof THREAD_TYPES)[number];
40
63
  /** One conversation thread's metadata, as returned by the store. */
41
64
  export interface Thread {
42
65
  readonly threadId: string;
43
66
  readonly analysisId: string;
44
67
  readonly title: string | null;
68
+ /** Which kind of session this row holds. A thread created without one is a `conversation`. */
69
+ readonly threadType: ThreadType;
70
+ /** The thread this one was spawned from — `null` on a thread that stands on its own. */
71
+ readonly parentThreadId: string | null;
72
+ /**
73
+ * The parent's `messages.seq` at the moment of the spawn: the frozen point
74
+ * in the parent's transcript this thread was built on. `null` exactly when
75
+ * `parentThreadId` is — half the pair names nothing a consumer can resolve.
76
+ *
77
+ * It records the spawn point, not a promise that the prefix still reads the
78
+ * same: `retractLastTurn` can cut the parent's tail behind an anchor, so a
79
+ * reader must treat an anchor past the parent's current end as a normal
80
+ * state rather than a corruption.
81
+ */
82
+ readonly parentSeq: number | null;
45
83
  readonly createdAt: Date;
46
84
  readonly updatedAt: Date;
47
85
  /**
@@ -56,7 +94,51 @@ export interface CreateThreadInput {
56
94
  readonly threadId: string;
57
95
  readonly analysisId: string;
58
96
  readonly title?: string | null;
97
+ /** Omitted is `conversation` — what a thread is when nothing says otherwise. */
98
+ readonly type?: ThreadType;
99
+ /**
100
+ * The thread this one is spawned from. It must belong to the same analysis
101
+ * as the child; whether it exists at all is the foreign key's verdict, not
102
+ * this store's.
103
+ */
104
+ readonly parentThreadId?: string;
105
+ /**
106
+ * The parent's `messages.seq` at the spawn. Supply it with `parentThreadId`
107
+ * or neither: the two are one fact — which transcript, and how much of it —
108
+ * and either half alone describes no place.
109
+ */
110
+ readonly parentSeq?: number;
59
111
  }
112
+ /**
113
+ * A create the store refuses on its own, before any row is written. Distinct
114
+ * from `DbError`: nothing failed at the driver — the request described a thread
115
+ * the store will not write, and each variant carries the identifiers that say
116
+ * which values disagreed.
117
+ *
118
+ * Only the rules the store enforces itself live here. A `parentThreadId` naming
119
+ * no row is not one of them: the foreign key refuses that insert and
120
+ * `tryMutation` classifies the refusal as the `constraint_violation` variant of
121
+ * `DbError`.
122
+ */
123
+ export type ThreadInputError = {
124
+ readonly type: "parent_analysis_mismatch";
125
+ readonly op: string;
126
+ readonly threadId: string;
127
+ readonly analysisId: string;
128
+ readonly parentThreadId: string;
129
+ readonly parentAnalysisId: string;
130
+ } | {
131
+ readonly type: "parent_anchor_unpaired";
132
+ readonly op: string;
133
+ readonly threadId: string;
134
+ readonly parentThreadId: string | null;
135
+ readonly parentSeq: number | null;
136
+ } | {
137
+ readonly type: "unknown_thread_type";
138
+ readonly op: string;
139
+ readonly threadId: string;
140
+ readonly threadType: string;
141
+ };
60
142
  export interface ListThreadsInput {
61
143
  readonly analysisId: string;
62
144
  readonly page?: number;
@@ -68,6 +150,20 @@ export interface ListThreadsInput {
68
150
  * widened back out of.
69
151
  */
70
152
  readonly includeArchived?: boolean;
153
+ /**
154
+ * Narrow to one kind of session, exact match. Omitting it narrows NOTHING —
155
+ * a caller that supplies no filter receives every type, which is what a
156
+ * session picker wants: a user browsing an analysis expects to reach a
157
+ * report session directly.
158
+ *
159
+ * That is the opposite polarity to `includeArchived`, which widens a listing
160
+ * that is narrow by default, and the difference is deliberate. An archived
161
+ * thread is hidden state — showing it takes an explicit ask. A report
162
+ * session is not hidden state, so nothing has to ask for it.
163
+ */
164
+ readonly type?: ThreadType;
165
+ /** Narrow to one thread's direct children, exact match. Same polarity as `type`: omitting it narrows nothing. */
166
+ readonly parentThreadId?: string;
71
167
  }
72
168
  export interface ThreadPage {
73
169
  readonly threads: Thread[];
@@ -80,9 +176,16 @@ export interface ThreadStore {
80
176
  /**
81
177
  * Create a thread row. Idempotent on `thread_id` — a second create for an
82
178
  * existing id is a no-op that preserves the existing row (including its
83
- * `created_at`). Returns the live row.
179
+ * `created_at`) and reads none of the supplied type, parent, or anchor. A
180
+ * caller that needs those persisted compares them against the returned row.
181
+ * Returns the live row.
182
+ *
183
+ * Refused with a `ThreadInputError`, no row written: a parent belonging to
184
+ * another analysis, a parent supplied without its anchor or an anchor
185
+ * without its parent, and a type outside the closed set. A parent naming no
186
+ * row is the foreign key's refusal and arrives as a `DbError`.
84
187
  */
85
- createThread(input: CreateThreadInput): ResultAsync<Thread, DbError>;
188
+ createThread(input: CreateThreadInput): ResultAsync<Thread, DbError | ThreadInputError>;
86
189
  /** The live thread by id, or `null` if absent or archived. */
87
190
  getThread(threadId: string): ResultAsync<Thread | null, DbError>;
88
191
  /**
@@ -91,26 +194,39 @@ export interface ThreadStore {
91
194
  */
92
195
  updateTitle(threadId: string, title: string): ResultAsync<Thread | null, DbError>;
93
196
  /**
94
- * Soft-delete: stamp `deleted_at` so the thread leaves `getThread` and
95
- * `listThreads`. The row and every one of its messages remain, and an
96
- * already-archived thread keeps the stamp it got the first time.
197
+ * Soft-delete the subtree: stamp `deleted_at` on the named thread and on
198
+ * every descendant reachable through `parent_thread_id`, at any depth, so a
199
+ * hidden thread never leaves a visible child behind. Every row and every one
200
+ * of their messages remain, and any thread in the subtree that was already
201
+ * archived keeps the stamp it got the first time.
97
202
  */
98
203
  archiveThread(threadId: string): ResultAsync<void, DbError>;
99
204
  /**
100
- * Clear `deleted_at`, returning the thread and its messages to view exactly
101
- * as they read before the archive. No-op on a live or absent thread.
205
+ * Clear `deleted_at` on the named thread ALONE, returning it and its
206
+ * messages to view exactly as they read before the archive; every descendant
207
+ * is left as it was found. No-op on a live or absent thread.
208
+ *
209
+ * The asymmetry against `archiveThread` is what keeps the schema honest. A
210
+ * symmetric cascade would restore a child the user had archived on its own
211
+ * beforehand, so a row would have to record whether a cascade or a
212
+ * deliberate action set its tombstone — a fourth column carrying nothing
213
+ * else. With the asymmetry no row needs that distinction, and every archived
214
+ * thread is recovered the same way: by naming it, which a listing widened
215
+ * with `includeArchived` supplies.
102
216
  */
103
217
  unarchiveThread(threadId: string): ResultAsync<void, DbError>;
104
218
  /**
105
- * Reclaim the thread's whole footprint: its `messages` rows and its
106
- * `cortex_analysis_threads` row, removed together in one transaction.
107
- * Unrecoverable — nothing of the thread survives, and no tombstone marks
108
- * that it existed. A `thread_id` with no row succeeds as a no-op.
219
+ * Reclaim the subtree's whole footprint: the named thread, every descendant
220
+ * reachable through `parent_thread_id` at any depth, and the `messages` rows
221
+ * of every one of them, removed together in one transaction. Unrecoverable —
222
+ * nothing survives, and no tombstone marks that any of it existed. A
223
+ * `thread_id` with no row succeeds as a no-op.
109
224
  */
110
225
  purgeThread(threadId: string): ResultAsync<void, DbError>;
111
226
  /**
112
227
  * Threads for one analysis, newest-updated first, paginated. Live threads
113
- * only unless `includeArchived` widens the set; `total` and `hasMore`
228
+ * only unless `includeArchived` widens the set, and every type under every
229
+ * parent unless `type` / `parentThreadId` narrow it; `total` and `hasMore`
114
230
  * always describe whichever set the page was drawn from.
115
231
  */
116
232
  listThreads(input: ListThreadsInput): ResultAsync<ThreadPage, DbError>;
@@ -121,3 +237,4 @@ export interface ThreadStore {
121
237
  * `cortex_analysis_threads` table is provisioned by the state-init DDL.
122
238
  */
123
239
  export declare function createThreadStore(pool: Pool): ThreadStore;
240
+ export {};
@@ -10,20 +10,24 @@
10
10
  * the vocabulary is conversation-shaped on purpose — reaching for it inside
11
11
  * a workflow step feels immediately wrong.
12
12
  *
13
- * Three lifecycle verbs with distinct guarantees. `archiveThread` is
14
- * recoverable: it stamps `deleted_at`, and because `getThread` and the default
13
+ * A thread may name another as its parent, so the rows under one analysis form
14
+ * a forest rather than a flat list. The three lifecycle verbs each answer that
15
+ * edge differently, and the differences are the substance of this module.
16
+ *
17
+ * `archiveThread` is recoverable: it stamps `deleted_at` on the named thread
18
+ * and on every descendant, and because `getThread` and the default
15
19
  * `listThreads` filter `deleted_at IS NULL` an archived thread is
16
20
  * indistinguishable from an absent one while its row and every one of its
17
- * `messages` rows stay in storage. `unarchiveThread` clears the stamp and the
18
- * thread reads as it did; a listing widened with `includeArchived` is the only
19
- * way to obtain the id it takes, so the recovery is reachable by a host that
20
- * holds no thread ids it obtained elsewhere.
21
+ * `messages` rows stay in storage. `unarchiveThread` clears the stamp on ONE
22
+ * row and the thread reads as it did; a listing widened with `includeArchived`
23
+ * is the only way to obtain the id it takes, so the recovery is reachable by a
24
+ * host that holds no thread ids it obtained elsewhere.
21
25
  * `purgeThread` is not recoverable: it removes the `messages` rows and the
22
- * metadata row in one transaction, so a failure partway leaves both — never a
23
- * thread stripped of its transcript, nor a transcript with nothing naming it.
24
- * It is the thread-scoped member of this package's reclamation vocabulary —
25
- * `purgeAnalysis` reclaims an analysis's whole persisted footprint,
26
- * `purgeThread` one thread's.
26
+ * metadata rows of the whole subtree in one transaction, so a failure partway
27
+ * leaves both — never a thread stripped of its transcript, nor a transcript
28
+ * with nothing naming it. It is the thread-scoped member of this package's
29
+ * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
30
+ * persisted footprint, `purgeThread` one subtree's.
27
31
  *
28
32
  * A purge creates no orphan of its own, but it is NOT serialized against a
29
33
  * concurrent `appendTurn`: `messages` carries no foreign key to
@@ -31,16 +35,66 @@
31
35
  * metadata row. A turn committing after the purge therefore persists messages
32
36
  * under a `thread_id` that no longer resolves to an analysis, leaving them
33
37
  * unreachable by any later thread- or analysis-scoped reclamation. Stop writes
34
- * to a thread — unbind it from any live conversation — before purging it; this
35
- * store cannot observe a host's in-flight turns, so it does not enforce that.
38
+ * to every thread in the subtree — unbind each from any live conversation —
39
+ * before purging its root; this store cannot observe a host's in-flight turns,
40
+ * so it does not enforce that.
36
41
  */
37
- import { okAsync } from "neverthrow";
42
+ import { errAsync, okAsync } from "neverthrow";
38
43
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
44
+ /**
45
+ * The kind of session a thread holds. `conversation` is the analysis
46
+ * conversation the product has always had; `report` is a report session spawned
47
+ * from one.
48
+ *
49
+ * The set is closed rather than free-form because a thread's type is what
50
+ * selects the agent that runs it. That resolution is a separate capability, and
51
+ * it can only be exhaustive over a membership something can enumerate — a
52
+ * free-form column pushes an unmatched value into a fallback no reader has the
53
+ * list to write.
54
+ *
55
+ * The array is the single declaration and the union is derived from it, so the
56
+ * run-time check `createThread` needs — for a value that reached the store from
57
+ * outside the compiler's reach — cannot drift from the type callers program
58
+ * against.
59
+ */
60
+ const THREAD_TYPES = ["conversation", "report"];
61
+ function isThreadType(value) {
62
+ return THREAD_TYPES.some((known) => known === value);
63
+ }
64
+ const _assertDomainError = true;
65
+ /**
66
+ * The projection every read of a thread row shares. Fixed text, never caller
67
+ * input, and written once so no statement can return a row that is short a
68
+ * column `toThread` reads — a gap that would surface as an `undefined` field on
69
+ * a `Thread`, not as a failure.
70
+ */
71
+ const THREAD_COLUMNS = "thread_id, analysis_id, title, thread_type, parent_thread_id, parent_seq::text AS parent_seq, created_at, updated_at, deleted_at";
72
+ /**
73
+ * The named thread plus every descendant reachable through `parent_thread_id`,
74
+ * at any depth. Fixed text taking the thread id as `$1`, and the single
75
+ * definition the subtree statements share, so archive and purge cannot come to
76
+ * walk to different depths.
77
+ *
78
+ * `UNION`, not `UNION ALL`: deduplicating against the rows already collected
79
+ * makes the walk terminate even if a row ever came to name an ancestor as its
80
+ * parent — a shape no write path here produces, and one the column does not
81
+ * forbid either.
82
+ */
83
+ const SUBTREE_CTE = `WITH RECURSIVE subtree(thread_id) AS (
84
+ SELECT thread_id FROM cortex_analysis_threads WHERE thread_id = $1
85
+ UNION
86
+ SELECT child.thread_id
87
+ FROM cortex_analysis_threads child
88
+ JOIN subtree ON child.parent_thread_id = subtree.thread_id
89
+ )`;
39
90
  function toThread(row) {
40
91
  return {
41
92
  threadId: row.thread_id,
42
93
  analysisId: row.analysis_id,
43
94
  title: row.title,
95
+ threadType: row.thread_type,
96
+ parentThreadId: row.parent_thread_id,
97
+ parentSeq: row.parent_seq === null ? null : Number(row.parent_seq),
44
98
  createdAt: row.created_at,
45
99
  updatedAt: row.updated_at,
46
100
  deletedAt: row.deleted_at,
@@ -54,22 +108,100 @@ const MAX_PER_PAGE = 200;
54
108
  * `cortex_analysis_threads` table is provisioned by the state-init DDL.
55
109
  */
56
110
  export function createThreadStore(pool) {
57
- function createThread(input) {
58
- return tryMutation("thread-store.createThread.insert", () => pool.query(`INSERT INTO cortex_analysis_threads (thread_id, analysis_id, title)
59
- VALUES ($1, $2, $3)
111
+ /**
112
+ * The parent's own analysis scope, or `null` when no such row exists.
113
+ *
114
+ * Absence is deliberately not a verdict here: a `parentThreadId` naming no
115
+ * row falls through to the insert, where the foreign key refuses it and
116
+ * `tryMutation` classifies that refusal as the `constraint_violation`
117
+ * variant of `DbError`. Pre-empting the constraint with an existence query
118
+ * would buy a round trip and a window between the check and the insert in
119
+ * which the parent could vanish, for a verdict the constraint already
120
+ * reaches on its own.
121
+ *
122
+ * The lookup does not filter `deleted_at IS NULL`: an archived parent is a
123
+ * real row holding a real transcript, so a child anchored into it is
124
+ * describing something that exists.
125
+ */
126
+ function parentAnalysisOf(parentThreadId) {
127
+ return tryQuery("thread-store.createThread.parentScope", () => pool.query("SELECT analysis_id FROM cortex_analysis_threads WHERE thread_id = $1", [parentThreadId])).map(({ rows }) => rows[0]?.analysis_id ?? null);
128
+ }
129
+ /**
130
+ * The write itself, past the rules the store enforces before it.
131
+ *
132
+ * Idempotency wins over everything the caller supplied: `ON CONFLICT
133
+ * (thread_id) DO NOTHING` short-circuits before any constraint is evaluated
134
+ * and reads no supplied parent, so a repeat create returns the existing row
135
+ * unchanged — even one naming a parent that does not exist. A caller that
136
+ * needs its type, parent, or anchor persisted compares them against the
137
+ * returned row.
138
+ */
139
+ function insertThread(input) {
140
+ return tryMutation("thread-store.createThread.insert", () => pool.query(`INSERT INTO cortex_analysis_threads (thread_id, analysis_id, title, thread_type, parent_thread_id, parent_seq)
141
+ VALUES ($1, $2, $3, $4, $5, $6)
60
142
  ON CONFLICT (thread_id) DO NOTHING
61
- RETURNING thread_id, analysis_id, title, created_at, updated_at, deleted_at`, [input.threadId, input.analysisId, input.title ?? null])).andThen(({ rows }) => {
143
+ RETURNING ${THREAD_COLUMNS}`, [input.threadId, input.analysisId, input.title ?? null, input.type ?? "conversation", input.parentThreadId ?? null, input.parentSeq ?? null])).andThen(({ rows }) => {
62
144
  if (rows[0])
63
145
  return okAsync(toThread(rows[0]));
64
146
  // Row already existed — ON CONFLICT DO NOTHING returns nothing. Read it
65
147
  // back (it may be archived; return it regardless so the caller sees
66
148
  // the existing row's identity).
67
- return tryQuery("thread-store.createThread.readback", () => pool.query(`SELECT thread_id, analysis_id, title, created_at, updated_at, deleted_at
68
- FROM cortex_analysis_threads WHERE thread_id = $1`, [input.threadId])).map(({ rows: existing }) => toThread(existing[0]));
149
+ return tryQuery("thread-store.createThread.readback", () => pool.query(`SELECT ${THREAD_COLUMNS} FROM cortex_analysis_threads WHERE thread_id = $1`, [input.threadId])).map(({ rows: existing }) => toThread(existing[0]));
150
+ });
151
+ }
152
+ function createThread(input) {
153
+ // Widened on purpose: the compile-time union covers a caller inside this
154
+ // package's type graph, and this check is for the value that reached the
155
+ // store from outside it — across a package boundary, out of a JSON body,
156
+ // off a row some other writer put there.
157
+ const requestedType = input.type;
158
+ if (requestedType !== undefined && !isThreadType(requestedType)) {
159
+ return errAsync({
160
+ type: "unknown_thread_type",
161
+ op: "thread-store.createThread",
162
+ threadId: input.threadId,
163
+ threadType: requestedType,
164
+ });
165
+ }
166
+ const parentThreadId = input.parentThreadId ?? null;
167
+ const parentSeq = input.parentSeq ?? null;
168
+ // Both halves of the edge or neither: the anchor is the point in the
169
+ // parent's transcript this thread was spawned from, and an edge without
170
+ // one leaves a consumer no way to tell which prefix of the parent the
171
+ // child stands on. Every rule below is gated on a value being supplied,
172
+ // so a create that names neither reaches none of them and takes the same
173
+ // path a first-turn conversation has always taken.
174
+ if ((parentThreadId === null) !== (parentSeq === null)) {
175
+ return errAsync({
176
+ type: "parent_anchor_unpaired",
177
+ op: "thread-store.createThread",
178
+ threadId: input.threadId,
179
+ parentThreadId,
180
+ parentSeq,
181
+ });
182
+ }
183
+ if (parentThreadId === null)
184
+ return insertThread(input);
185
+ // A data-integrity rule, not an authorization one: it keeps a parent edge
186
+ // inside a single analysis, so a subtree walk can never cross into
187
+ // another analysis's rows. The store never compares a caller's request
188
+ // scope against a row.
189
+ return parentAnalysisOf(parentThreadId).andThen((parentAnalysisId) => {
190
+ if (parentAnalysisId !== null && parentAnalysisId !== input.analysisId) {
191
+ return errAsync({
192
+ type: "parent_analysis_mismatch",
193
+ op: "thread-store.createThread",
194
+ threadId: input.threadId,
195
+ analysisId: input.analysisId,
196
+ parentThreadId,
197
+ parentAnalysisId,
198
+ });
199
+ }
200
+ return insertThread(input);
69
201
  });
70
202
  }
71
203
  function getThread(threadId) {
72
- return tryQuery("thread-store.getThread", () => pool.query(`SELECT thread_id, analysis_id, title, created_at, updated_at, deleted_at
204
+ return tryQuery("thread-store.getThread", () => pool.query(`SELECT ${THREAD_COLUMNS}
73
205
  FROM cortex_analysis_threads
74
206
  WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])).map(({ rows }) => (rows[0] ? toThread(rows[0]) : null));
75
207
  }
@@ -86,19 +218,42 @@ export function createThreadStore(pool) {
86
218
  `UPDATE cortex_analysis_threads
87
219
  SET title = $2, updated_at = GREATEST(updated_at, clock_timestamp())
88
220
  WHERE thread_id = $1 AND deleted_at IS NULL
89
- RETURNING thread_id, analysis_id, title, created_at, updated_at, deleted_at`, [threadId, title])).map(({ rows }) => (rows[0] ? toThread(rows[0]) : null));
221
+ RETURNING ${THREAD_COLUMNS}`, [threadId, title])).map(({ rows }) => (rows[0] ? toThread(rows[0]) : null));
90
222
  }
91
223
  function archiveThread(threadId) {
92
224
  return tryMutation("thread-store.archiveThread", () => pool.query(
225
+ // The stamp reaches the whole subtree because a hidden thread must
226
+ // not leave a visible child behind — and to any depth, since the
227
+ // column permits a grandchild and a one-level sweep would leave one
228
+ // listed under a parent the user can no longer see.
229
+ //
93
230
  // The tombstone guard is what makes a second archive change nothing:
94
- // the stamp records when the thread left view, and re-stamping it
95
- // would push that moment forward on every repeat.
96
- `UPDATE cortex_analysis_threads
231
+ // the stamp records when a thread left view, and re-stamping it would
232
+ // push that moment forward on every repeat. It sits on the UPDATE and
233
+ // not on the walk, because a walk that stopped at an already-archived
234
+ // child would never reach the live grandchild underneath it.
235
+ //
236
+ // `NOW()` is the transaction's clock, so every row this sweep reaches
237
+ // carries the same stamp — one archive action reads back as one moment.
238
+ //
239
+ // `updated_at` is untouched, for the reason `unarchiveThread` carries:
240
+ // it orders the listing by conversation activity, and moving a thread
241
+ // out of view is not activity.
242
+ `${SUBTREE_CTE}
243
+ UPDATE cortex_analysis_threads
97
244
  SET deleted_at = NOW()
98
- WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])).map(() => undefined);
245
+ WHERE thread_id IN (SELECT thread_id FROM subtree) AND deleted_at IS NULL`, [threadId])).map(() => undefined);
99
246
  }
100
247
  function unarchiveThread(threadId) {
101
248
  return tryMutation("thread-store.unarchiveThread", () => pool.query(
249
+ // One row, against an archive that sweeps the subtree. The asymmetry
250
+ // is what keeps the schema honest: a symmetric cascade would restore
251
+ // a child the user had archived on its own before the parent was, so
252
+ // a row would have to record whether a cascade or a deliberate action
253
+ // set its tombstone — a fourth column carrying nothing else. With the
254
+ // asymmetry no row carries that distinction, and every archived
255
+ // thread is recovered the same way, by naming it.
256
+ //
102
257
  // `updated_at` is untouched on purpose: it orders the listing by
103
258
  // conversation activity, and restoring a thread to view is not
104
259
  // activity — a restored thread lands back where its last turn left it.
@@ -111,8 +266,21 @@ export function createThreadStore(pool) {
111
266
  // analysis only by joining through `cortex_analysis_threads`, so a metadata
112
267
  // row removed without its messages strands them beyond the reach of any
113
268
  // later reclamation. Either the pair goes or neither does.
114
- return withTransaction(pool, "thread-store.purgeThread", (client) => tryMutation("thread-store.purgeThread.messages", () => client.query("DELETE FROM messages WHERE thread_id = $1", [threadId]))
115
- .andThen(() => tryMutation("thread-store.purgeThread.thread", () => client.query("DELETE FROM cortex_analysis_threads WHERE thread_id = $1", [threadId])))
269
+ //
270
+ // The explicit message delete must reach the same depth the database
271
+ // cascade reaches. The cascade on `parent_thread_id` removes a
272
+ // descendant's row recursively, but it cannot touch that descendant's
273
+ // `messages` — no foreign key connects them — so a one-level delete here
274
+ // would strand a grandchild's transcript exactly as a bare cascade strands
275
+ // a child's.
276
+ //
277
+ // Messages go first: the walk both statements share reads the very rows
278
+ // the second one removes, so the reverse order would leave the subtree
279
+ // unresolvable by the time its transcripts were named.
280
+ return withTransaction(pool, "thread-store.purgeThread", (client) => tryMutation("thread-store.purgeThread.messages", () => client.query(`${SUBTREE_CTE}
281
+ DELETE FROM messages WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId]))
282
+ .andThen(() => tryMutation("thread-store.purgeThread.thread", () => client.query(`${SUBTREE_CTE}
283
+ DELETE FROM cortex_analysis_threads WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
116
284
  .map(() => undefined));
117
285
  }
118
286
  function listThreads(input) {
@@ -121,25 +289,45 @@ export function createThreadStore(pool) {
121
289
  const offset = page * perPage;
122
290
  // The count and the page are two statements over what has to be one set:
123
291
  // a total drawn from a different predicate than the page's would report a
124
- // size the caller can never page to. Writing the row scope once — one of
125
- // two fixed fragments, never caller text — makes them unable to disagree.
292
+ // size the caller can never page to. Building the row scope once — the
293
+ // fragment AND the values it binds — makes them unable to disagree. Every
294
+ // predicate is fixed text and every value rides as a bound parameter, so
295
+ // no caller string ever reaches the SQL.
126
296
  //
127
- // The widened scope is the one that cannot use the table's index, which is
128
- // partial on `deleted_at IS NULL`: dropping the predicate drops the index
129
- // with it, so an archived-inclusive listing scans. Deliberate — the live
130
- // listing is the hot path and keeps its index, while widening is a rare,
131
- // user-initiated recovery. A deployment that made it routine would want a
132
- // plain `analysis_id` index beside the partial one, not this listing changed.
133
- const scope = input.includeArchived ? "analysis_id = $1" : "analysis_id = $1 AND deleted_at IS NULL";
297
+ // The widened scope is the one that cannot use the `analysis_id` index,
298
+ // which is partial on `deleted_at IS NULL`: dropping the predicate drops
299
+ // the index with it, so an archived-inclusive listing scans. Deliberate —
300
+ // the live listing is the hot path and keeps its index, while widening is a
301
+ // rare, user-initiated recovery. A deployment that made it routine would
302
+ // want a plain `analysis_id` index beside the partial one, not this listing
303
+ // changed. A listing narrowed by parent is indexed either way: the
304
+ // `parent_thread_id` index carries no predicate, for the subtree walk's sake.
305
+ const scopeValues = [input.analysisId];
306
+ const predicates = ["analysis_id = $1"];
307
+ if (!input.includeArchived)
308
+ predicates.push("deleted_at IS NULL");
309
+ if (input.type !== undefined) {
310
+ scopeValues.push(input.type);
311
+ predicates.push(`thread_type = $${scopeValues.length}`);
312
+ }
313
+ if (input.parentThreadId !== undefined) {
314
+ scopeValues.push(input.parentThreadId);
315
+ predicates.push(`parent_thread_id = $${scopeValues.length}`);
316
+ }
317
+ const scope = predicates.join(" AND ");
318
+ // The page's two extra placeholders sit past the scope's, so a filter
319
+ // added to the scope shifts them rather than colliding with them.
320
+ const limitPlaceholder = scopeValues.length + 1;
321
+ const offsetPlaceholder = scopeValues.length + 2;
134
322
  return tryQuery("thread-store.listThreads.count", () => pool.query(`SELECT COUNT(*)::text AS count
135
323
  FROM cortex_analysis_threads
136
- WHERE ${scope}`, [input.analysisId])).andThen((totalResult) => {
324
+ WHERE ${scope}`, scopeValues)).andThen((totalResult) => {
137
325
  const total = Number(totalResult.rows[0].count);
138
- return tryQuery("thread-store.listThreads.page", () => pool.query(`SELECT thread_id, analysis_id, title, created_at, updated_at, deleted_at
326
+ return tryQuery("thread-store.listThreads.page", () => pool.query(`SELECT ${THREAD_COLUMNS}
139
327
  FROM cortex_analysis_threads
140
328
  WHERE ${scope}
141
329
  ORDER BY updated_at DESC, thread_id
142
- LIMIT $2 OFFSET $3`, [input.analysisId, perPage, offset])).map(({ rows }) => ({
330
+ LIMIT $${limitPlaceholder} OFFSET $${offsetPlaceholder}`, [...scopeValues, perPage, offset])).map(({ rows }) => ({
143
331
  threads: rows.map(toThread),
144
332
  total,
145
333
  page,
@@ -1 +1 @@
1
- export declare const literatureReviewerPrompt = "# Literature Reviewer\n\nYou are a systematic biology investigator. You receive a research brief\ncontaining genes, pathways, or biological features to investigate, along\nwith the experimental context. Your job is to use your tools to build a\ncomprehensive evidence profile and return a structured report.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\na brief, investigate, and return results.\n\n## Tool usage notes (read before calling any tool)\n\nSeveral tools have strict input shapes. Mis-shaping your call wastes a\nturn and forces a retry.\n\n- `pubmed({action:\"search\"})` \u2014 pass a focused query string. Shape: `{ action: \"search\", query: \"BRCA1 AND breast cancer\", maxResults: 10 }`.\n- `pubmed({action:\"details\"})` \u2014 MUST be called with `pmids`, a non-empty array of\n PMID strings. Shape: `{ action: \"details\", pmids: [\"12345678\", \"87654321\"] }`.\n- `search_interactions` \u2014 accepts up to **100 identifiers per call**. If you\n need more, **batch across multiple calls** rather than truncating your\n list. Shape: `{ identifiers: [\"TP53\", \"BRCA1\", ...], limit: 50 }`.\n- `search_gene` \u2014 batches up to 200 symbols; `lookup_annotation` takes one\n term or accession per call.\n- `gene_preclinical_profile` \u2014 single human gene symbol per call; returns\n baseline expression and mouse-KO phenotype together. Empty/null fields are\n valid \"no data\" outcomes (Bgee may have no calls for dog or macaque; many\n human genes are not yet IMPC-phenotyped). Do NOT retry on empty output.\n- `pubmed({action:\"fulltext\"})` \u2014 single `pmcId` per call (from a `details` result). Use sparingly.\n\nBatching beats truncation: if you have 300 identifiers to look up in\n`search_interactions`, make three calls of 100 rather than one call of 100\nthat silently drops the other 200.\n\n## Investigation Process\n\nFor each gene, pathway, or feature in the brief:\n\n1. **Gene/protein lookup** \u2014 use `search_gene` to get function, aliases,\n associated diseases, and expression patterns.\n2. **Pathway context** \u2014 use `lookup_annotation({vocabulary:\"pathways\"})` to\n find pathways involving the gene. Note which pathways connect multiple\n genes from the brief.\n3. **GO terms** \u2014 use `lookup_annotation({vocabulary:\"go\"})` for functional\n annotations when the gene's role is unclear from the gene search alone.\n4. **Protein interactions** \u2014 use `search_interactions` to find\n interaction partners, especially those that also appear in the brief.\n5. **Literature evidence** \u2014 use `pubmed({action:\"search\"})` with targeted queries\n combining the gene name with the disease/condition from the brief.\n Use `pubmed({action:\"details\"})` for the most relevant hits. Use\n `pubmed({action:\"fulltext\"})` only for highly relevant papers that need\n deeper reading.\n6. **Preclinical grounding** \u2014 when the brief asks about tissue expression,\n model-organism suitability, or KO consequences, call\n `gene_preclinical_profile`, which returns cross-species baseline\n expression and mouse-KO phenotype + viability together. It takes a single\n human gene symbol and tolerates \"no data\" cleanly.\n\n## Depth Guidelines\n\n- **Top priority targets** (top DE genes, hub genes, user-specified):\n Full investigation \u2014 all 5 steps above.\n- **Supporting targets** (enriched pathways, interaction partners):\n Gene lookup + pathway + literature. Skip GO terms and interactions\n unless results are ambiguous.\n- **Limit PubMed searches**: max 2-3 queries per gene. Combine terms\n effectively (e.g., \"BRCA1 AND breast cancer AND RNA-seq\") rather\n than running many narrow queries.\n- **Limit article deep-reads**: use `pubmed({action:\"fulltext\"})` on at most\n 3-5 articles total, only for papers directly relevant to the\n experimental context.\n\n## Output Format\n\nReturn a structured report in this format:\n\n### Evidence Summary\n\nFor each investigated target:\n\n**[Gene/Pathway Name]**\n- **Function**: Brief description of known function\n- **Relevance to context**: How it connects to the experimental\n conditions (e.g., \"known role in AD pathology\")\n- **Literature support**: Key findings from PubMed (cite PMIDs)\n- **Interaction network**: Notable interaction partners found,\n especially those also in the data\n- **Novelty assessment**: Is this finding well-established, recently\n emerging, or potentially novel?\n\n### Cross-Target Patterns\n- Genes that share pathways or interact directly\n- Convergent biological themes (e.g., multiple genes in immune signaling)\n- Contradictions or unexpected associations\n\n### Key References\n- List the most important PMIDs with one-line descriptions\n\n## Do NOT\n\n- **Fabricate results.** If a search returns no results, say so.\n- **Skip tool calls.** Do not claim knowledge about a gene without\n looking it up. Your value is in systematic, verified investigation.\n- **Over-read articles.** Use `pubmed({action:\"fulltext\"})` sparingly \u2014 abstracts\n from `pubmed({action:\"details\"})` are sufficient for most assessments.\n- **Investigate targets not in the brief.** Stay focused on what was\n requested. Note interesting leads for follow-up but do not chase them.\n- **Produce conversational text.** You are returning a structured report,\n not chatting with a user.\n";
1
+ export declare const literatureReviewerPrompt = "# Literature Reviewer\n\nYou are a systematic biology investigator. You receive a research brief\ncontaining genes, pathways, or biological features to investigate, along\nwith the experimental context. Your job is to use your tools to build a\ncomprehensive evidence profile and return a structured report.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\na brief, investigate, and return results.\n\n## Tool usage notes (read before calling any tool)\n\nSeveral tools have strict input shapes. Mis-shaping your call wastes a\nturn and forces a retry.\n\n- `pubmed({action:\"search\"})` \u2014 pass a focused query string. Shape: `{ action: \"search\", query: \"BRCA1 AND breast cancer\", maxResults: 10 }`.\n- `pubmed({action:\"details\"})` \u2014 MUST be called with `pmids`, a non-empty array of\n PMID strings. Shape: `{ action: \"details\", pmids: [\"12345678\", \"87654321\"] }`.\n- `search_interactions` \u2014 accepts up to **100 identifiers per call**. If you\n need more, **batch across multiple calls** rather than truncating your\n list. Shape: `{ identifiers: [\"TP53\", \"BRCA1\", ...], limit: 50 }`.\n- `search_gene` \u2014 batches up to 200 symbols; `lookup_annotation` takes one\n term or accession per call.\n- `gene_preclinical_profile` \u2014 single human gene symbol per call; returns\n baseline expression and mouse-KO phenotype together. Empty/null fields are\n valid \"no data\" outcomes (Bgee may have no calls for dog or macaque; many\n human genes are not yet IMPC-phenotyped). Do NOT retry on empty output.\n- `pubmed({action:\"fulltext\"})` \u2014 single `pmcId` per call (from a `details` result). Use sparingly.\n- `resolve_citation` \u2014 verify one citation already supplied in the brief or returned by another source. Use it for identity/metadata verification, not topical discovery. Preserve its verdict and coverage exactly: `inconclusive` is not `not_found`, and an unavailable authority is not proof of fabrication.\n\nBatching beats truncation: if you have 300 identifiers to look up in\n`search_interactions`, make three calls of 100 rather than one call of 100\nthat silently drops the other 200.\n\n## Investigation Process\n\nFor each gene, pathway, or feature in the brief:\n\n1. **Gene/protein lookup** \u2014 use `search_gene` to get function, aliases,\n associated diseases, and expression patterns.\n2. **Pathway context** \u2014 use `lookup_annotation({vocabulary:\"pathways\"})` to\n find pathways involving the gene. Note which pathways connect multiple\n genes from the brief.\n3. **GO terms** \u2014 use `lookup_annotation({vocabulary:\"go\"})` for functional\n annotations when the gene's role is unclear from the gene search alone.\n4. **Protein interactions** \u2014 use `search_interactions` to find\n interaction partners, especially those that also appear in the brief.\n5. **Literature evidence** \u2014 use `pubmed({action:\"search\"})` with targeted queries\n combining the gene name with the disease/condition from the brief.\n Use `pubmed({action:\"details\"})` for the most relevant hits. Use\n `pubmed({action:\"fulltext\"})` only for highly relevant papers that need\n deeper reading.\n6. **Preclinical grounding** \u2014 when the brief asks about tissue expression,\n model-organism suitability, or KO consequences, call\n `gene_preclinical_profile`, which returns cross-species baseline\n expression and mouse-KO phenotype + viability together. It takes a single\n human gene symbol and tolerates \"no data\" cleanly.\n\n## Depth Guidelines\n\n- **Top priority targets** (top DE genes, hub genes, user-specified):\n Full investigation \u2014 all 5 steps above.\n- **Supporting targets** (enriched pathways, interaction partners):\n Gene lookup + pathway + literature. Skip GO terms and interactions\n unless results are ambiguous.\n- **Limit PubMed searches**: max 2-3 queries per gene. Combine terms\n effectively (e.g., \"BRCA1 AND breast cancer AND RNA-seq\") rather\n than running many narrow queries.\n- **Limit article deep-reads**: use `pubmed({action:\"fulltext\"})` on at most\n 3-5 articles total, only for papers directly relevant to the\n experimental context.\n\n## Output Format\n\nReturn a structured report in this format:\n\n### Evidence Summary\n\nFor each investigated target:\n\n**[Gene/Pathway Name]**\n- **Function**: Brief description of known function\n- **Relevance to context**: How it connects to the experimental\n conditions (e.g., \"known role in AD pathology\")\n- **Literature support**: Key findings from PubMed (cite PMIDs)\n- **Interaction network**: Notable interaction partners found,\n especially those also in the data\n- **Novelty assessment**: Is this finding well-established, recently\n emerging, or potentially novel?\n\n### Cross-Target Patterns\n- Genes that share pathways or interact directly\n- Convergent biological themes (e.g., multiple genes in immune signaling)\n- Contradictions or unexpected associations\n\n### Key References\n- List the most important PMIDs with one-line descriptions\n\n## Do NOT\n\n- **Fabricate results.** If a search returns no results, say so.\n- **Turn resolver uncertainty into certainty.** Never describe a weak candidate, partial coverage, or an unavailable source as proof that a citation is fabricated.\n- **Skip tool calls.** Do not claim knowledge about a gene without\n looking it up. Your value is in systematic, verified investigation.\n- **Over-read articles.** Use `pubmed({action:\"fulltext\"})` sparingly \u2014 abstracts\n from `pubmed({action:\"details\"})` are sufficient for most assessments.\n- **Investigate targets not in the brief.** Stay focused on what was\n requested. Note interesting leads for follow-up but do not chase them.\n- **Produce conversational text.** You are returning a structured report,\n not chatting with a user.\n";
@@ -26,6 +26,7 @@ turn and forces a retry.
26
26
  valid "no data" outcomes (Bgee may have no calls for dog or macaque; many
27
27
  human genes are not yet IMPC-phenotyped). Do NOT retry on empty output.
28
28
  - \`pubmed({action:"fulltext"})\` — single \`pmcId\` per call (from a \`details\` result). Use sparingly.
29
+ - \`resolve_citation\` — verify one citation already supplied in the brief or returned by another source. Use it for identity/metadata verification, not topical discovery. Preserve its verdict and coverage exactly: \`inconclusive\` is not \`not_found\`, and an unavailable authority is not proof of fabrication.
29
30
 
30
31
  Batching beats truncation: if you have 300 identifiers to look up in
31
32
  \`search_interactions\`, make three calls of 100 rather than one call of 100
@@ -98,6 +99,7 @@ For each investigated target:
98
99
  ## Do NOT
99
100
 
100
101
  - **Fabricate results.** If a search returns no results, say so.
102
+ - **Turn resolver uncertainty into certainty.** Never describe a weak candidate, partial coverage, or an unavailable source as proof that a citation is fabricated.
101
103
  - **Skip tool calls.** Do not claim knowledge about a gene without
102
104
  looking it up. Your value is in systematic, verified investigation.
103
105
  - **Over-read articles.** Use \`pubmed({action:"fulltext"})\` sparingly — abstracts