@unblocklabs/unblock-memory 0.3.22 → 0.3.24

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.
@@ -14,28 +14,33 @@ import { getContext } from "./tool-context.js";
14
14
  import { WhispererDiagnostics } from "./diagnostics.js";
15
15
  import { registerReviewTools } from "./review-tools.js";
16
16
  import { registerResponseAudit } from "./response-runtime.js";
17
+ import { resolveTimezone } from "./session-projector.js";
17
18
  const searchParameters = Type.Object({
18
19
  query: Type.String({ pattern: "\\S" }),
19
- corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), { minItems: 1 })),
20
+ corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), {
21
+ minItems: 1, description: 'Configured corpus names; default is all non-skill corpora. Use ["all"] alone for explicit all-corpora recall.',
22
+ })),
20
23
  sessionFilter: Type.Optional(Type.Object({
21
24
  startedFrom: Type.Optional(Type.String({
25
+ description: "Inclusive lower bound on session start time, not message or claim dates (ISO 8601).",
22
26
  pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
23
27
  })),
24
28
  startedTo: Type.Optional(Type.String({
29
+ description: "Inclusive upper bound on session start time, not message or claim dates (ISO 8601).",
25
30
  pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
26
31
  })),
27
32
  provider: Type.Optional(Type.String({ pattern: "\\S" })),
28
33
  chatType: Type.Optional(Type.Union([Type.Literal("channel"), Type.Literal("group"), Type.Literal("direct")])),
29
34
  accountId: Type.Optional(Type.String({ pattern: "\\S" })),
30
35
  conversationId: Type.Optional(Type.String({ pattern: "\\S" })),
31
- }, { additionalProperties: false })),
32
- maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
33
- minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
36
+ }, { additionalProperties: false, description: "Restricts session documents only; selected file corpora remain eligible. Not an audience access control." })),
37
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum hits; default 5." })),
38
+ minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum vector similarity; default 0.3. Not confidence in factual truth." })),
34
39
  }, { additionalProperties: false });
35
40
  const getParameters = Type.Object({
36
41
  path: Type.String({ pattern: "\\S" }),
37
- from: Type.Optional(Type.Integer({ minimum: 1 })),
38
- lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
42
+ from: Type.Optional(Type.Integer({ minimum: 1, description: "First source line, 1-based; default 1. Use nextFrom from a truncated read to continue." })),
43
+ lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000, description: "Requested lines; default 120, also bounded by 12,000 content characters." })),
39
44
  }, { additionalProperties: false });
40
45
  const syncSessionsParameters = Type.Object({
41
46
  force: Type.Optional(Type.Boolean()),
@@ -48,7 +53,7 @@ function createSearchTool(runtime, ctx) {
48
53
  return {
49
54
  name: "memory_search",
50
55
  label: "Memory Search",
51
- description: "Search configured memory corpora with semantic vector retrieval. The isolated skills corpus is never included.",
56
+ description: "Search this agent's configured memory corpora with local vector retrieval, not QMD's hybrid query. Skills are excluded. Session snippets are arrays of messages (type, name, timestamp, body; partial when incomplete); file snippets are strings. Results are evidence leads; inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
52
57
  parameters: searchParameters,
53
58
  async execute(_toolCallId, params, signal) {
54
59
  const { query: untrimmedQuery, corpora, sessionFilter, maxResults, minScore, } = Value.Parse(searchParameters, params);
@@ -64,18 +69,22 @@ function createSearchTool(runtime, ctx) {
64
69
  signal,
65
70
  requestContext: active.requestContext,
66
71
  });
67
- return jsonResult({
68
- results: results.map((result) => result.session
69
- ? {
70
- ...result,
71
- session: {
72
- ...result.session,
73
- startedAt: new Date(result.session.startedAt).toISOString(),
74
- },
75
- }
76
- : result),
77
- provider: "unblock-memory",
78
- });
72
+ // Compact only the public tool response; internal ranking and consumers keep
73
+ // full-precision scores and the host's source/citation compatibility fields.
74
+ const payload = {
75
+ results: results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
76
+ ...result,
77
+ snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
78
+ score: Number(result.score.toFixed(2)),
79
+ ...(result.vectorScore !== undefined ? { vectorScore: Number(result.vectorScore.toFixed(2)) } : {}),
80
+ ...(result.textScore !== undefined ? { textScore: Number(result.textScore.toFixed(2)) } : {}),
81
+ ...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
82
+ })),
83
+ };
84
+ return {
85
+ content: [{ type: "text", text: JSON.stringify(payload) }],
86
+ details: payload,
87
+ };
79
88
  },
80
89
  };
81
90
  }
@@ -86,7 +95,7 @@ function createGetTool(runtime, ctx) {
86
95
  return {
87
96
  name: "memory_get",
88
97
  label: "Memory Get",
89
- description: "Read an exact qmd:// path returned by memory_search.",
98
+ description: "Read an exact indexed qmd:// source path returned by memory tools. Defaults to 120 lines, bounded to 12,000 content characters. Check truncated/nextFrom and continue when present; not_found or unavailable is not a successful empty read.",
90
99
  parameters: getParameters,
91
100
  async execute(_toolCallId, params) {
92
101
  const { path: untrimmedPath, from, lines } = Value.Parse(getParameters, params);
@@ -383,25 +392,12 @@ function parseByteSize(value) {
383
392
  const bytes = Math.round(Number(match[1]) * 1024 ** powers[unit]);
384
393
  return Number.isSafeInteger(bytes) ? bytes : undefined;
385
394
  }
386
- function resolveTimezone(cfg) {
387
- const configured = cfg?.agents?.defaults?.userTimezone?.trim();
388
- if (configured) {
389
- try {
390
- new Intl.DateTimeFormat("en-US", { timeZone: configured }).format();
391
- return configured;
392
- }
393
- catch {
394
- // Host validation normally prevents this; fall through defensively.
395
- }
396
- }
397
- return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
398
- }
399
395
  export function resolveFlushPlan(params = {}) {
400
396
  const configured = params.cfg?.agents?.defaults?.compaction?.memoryFlush;
401
397
  if (configured?.enabled === false)
402
398
  return null;
403
399
  const nowMs = params.nowMs ?? Date.now();
404
- const date = formatDateInTimezone(nowMs, resolveTimezone(params.cfg));
400
+ const date = formatDateInTimezone(nowMs, resolveTimezone(params.cfg?.agents?.defaults?.userTimezone?.trim()));
405
401
  const target = `memory/${date}.md`;
406
402
  return {
407
403
  softThresholdTokens: nonNegativeInteger(configured?.softThresholdTokens, 4000),
@@ -427,7 +423,7 @@ export function registerUnblockMemory(api) {
427
423
  supportsPrivateTranscriptRecall: false,
428
424
  promptBuilder: ({ availableTools }) => availableTools.has("memory_search")
429
425
  ? [
430
- "Use memory_search for relevant past facts, then memory_get when more surrounding context is needed.",
426
+ "Use memory_search for relevant past facts, then memory_get to verify source context, attribution and dates. Follow read continuation when present. Empty search is not proof of absence; historical memory is not current authorization.",
431
427
  ]
432
428
  : [],
433
429
  flushPlanResolver: resolveFlushPlan,
@@ -0,0 +1,39 @@
1
+ type RetrievalOperation = "vector" | "lexical" | "memoryWhisperer";
2
+ type RetrievalOutcome = "ok" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
3
+ declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "candidates", "eligible", "results", "contextChars"];
4
+ type Field = typeof fields[number];
5
+ export type RetrievalObservation = {
6
+ outcome: RetrievalOutcome;
7
+ elapsedMs: number;
8
+ } & Partial<Record<Field, number>>;
9
+ /** Only fixed operation/outcome names and nonnegative numbers enter this store. */
10
+ export declare class RetrievalTelemetry {
11
+ #private;
12
+ record(operation: RetrievalOperation, observation: RetrievalObservation): void;
13
+ snapshot(): {
14
+ scope: string;
15
+ operations: {
16
+ [k: string]: {
17
+ calls: number;
18
+ outcomes: {
19
+ ok?: number | undefined;
20
+ skipped?: number | undefined;
21
+ failed?: number | undefined;
22
+ empty?: number | undefined;
23
+ cancelled?: number | undefined;
24
+ timed_out?: number | undefined;
25
+ };
26
+ measurements: {
27
+ [k: string]: {
28
+ total: number;
29
+ samples: number;
30
+ recentSamples: number;
31
+ p50: number | null;
32
+ p95: number | null;
33
+ };
34
+ };
35
+ };
36
+ };
37
+ };
38
+ }
39
+ export {};
@@ -0,0 +1,40 @@
1
+ const fields = ["elapsedMs", "retrievalMs", "judgeMs", "candidates", "eligible", "results", "contextChars"];
2
+ const recentLimit = 256;
3
+ const boundedAdd = (left, right) => Math.min(Number.MAX_SAFE_INTEGER, left + right);
4
+ /** Only fixed operation/outcome names and nonnegative numbers enter this store. */
5
+ export class RetrievalTelemetry {
6
+ #entries = new Map();
7
+ record(operation, observation) {
8
+ const entry = this.#entries.get(operation) ?? { calls: 0, outcomes: {}, totals: {}, recent: [] };
9
+ this.#entries.set(operation, entry);
10
+ entry.calls = boundedAdd(entry.calls, 1);
11
+ entry.outcomes[observation.outcome] = boundedAdd(entry.outcomes[observation.outcome] ?? 0, 1);
12
+ const sample = {};
13
+ for (const field of fields) {
14
+ const value = observation[field];
15
+ if (value === undefined || !Number.isFinite(value) || value < 0)
16
+ continue;
17
+ sample[field] = value;
18
+ const total = entry.totals[field] ?? { sum: 0, samples: 0 };
19
+ entry.totals[field] = { sum: boundedAdd(total.sum, value), samples: boundedAdd(total.samples, 1) };
20
+ }
21
+ entry.recent.push(sample);
22
+ if (entry.recent.length > recentLimit)
23
+ entry.recent.shift();
24
+ }
25
+ snapshot() {
26
+ return {
27
+ scope: "Lifetime counters; percentiles cover at most the last 256 calls per operation, including failures. No content or hashes; resets on recreation.",
28
+ operations: Object.fromEntries([...this.#entries].map(([operation, entry]) => [operation, {
29
+ calls: entry.calls,
30
+ outcomes: { ...entry.outcomes },
31
+ measurements: Object.fromEntries(fields.map(field => {
32
+ const values = entry.recent.flatMap(sample => sample[field] === undefined ? [] : [sample[field]]).sort((a, b) => a - b);
33
+ const percentile = (fraction) => values.length ? values[Math.ceil(values.length * fraction) - 1] : null;
34
+ return [field, { total: entry.totals[field]?.sum ?? 0, samples: entry.totals[field]?.samples ?? 0,
35
+ recentSamples: values.length, p50: percentile(0.5), p95: percentile(0.95) }];
36
+ })),
37
+ }])),
38
+ };
39
+ }
40
+ }
@@ -22,10 +22,27 @@ export type SessionProjectionInput = SessionMetadata & {
22
22
  attachmentBudgetSkipped: number;
23
23
  };
24
24
  };
25
+ export type SessionSnippetMessage = {
26
+ type?: "user" | "assistant";
27
+ name?: string;
28
+ timestamp?: string;
29
+ body: string;
30
+ partial?: true;
31
+ };
32
+ /** Character offsets in the exact indexed projection; end excludes message separators. */
33
+ export type SessionMessageSpan = {
34
+ type: "user" | "assistant";
35
+ name: string;
36
+ timestamp: string;
37
+ start: number;
38
+ bodyStart: number;
39
+ end: number;
40
+ };
25
41
  export type SessionContextSpans = {
26
42
  message: {
27
43
  start: number;
28
44
  end: number;
45
+ timestamp: string;
29
46
  };
30
47
  turn: {
31
48
  start: number;
@@ -33,6 +50,20 @@ export type SessionContextSpans = {
33
50
  };
34
51
  };
35
52
  export declare function projectSession(input: SessionProjectionInput): string | undefined;
36
- export declare function sessionContextSpans(content: string, position: number): SessionContextSpans | undefined;
53
+ export declare function projectSessionDocument(input: SessionProjectionInput): {
54
+ content: string;
55
+ messages: SessionMessageSpan[];
56
+ } | undefined;
57
+ /** Legacy fallback only. New projections retain exact boundaries before rendering Markdown. */
58
+ export declare function parseSessionMessageSpans(content: string): SessionMessageSpan[];
59
+ export declare function sessionContextSpans(content: string, position: number, markers?: SessionMessageSpan[]): SessionContextSpans | undefined;
60
+ export declare function sessionSnippetMessages(content: string, selected: {
61
+ text: string;
62
+ position: number;
63
+ sourceText?: string;
64
+ }, spans: readonly SessionMessageSpan[], identity?: {
65
+ agentId: string;
66
+ agentName: string;
67
+ }): SessionSnippetMessage[];
37
68
  export declare function sessionDocumentPath(metadata: SessionMetadata): string;
38
69
  export declare function resolveTimezone(configured?: string): string;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { projectLoggieMessage } from "./loggie-projection.js";
3
3
  import { applyProposal, parseAttachments, parseInternalMessage } from "./session-noise.js";
4
- const MESSAGE_HEADING = /^## (User|Assistant) — .* — \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \S.*$/gmu;
4
+ const MESSAGE_HEADING = /^## (User|Assistant) — (.+) — (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \S.*)$/u;
5
5
  function record(value) {
6
6
  return value !== null && typeof value === "object" && !Array.isArray(value)
7
7
  ? value
@@ -126,6 +126,9 @@ function formatTimestamp(value, timezone) {
126
126
  `${part("hour")}:${part("minute")}:${part("second")} ${part("timeZoneName")}`.trim();
127
127
  }
128
128
  export function projectSession(input) {
129
+ return projectSessionDocument(input)?.content;
130
+ }
131
+ export function projectSessionDocument(input) {
129
132
  const messages = input.events.flatMap((event) => {
130
133
  const projected = projectMessage(event, input);
131
134
  return projected ? [projected] : [];
@@ -164,28 +167,64 @@ export function projectSession(input) {
164
167
  else if (!previous || (!previous.meeting?.complete && meeting.complete))
165
168
  latest.set(meeting.key, message);
166
169
  }
167
- const transcript = messages.filter(message => !hidden.has(message)).map((message) => `## ${message.role === "user" ? "User" : "Assistant"} — ${message.speaker} — ` +
168
- `${formatTimestamp(message.timestamp, input.timezone)}\n\n${message.text}`);
169
- return `# Transcript\n\n${transcript.join("\n\n")}\n`;
170
+ let content = "# Transcript\n\n";
171
+ const spans = [];
172
+ for (const message of messages.filter(message => !hidden.has(message))) {
173
+ if (spans.length)
174
+ content += "\n\n";
175
+ const start = content.length;
176
+ const timestamp = formatTimestamp(message.timestamp, input.timezone);
177
+ content += `## ${message.role === "user" ? "User" : "Assistant"} — ${message.speaker} — ${timestamp}\n\n`;
178
+ const bodyStart = content.length;
179
+ content += message.text;
180
+ spans.push({ type: message.role, name: message.speaker, timestamp, start, bodyStart, end: content.length });
181
+ }
182
+ return { content: `${content}\n`, messages: spans };
170
183
  }
171
- export function sessionContextSpans(content, position) {
172
- const markers = [...content.matchAll(MESSAGE_HEADING)].map((match) => ({
173
- start: match.index,
174
- role: match[1] === "User" ? "user" : "assistant",
175
- }));
184
+ /** Legacy fallback only. New projections retain exact boundaries before rendering Markdown. */
185
+ export function parseSessionMessageSpans(content) {
186
+ const messages = [];
187
+ let fence;
188
+ for (const line of content.matchAll(/[^\n]*(?:\n|$)/gu)) {
189
+ const text = line[0].replace(/\n$/u, "");
190
+ const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(text);
191
+ if (fence) {
192
+ if (delimiter?.[1]?.[0] === fence.char && delimiter[1].length >= fence.length && !delimiter[2]?.trim())
193
+ fence = undefined;
194
+ continue;
195
+ }
196
+ if (delimiter) {
197
+ fence = { char: delimiter[1][0], length: delimiter[1].length };
198
+ continue;
199
+ }
200
+ const match = MESSAGE_HEADING.exec(text);
201
+ // Only the projector's complete heading + blank-line form is recognized.
202
+ if (!match || !content.startsWith("\n\n", line.index + text.length))
203
+ continue;
204
+ const previous = messages.at(-1);
205
+ if (previous)
206
+ previous.end = content.startsWith("\n\n", line.index - 2) ? line.index - 2 : line.index;
207
+ messages.push({ type: match[1] === "User" ? "user" : "assistant", name: match[2], timestamp: match[3],
208
+ start: line.index, bodyStart: line.index + text.length + 2,
209
+ end: content.endsWith("\n") ? content.length - 1 : content.length });
210
+ }
211
+ return messages;
212
+ }
213
+ export function sessionContextSpans(content, position, markers = parseSessionMessageSpans(content)) {
176
214
  const containing = markers.findLastIndex((marker) => marker.start <= position);
177
215
  if (containing < 0)
178
216
  return undefined;
179
217
  const message = {
180
218
  start: markers[containing].start,
181
219
  end: markers[containing + 1]?.start ?? content.length,
220
+ timestamp: markers[containing].timestamp,
182
221
  };
183
222
  let turnStart = containing;
184
- while (turnStart > 0 && markers[turnStart].role !== "user")
223
+ while (turnStart > 0 && markers[turnStart].type !== "user")
185
224
  turnStart -= 1;
186
- if (markers[turnStart].role !== "user")
225
+ if (markers[turnStart].type !== "user")
187
226
  turnStart = containing;
188
- const nextUser = markers.findIndex((marker, index) => index > turnStart && marker.role === "user");
227
+ const nextUser = markers.findIndex((marker, index) => index > turnStart && marker.type === "user");
189
228
  return {
190
229
  message,
191
230
  turn: {
@@ -194,6 +233,39 @@ export function sessionContextSpans(content, position) {
194
233
  },
195
234
  };
196
235
  }
236
+ export function sessionSnippetMessages(content, selected, spans, identity) {
237
+ const sourceText = selected.sourceText ?? selected.text;
238
+ const end = selected.position + sourceText.length;
239
+ // Added meeting speaker/revision context is evidence too; retain it in the first body.
240
+ const prefix = selected.text.endsWith(sourceText) ? selected.text.slice(0, selected.text.length - sourceText.length) : "";
241
+ const messages = [];
242
+ let cursor = selected.position;
243
+ const keepUnattributed = (from, to) => {
244
+ const body = content.slice(from, to);
245
+ if (body.trim() && !(from === 0 && body === "# Transcript\n\n"))
246
+ messages.push({ body, partial: true });
247
+ };
248
+ for (const span of spans) {
249
+ if (span.start >= end || span.end <= selected.position)
250
+ continue;
251
+ if (span.start > cursor)
252
+ keepUnattributed(cursor, span.start);
253
+ const from = Math.max(span.bodyStart, selected.position);
254
+ const to = Math.min(span.end, end);
255
+ messages.push({ type: span.type,
256
+ name: span.type === "assistant" && span.name === identity?.agentId ? identity.agentName : span.name,
257
+ timestamp: span.timestamp, body: content.slice(from, Math.max(from, to)),
258
+ ...(from > span.bodyStart || to < span.end ? { partial: true } : {}),
259
+ });
260
+ cursor = Math.min(span.end, end);
261
+ }
262
+ if (cursor < end)
263
+ keepUnattributed(cursor, end);
264
+ if (!messages.length)
265
+ return [{ body: selected.text, partial: true }];
266
+ messages[0].body = prefix + messages[0].body;
267
+ return messages;
268
+ }
197
269
  function hash(value) {
198
270
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
199
271
  }
@@ -1,6 +1,6 @@
1
1
  import type { ChatType } from "./config.js";
2
- import { type SessionMetadata, type SessionProjectionInput } from "./session-projector.js";
3
- export declare const PROJECTOR_VERSION = 6;
2
+ import { type SessionMetadata, type SessionProjectionInput, type SessionMessageSpan } from "./session-projector.js";
3
+ export declare const PROJECTOR_VERSION = 7;
4
4
  type IndexedSession = SessionMetadata & {
5
5
  sourceGeneration: string;
6
6
  maxSeq: number;
@@ -10,6 +10,7 @@ type IndexedSession = SessionMetadata & {
10
10
  documentPath: string;
11
11
  projectorVersion: number;
12
12
  sourceFingerprint?: string;
13
+ messages?: SessionMessageSpan[];
13
14
  };
14
15
  export type SessionManifest = {
15
16
  version: number;
@@ -3,9 +3,9 @@ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rename, unlink, utimes, writeFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import { projectSession, sessionDocumentPath, } from "./session-projector.js";
6
+ import { projectSessionDocument, sessionDocumentPath, } from "./session-projector.js";
7
7
  const MANIFEST_VERSION = 1;
8
- export const PROJECTOR_VERSION = 6;
8
+ export const PROJECTOR_VERSION = 7;
9
9
  const SUPPORTED_SCHEMA_VERSIONS = new Set([17, 18, 19]);
10
10
  // Source lives in src/, published code in dist/src/. Read our own pinned dependency
11
11
  // metadata, not QMD internals (which may also be substituted by runtime inspectors).
@@ -290,7 +290,7 @@ export async function syncSessionProjections(params) {
290
290
  sessions[window.sessionId] = previous;
291
291
  continue;
292
292
  }
293
- let content;
293
+ let projection;
294
294
  try {
295
295
  const input = {
296
296
  ...metadata,
@@ -300,7 +300,7 @@ export async function syncSessionProjections(params) {
300
300
  events,
301
301
  diagnostics,
302
302
  };
303
- content = projectSession(input);
303
+ projection = projectSessionDocument(input);
304
304
  }
305
305
  catch {
306
306
  counts.failed += 1;
@@ -308,7 +308,7 @@ export async function syncSessionProjections(params) {
308
308
  sessions[window.sessionId] = previous;
309
309
  continue;
310
310
  }
311
- if (!content) {
311
+ if (!projection) {
312
312
  ignoredSessions[window.sessionId] = JSON.stringify(window);
313
313
  counts.skipped += 1;
314
314
  if (previous) {
@@ -317,6 +317,7 @@ export async function syncSessionProjections(params) {
317
317
  }
318
318
  continue;
319
319
  }
320
+ const { content, messages } = projection;
320
321
  const target = projectionPath(params.outputDir, documentPath);
321
322
  const hash = projectionHash(content);
322
323
  const contentChanged = params.force === true || previous?.projectorVersion !== PROJECTOR_VERSION ||
@@ -338,6 +339,7 @@ export async function syncSessionProjections(params) {
338
339
  documentPath,
339
340
  projectorVersion: PROJECTOR_VERSION,
340
341
  sourceFingerprint: JSON.stringify(window),
342
+ messages,
341
343
  };
342
344
  if (contentChanged)
343
345
  counts.updated += 1;
@@ -95,7 +95,8 @@ export async function syncSlackDirectory(params) {
95
95
  }
96
96
  try {
97
97
  const existing = params.store.findIdentity("slack", params.accountId, externalId);
98
- if (existing && params.store.getPerson(existing.personId)?.status !== "active") {
98
+ const existingPerson = existing ? params.store.getPerson(existing.personId) : undefined;
99
+ if (existing && existingPerson?.status !== "active") {
99
100
  counts.skipped += 1;
100
101
  continue;
101
102
  }
@@ -118,7 +119,7 @@ export async function syncSlackDirectory(params) {
118
119
  });
119
120
  if (result.created)
120
121
  counts.created += 1;
121
- else if (changed)
122
+ else if (changed || result.person.displayName !== existingPerson?.displayName)
122
123
  counts.updated += 1;
123
124
  else
124
125
  counts.unchanged += 1;
@@ -6,7 +6,7 @@ type RequestOptions = {
6
6
  type Json = string | number | boolean | null | Json[] | {
7
7
  [key: string]: Json;
8
8
  };
9
- export declare const TYPESAFE_REVIEW_MODEL = "jev-1.13.0";
9
+ export { TYPESAFE_MODEL as TYPESAFE_REVIEW_MODEL } from "./typesafe-transport.js";
10
10
  export declare function askTypeSafeReview(params: RequestOptions, state: Json, questions: Json): Promise<unknown>;
11
11
  /** The source is an indexed snapshot, not proof of current truth or permission to write. */
12
12
  export declare function reviewTypeSafeClaim(params: RequestOptions & {
@@ -50,4 +50,3 @@ export declare function reviewClusterDefects(params: RequestOptions & {
50
50
  defect: "encoding" | "wrapper" | "boilerplate" | "none_or_uncertain";
51
51
  confidence: number;
52
52
  }[]>;
53
- export {};
@@ -1,21 +1,13 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { backgroundWordCount, PEOPLE_BACKGROUND_MAX_WORDS } from "./people-background.js";
4
- export const TYPESAFE_REVIEW_MODEL = "jev-1.13.0";
4
+ import { postTypeSafe } from "./typesafe-transport.js";
5
+ export { TYPESAFE_MODEL as TYPESAFE_REVIEW_MODEL } from "./typesafe-transport.js";
5
6
  export async function askTypeSafeReview(params, state, questions) {
6
7
  const signal = AbortSignal.any([params.signal, AbortSignal.timeout(params.timeoutMs)]);
7
8
  try {
8
9
  signal.throwIfAborted();
9
- const response = await fetch("https://api.typesafe.ai/v1/systemone", {
10
- method: "POST", redirect: "error", signal,
11
- headers: { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json" },
12
- body: JSON.stringify({ model: TYPESAFE_REVIEW_MODEL, state, questions }),
13
- });
14
- if (!response.ok) {
15
- await response.body?.cancel();
16
- throw new Error("HTTP failure");
17
- }
18
- return await response.json();
10
+ return await postTypeSafe({ apiKey: params.apiKey, signal }, state, questions);
19
11
  }
20
12
  catch {
21
13
  throw new Error(signal.aborted ? "TypeSafe review aborted" : "TypeSafe review unavailable");
@@ -0,0 +1,10 @@
1
+ export declare const TYPESAFE_MODEL = "jev-1.13.0";
2
+ export declare class TypeSafeHttpError extends Error {
3
+ readonly status: number;
4
+ constructor(status: number);
5
+ }
6
+ /** Shared wire protocol; callers own deadlines, judgments and public errors. */
7
+ export declare function postTypeSafe(params: {
8
+ apiKey: string;
9
+ signal: AbortSignal;
10
+ }, state: unknown, questions: unknown): Promise<unknown>;
@@ -0,0 +1,26 @@
1
+ export const TYPESAFE_MODEL = "jev-1.13.0";
2
+ export class TypeSafeHttpError extends Error {
3
+ status;
4
+ constructor(status) {
5
+ super(`TypeSafe HTTP ${status}`);
6
+ this.status = status;
7
+ }
8
+ }
9
+ /** Shared wire protocol; callers own deadlines, judgments and public errors. */
10
+ export async function postTypeSafe(params, state, questions) {
11
+ const response = await fetch("https://api.typesafe.ai/v1/systemone", {
12
+ method: "POST", redirect: "error", signal: params.signal,
13
+ headers: { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json" },
14
+ body: JSON.stringify({ model: TYPESAFE_MODEL, state, questions }),
15
+ });
16
+ if (!response.ok) {
17
+ try {
18
+ await response.body?.cancel();
19
+ }
20
+ finally {
21
+ // Preserve the status even if cancellation fails; never include provider content.
22
+ throw new TypeSafeHttpError(response.status);
23
+ }
24
+ }
25
+ return response.json();
26
+ }
@@ -41,7 +41,7 @@ export declare function judgeTypeSafeMemories(params: {
41
41
  candidates: readonly {
42
42
  excerpt: string;
43
43
  corpus: string;
44
- startedAt?: number;
44
+ messageTimestamp?: string;
45
45
  }[];
46
46
  }): Promise<number[]>;
47
47
  export {};