@inflexa-ai/harness 0.36.4 → 0.37.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.
@@ -74,4 +74,19 @@ export interface CortexMessage {
74
74
  * zero is a figure, thus it stays a value and it never reads as an absence.
75
75
  */
76
76
  durationMs?: number;
77
+ /**
78
+ * Who sent this message — the identity the host named at the append site.
79
+ * Carried on a `user` message only: `role` already names the sender of a
80
+ * reply, and the harness holds no per-turn model name at the store boundary.
81
+ * Absent means that nothing recorded a sender.
82
+ */
83
+ author?: string;
84
+ /**
85
+ * When the store wrote this message: the START time of the append
86
+ * transaction, thus every message of one append carries the same value and
87
+ * no order holds between two appends. An ISO 8601 string rather than a
88
+ * `Date`, because the wire shape is JSON. Absent means that no stored row
89
+ * backs the message — a live surface builds one before anything is written.
90
+ */
91
+ createdAt?: string;
77
92
  }
@@ -23,7 +23,20 @@ export function storedMessagesToCortex(messages) {
23
23
  let append = null;
24
24
  for (const row of messages) {
25
25
  if (row.displayEnvelope) {
26
- append = conversationUIToCortexMessages(row.displayEnvelope.messages);
26
+ // The author and the creation time are facts about the ROW, the same as
27
+ // the rollup below, and the projection holds neither. Both ride the row
28
+ // that opens the append, and the author reaches the `user` messages
29
+ // alone, because `role` already names the sender of a reply. Spread
30
+ // conditionally: an absent value must leave no key, or a consumer that
31
+ // spreads the message acquires one that overwrites a real value. `Date`
32
+ // does not survive the JSON crossing typed, hence the ISO string here.
33
+ const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
34
+ const author = row.author === undefined ? {} : { author: row.author };
35
+ append = conversationUIToCortexMessages(row.displayEnvelope.messages).map((message) => ({
36
+ ...message,
37
+ ...createdAt,
38
+ ...(message.role === "user" ? author : {}),
39
+ }));
27
40
  out.push(...append);
28
41
  }
29
42
  // The reported rollup of the turn and its duration both ride the model row that
@@ -55,6 +55,20 @@ export interface StoredMessage {
55
55
  * reported no quantity still reads back with the duration that it took.
56
56
  */
57
57
  readonly durationMs?: number;
58
+ /**
59
+ * Who sent the user message of the append this row OPENED — present only on a
60
+ * genuine-user-start row of a turn appended with one. A row written before the
61
+ * column existed carries none, because no sender was ever recorded for it.
62
+ */
63
+ readonly author?: string;
64
+ /**
65
+ * When the store wrote this row: the START time of the append transaction,
66
+ * thus every row of one append holds the same value, and `seq` alone orders
67
+ * rows. The read sets it on every row, because the column is `NOT NULL`. It is
68
+ * optional because a fake or a fixture builds a `StoredMessage` with no row
69
+ * behind it.
70
+ */
71
+ readonly createdAt?: Date;
58
72
  }
59
73
  /**
60
74
  * One atomic append: the exact provider-facing history, the display projection of
@@ -86,6 +100,16 @@ export interface ConversationTurn {
86
100
  * reported no quantity still keeps the duration that it took.
87
101
  */
88
102
  readonly turnDurationMs?: number;
103
+ /**
104
+ * Who sent the user message of this turn — the identity the host holds at the
105
+ * append site. Stored on the FIRST row of the append, and only when that row
106
+ * is a genuine user start: that row also carries the display projection, thus
107
+ * the write and the transcript replay read one row under one rule. A record
108
+ * append opens on a synthetic row, so it stores no author however a caller
109
+ * spreads one in. The value is NUL-stripped on the way in, the same as the
110
+ * envelope, because a `text` parameter carrying 0x00 fails the statement.
111
+ */
112
+ readonly author?: string;
89
113
  }
90
114
  /**
91
115
  * The append a host uses to record out-of-band work in a thread — an analysis
@@ -181,7 +181,7 @@ export const EVICTION_BLOCK_TURNS = 4;
181
181
  */
182
182
  export function createThreadHistory(pool, logger) {
183
183
  function appendTurn(threadId, turn) {
184
- const { modelMessages: messages, displayMessages, turnUsage, turnDurationMs } = turn;
184
+ const { modelMessages: messages, displayMessages, turnUsage, turnDurationMs, author } = turn;
185
185
  if (messages.length === 0)
186
186
  return okVoid();
187
187
  // The display projection rides the append's FIRST row, so one SELECT of a
@@ -206,6 +206,13 @@ export function createThreadHistory(pool, logger) {
206
206
  // the turn. -1 (a turn that persisted no reply — an abort before any output)
207
207
  // matches no index below, so nothing is written and the append still succeeds.
208
208
  const lastAssistantRow = messages.reduce((last, m, i) => (m.role === "assistant" ? i : last), -1);
209
+ // The author rides the row the display projection rides, and only when that
210
+ // row is a message a person actually sent — an assistant row, a tool row,
211
+ // and the synthetic opening row of a record append are not. NUL is dropped
212
+ // rather than refused: a 0x00 byte in a `text` parameter fails the
213
+ // statement, and one byte in a label would roll the whole turn back.
214
+ const turnAuthor = author === undefined ? null : stripNulCharacters(author);
215
+ const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
209
216
  return withTransaction(pool, "thread-history.appendTurn", (client) =>
210
217
  // Serialize concurrent appends on this thread — without the lock, two
211
218
  // transactions can both read the same MAX(seq) and collide on the
@@ -224,14 +231,15 @@ export function createThreadHistory(pool, logger) {
224
231
  // `message_envelope::json`, never `::jsonb`; `display_envelope` and
225
232
  // `reported_usage::jsonb`, never `::json` — see the column comments
226
233
  // in the state-init DDL.
227
- `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms)
228
- VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7)
234
+ `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms, author)
235
+ VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7, $8)
229
236
  ON CONFLICT (thread_id, seq) DO UPDATE
230
237
  SET message_envelope = EXCLUDED.message_envelope,
231
238
  display_envelope = EXCLUDED.display_envelope,
232
239
  tokens = EXCLUDED.tokens,
233
240
  reported_usage = EXCLUDED.reported_usage,
234
- turn_duration_ms = EXCLUDED.turn_duration_ms`, [
241
+ turn_duration_ms = EXCLUDED.turn_duration_ms,
242
+ author = EXCLUDED.author`, [
235
243
  threadId,
236
244
  startSeq + i,
237
245
  serializeEnvelope(message),
@@ -239,6 +247,7 @@ export function createThreadHistory(pool, logger) {
239
247
  countTokens(message.content),
240
248
  i === lastAssistantRow ? rollup : null,
241
249
  i === lastAssistantRow ? duration : null,
250
+ i === authorRow ? turnAuthor : null,
242
251
  ])).map(() => undefined)), okVoid()))
243
252
  .andThen(() =>
244
253
  // Thread listings sort on `cortex_analysis_threads.updated_at`, and
@@ -368,12 +377,14 @@ export function createThreadHistory(pool, logger) {
368
377
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
369
378
  // ORDER BY name to the output column), sorting the bigint as text:
370
379
  // "10" before "2". The qualified name forces the bigint column.
371
- `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage, turn_duration_ms::text AS turn_duration_ms
380
+ `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage, turn_duration_ms::text AS turn_duration_ms, author, created_at
372
381
  FROM messages WHERE thread_id = $1
373
382
  ORDER BY messages.seq ASC`, [threadId]);
374
383
  // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
375
384
  // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
376
- // The duration obeys the same rule, and it reads from its own column.
385
+ // The duration and the author obey the same rule, each from its own column.
386
+ // The creation time is unconditional: its column is NOT NULL, thus it has no
387
+ // absence to represent.
377
388
  const stored = await Promise.all(rows.map(async (r) => {
378
389
  const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
379
390
  const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
@@ -384,6 +395,8 @@ export function createThreadHistory(pool, logger) {
384
395
  ...(displayEnvelope ? { displayEnvelope } : {}),
385
396
  ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
386
397
  ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
398
+ ...(r.author === null ? {} : { author: r.author }),
399
+ createdAt: r.created_at,
387
400
  };
388
401
  }));
389
402
  return groupTurns(stored, (row) => isGenuineUserStart(row.message));
@@ -270,6 +270,13 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
270
270
  -- Nullable with no default: absent means that nobody measured the turn, and a
271
271
  -- zero would claim that a turn took no time. A row written before this column
272
272
  -- existed is absent for the same reason, thus the migration needs no backfill.
273
+ --
274
+ -- author names the identity that sent the user message of a turn, as the host
275
+ -- knew it at the append site. It is written on the genuine-user-start row of an
276
+ -- append only -- the row that carries display_envelope -- and never on an
277
+ -- assistant, tool, or synthetic row. Nullable with no default and no backfill:
278
+ -- absent means that no sender was recorded, which is the honest value both for a
279
+ -- row that predates the column and for a turn appended without one.
273
280
  CREATE TABLE IF NOT EXISTS messages (
274
281
  thread_id TEXT NOT NULL,
275
282
  seq BIGINT NOT NULL,
@@ -282,6 +289,7 @@ CREATE TABLE IF NOT EXISTS messages (
282
289
  tokens INTEGER NOT NULL,
283
290
  reported_usage JSONB,
284
291
  turn_duration_ms BIGINT,
292
+ author TEXT,
285
293
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
286
294
  PRIMARY KEY (thread_id, seq)
287
295
  );
@@ -547,6 +555,9 @@ export async function initCortexState(pool, injected) {
547
555
  // and every earlier message reads back with no duration — which is the
548
556
  // honest value, because nobody measured those turns.
549
557
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS turn_duration_ms BIGINT",
558
+ // Who sent the user message of the turn (see the CREATE TABLE comment).
559
+ // Purely additive: nullable with no default, thus the column rewrites no row.
560
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS author TEXT",
550
561
  // Demote an existing JSONB envelope column to JSON (see the CREATE
551
562
  // TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
552
563
  // whole table and this runs at every boot; scoped to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.36.4",
3
+ "version": "0.37.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",