@unblocklabs/unblock-memory 0.3.13 → 0.3.15

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 (43) hide show
  1. package/README.md +93 -0
  2. package/dist/src/abortable.d.ts +2 -0
  3. package/dist/src/abortable.js +21 -0
  4. package/dist/src/cluster-review.d.ts +47 -0
  5. package/dist/src/cluster-review.js +64 -0
  6. package/dist/src/config.d.ts +5 -0
  7. package/dist/src/config.js +22 -3
  8. package/dist/src/curation.js +4 -1
  9. package/dist/src/diagnostics.d.ts +39 -0
  10. package/dist/src/diagnostics.js +18 -0
  11. package/dist/src/evidence-review.d.ts +41 -0
  12. package/dist/src/evidence-review.js +50 -0
  13. package/dist/src/loggie-projection.d.ts +24 -0
  14. package/dist/src/loggie-projection.js +126 -0
  15. package/dist/src/manager.d.ts +85 -4
  16. package/dist/src/manager.js +93 -9
  17. package/dist/src/memory-whisperer.d.ts +2 -1
  18. package/dist/src/memory-whisperer.js +45 -9
  19. package/dist/src/plugin.js +8 -20
  20. package/dist/src/quality-audit.d.ts +3 -0
  21. package/dist/src/quality-audit.js +6 -3
  22. package/dist/src/quality-triage.d.ts +9 -0
  23. package/dist/src/quality-triage.js +38 -0
  24. package/dist/src/review-tools.d.ts +5 -0
  25. package/dist/src/review-tools.js +116 -0
  26. package/dist/src/session-noise.d.ts +20 -0
  27. package/dist/src/session-noise.js +142 -0
  28. package/dist/src/session-projector.d.ts +6 -0
  29. package/dist/src/session-projector.js +57 -2
  30. package/dist/src/session-sync.d.ts +3 -1
  31. package/dist/src/session-sync.js +4 -1
  32. package/dist/src/skill-whisperer.d.ts +2 -1
  33. package/dist/src/skill-whisperer.js +24 -8
  34. package/dist/src/tool-context.d.ts +7 -0
  35. package/dist/src/tool-context.js +17 -0
  36. package/dist/src/typesafe-review.d.ts +40 -0
  37. package/dist/src/typesafe-review.js +133 -0
  38. package/dist/src/typesafe.d.ts +1 -1
  39. package/dist/src/typesafe.js +67 -35
  40. package/openclaw.plugin.json +17 -1
  41. package/package.json +2 -2
  42. package/skills/memory-curator/SKILL.md +11 -0
  43. package/skills/people-whisperer/SKILL.md +7 -0
package/README.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Unblock Memory
2
2
 
3
+ ## Review and diagnostics
4
+
5
+ - `memory_diagnostics` reports credential **availability only**, per-agent process-local
6
+ whisperer counters, projection version, old indexed-session projection count, and
7
+ embedding readiness. Counters are bounded to 100 agents and reset on restart.
8
+ No prompts, excerpts, paths, keys, or provider error bodies enter these counters.
9
+ Parser cleanup/budget-skip counts are persisted with the latest completed
10
+ `memory_sync_status`; unchanged sessions are not counted again. QMD structural
11
+ omission counts cover this manager's embedding passes, not the whole corpus.
12
+ - Quality-audit groups distinguish `preserve_evidence_repair`, `inspect_scaffolding`,
13
+ and `context_review`, reusing cached noise/evidence judgments without another call.
14
+ Evidence-preserving repair tasks sort first. Maintenance tasks expose indexed
15
+ fingerprint presence; `not_present_in_index` is **not** a verified repair and
16
+ never resolves or deletes the task. Chunk boundaries may simply have changed.
17
+ - `memory_review_cluster` uses the existing `qualityAudit` opt-in/corpus allowlist.
18
+ It judges up to three representative and three low-membership members, deduplicates
19
+ the sample, and skips unapproved or >2,000-character chunks whole. Repeated defect
20
+ labels are investigation leads only. Stale/changed samples are rejected; useful
21
+ or uncertain members are retained. No tasks or sources are modified.
22
+ - `memory_review_claim` accepts one atomic claim (up to 2,000 characters) and 1–3
23
+ citations `{path, from, lines}`. It reads approved indexed evidence itself (at
24
+ most 6,000 characters), returns supports/contradicts/insufficient_evidence with
25
+ confidence and source hashes, and never writes or authorizes a write. Support
26
+ below 0.9 confidence is marked for review. This threshold is provisional, not a
27
+ guarantee of truth; read original evidence and verify current-state claims.
28
+
29
+ New optional configuration (corpora must already be configured):
30
+
31
+ ```json
32
+ {
33
+ "evidenceReview": { "enabled": true, "corpora": ["memory", "knowledge", "sessions"] },
34
+ "memoryWhisperer": {
35
+ "enabled": true, "corpora": ["memory", "knowledge"], "complementaryHints": true
36
+ }
37
+ }
38
+ ```
39
+
40
+ Both additions default off. Claim review sends the proposed claim and approved
41
+ source excerpts to TypeSafe; cluster review sends approved sampled excerpts.
42
+ Complementary hints use one extra bounded call over at most four already-useful
43
+ candidates (six directional comparisons). Only redundancy probability >=0.9
44
+ removes a hint; distinct evidence and contradictions should remain. Provider errors
45
+ retain baseline hints, while the existing total turn deadline/cancellation still
46
+ suppresses late results. Missing keys or disabled TypeSafe never enable these calls.
47
+ The retrieval corpus/session boundaries are unchanged.
48
+
3
49
  Workspace-native memory for OpenClaw, powered internally by `@unblocklabs/qmd`.
4
50
  It keeps one warm QMD store per agent and exposes the standard `memory_search`
5
51
  and `memory_get` tools. Search uses semantic chunking and direct QMD vector
@@ -8,6 +54,53 @@ search without query expansion or a reranker, so only the embedding model loads.
8
54
  Optional memory analysis uses those same stored vectors in the same SQLite
9
55
  index. It does not re-embed memory, copy vectors, or create another database.
10
56
 
57
+ ### Loggie meeting interoperability
58
+
59
+ Loggie v0.1.12+ persists versioned, speaker-attributed meeting Markdown separately
60
+ from its workflow prompt. Session projection recognizes that format and also
61
+ normalizes complete legacy Loggie JSON envelopes. Unrecognized, malformed or
62
+ truncated legacy payloads keep their original text; source sessions are never
63
+ rewritten. Summaries remain labeled as generated material, distinct from speech.
64
+
65
+ QMD groups adjacent speaker blocks rather than forcing one chunk per speaker.
66
+ Search expands around the matching exchange within its existing budget. Long
67
+ monologue excerpts regain the source speaker label while citations still point
68
+ to the exact original source lines. No identity or timestamp is invented.
69
+
70
+ Within a session, identical replayed transcripts are suppressed; distinct
71
+ complete revisions with ordered source sequence numbers retain their history
72
+ and assistant follow-ups, with older versions marked superseded. Account,
73
+ workspace, meeting and external transcript identifiers scope the comparison.
74
+ Ambiguous/partial revisions are preserved. Separate session windows are not
75
+ globally deduplicated.
76
+
77
+ Use the session projection as the searchable meeting copy. Loggie raw archives
78
+ remain opt-in and should stay outside file-corpus globs (new default:
79
+ `transcripts/loggie-archive`). Memory never follows archive paths embedded in
80
+ messages. Truncated sessions stay explicitly incomplete; enabling archive
81
+ enrichment is not part of this version.
82
+
83
+ ### Conservative ingestion cleanup
84
+
85
+ Session projections unwrap complete, recognized task/attachment envelopes while
86
+ keeping the actual result, task/status, filename, MIME type, and untrusted-content
87
+ label. Internal task cleanup requires structured inter-session provenance, not
88
+ just matching text. Unknown formats, malformed envelopes, and code examples stay
89
+ intact. Assistant messages and Loggie's separate projection path are unaffected.
90
+ Raw session events and workspace memory files are never rewritten.
91
+ Attachment matching has a fixed work budget; oversized or repeatedly nested/
92
+ incomplete envelopes leave the entire message unchanged rather than blocking sync.
93
+
94
+ The companion QMD semantic-chunking update skips only source-confirmed standalone
95
+ REM heading/marker spans and orphan closing fences. Reflections and useful text
96
+ remain searchable, with original source offsets. These are deterministic rules,
97
+ not TypeSafe judgments; audit flags never authorize automatic memory deletion.
98
+
99
+ This release pins QMD 2.9.6. Projector/chunker version changes refresh derived
100
+ projections and embeddings on their next normal sync; the first sync may take
101
+ longer while re-embedding. No manual deletion of source memories or review tasks
102
+ is needed.
103
+
11
104
  ## Installation
12
105
 
13
106
  From npm:
@@ -0,0 +1,2 @@
1
+ /** Stop waiting without cancelling shared work that other callers still need. */
2
+ export declare function abortable<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -0,0 +1,21 @@
1
+ /** Stop waiting without cancelling shared work that other callers still need. */
2
+ export async function abortable(pending, signal) {
3
+ if (!signal)
4
+ return pending;
5
+ let onAbort = () => { };
6
+ const cancelled = new Promise((_resolve, reject) => {
7
+ onAbort = () => reject(signal.reason);
8
+ signal.addEventListener("abort", onAbort, { once: true });
9
+ if (signal.aborted)
10
+ onAbort();
11
+ });
12
+ try {
13
+ // Observe late failures even when cancellation wins the race.
14
+ const result = await Promise.race([pending, cancelled]);
15
+ signal.throwIfAborted();
16
+ return result;
17
+ }
18
+ finally {
19
+ signal.removeEventListener("abort", onAbort);
20
+ }
21
+ }
@@ -0,0 +1,47 @@
1
+ import type { QMDStore } from "@unblocklabs/qmd";
2
+ import { type ResolvedSource } from "./sources.js";
3
+ /** Inspect center and edge samples; never extrapolate their labels to the rest of a cluster. */
4
+ export declare function reviewClusterIngestion(params: {
5
+ db: QMDStore["internal"]["db"];
6
+ sources: readonly ResolvedSource[];
7
+ clusterId: string;
8
+ apiKey: string;
9
+ timeoutMs: number;
10
+ signal: AbortSignal;
11
+ read?: <T>(run: () => T) => Promise<T>;
12
+ }): Promise<{
13
+ status: "unavailable";
14
+ reason: string;
15
+ sample?: undefined;
16
+ considered?: undefined;
17
+ runId?: undefined;
18
+ clusterSize?: undefined;
19
+ } | {
20
+ status: "ok";
21
+ runId: string;
22
+ clusterId: string;
23
+ members: {
24
+ flagged: boolean;
25
+ defect: "encoding" | "wrapper" | "boilerplate" | "none_or_uncertain";
26
+ confidence: number;
27
+ path: string;
28
+ hash: string;
29
+ seq: number;
30
+ from: number;
31
+ fingerprint: string;
32
+ }[];
33
+ recurring: {
34
+ defect: string;
35
+ examples: {
36
+ path: string;
37
+ from: number;
38
+ fingerprint: string;
39
+ }[];
40
+ }[];
41
+ sampled: number;
42
+ considered: number;
43
+ clusterSize: number | undefined;
44
+ policy: string;
45
+ scope: string;
46
+ reason?: undefined;
47
+ }>;
@@ -0,0 +1,64 @@
1
+ import { readAnalysisSummary, readCluster } from "./analysis.js";
2
+ import { parseSafeVirtualPath } from "./sources.js";
3
+ import { reviewClusterDefects } from "./typesafe-review.js";
4
+ import { chunkFingerprint } from "./curation.js";
5
+ /** Inspect center and edge samples; never extrapolate their labels to the rest of a cluster. */
6
+ export async function reviewClusterIngestion(params) {
7
+ params.signal.throwIfAborted();
8
+ const sources = new Map(params.sources.filter(source => source.kind !== "skills").map(source => [source.collection, source]));
9
+ const read = params.read ?? (async (run) => run());
10
+ const snapshot = await read(() => {
11
+ const center = readCluster(params.db, params.clusterId, 3, 0, "representative");
12
+ if (center.status !== "ok" || center.stale)
13
+ return { status: "unavailable", reason: "Cluster missing or stale; refresh analysis first" };
14
+ const edge = readCluster(params.db, params.clusterId, 3, 0, "score_asc");
15
+ const candidates = [...new Map([...(center.members ?? []), ...(edge.members ?? [])].map(member => [`${member.hash}:${member.seq}`, member])).values()];
16
+ const sample = candidates.flatMap(member => {
17
+ const path = member.sourcePaths.find(path => parseSafeVirtualPath(path, sources));
18
+ if (!path)
19
+ return [];
20
+ const safe = parseSafeVirtualPath(path, sources);
21
+ // Reload the full chunk; analysis previews may be truncated. Never judge a silently cut prefix.
22
+ const row = params.db.prepare(`SELECT c.doc, v.pos, v.chunk_len FROM documents d JOIN content c ON c.hash = d.hash
23
+ JOIN content_vectors v ON v.hash = d.hash WHERE d.active = 1 AND d.collection = ? AND d.path = ? AND d.hash = ? AND v.seq = ?`)
24
+ .get(safe.source.collection, safe.relativePath, member.hash, member.seq);
25
+ if (!row || row.pos < 0 || row.chunk_len < 1 || row.pos + row.chunk_len > row.doc.length || row.chunk_len > 2000)
26
+ return [];
27
+ const text = row.doc.slice(row.pos, row.pos + row.chunk_len);
28
+ return [{ path, hash: member.hash, seq: member.seq, from: row.doc.slice(0, row.pos).split("\n").length,
29
+ fingerprint: chunkFingerprint(text), text }];
30
+ });
31
+ return { status: "ready", sample, considered: candidates.length, runId: center.runId, clusterSize: center.cluster?.availableSize };
32
+ });
33
+ if (snapshot.status !== "ready")
34
+ return snapshot;
35
+ const { sample } = snapshot;
36
+ if (!sample.length)
37
+ return { status: "unavailable", reason: "No complete bounded members in approved corpora" };
38
+ const judgments = await reviewClusterDefects({ ...params, excerpts: sample.map(member => member.text) });
39
+ params.signal.throwIfAborted();
40
+ return read(() => {
41
+ const current = readAnalysisSummary(params.db);
42
+ if (!current || current.stale || current.runId !== snapshot.runId)
43
+ return { status: "unavailable", reason: "Analysis changed during review; retry" };
44
+ for (const member of sample) {
45
+ const safe = parseSafeVirtualPath(member.path, sources);
46
+ if (!safe)
47
+ return { status: "unavailable", reason: "Source scope changed; retry" };
48
+ const row = params.db.prepare(`SELECT c.doc, v.pos, v.chunk_len FROM documents d JOIN content c ON c.hash = d.hash
49
+ JOIN content_vectors v ON v.hash = d.hash WHERE d.active = 1 AND d.collection = ? AND d.path = ? AND d.hash = ? AND v.seq = ?`)
50
+ .get(safe.source.collection, safe.relativePath, member.hash, member.seq);
51
+ if (!row || chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len)) !== member.fingerprint)
52
+ return { status: "unavailable", reason: "Sample changed during review; retry" };
53
+ }
54
+ const members = sample.map(({ text: _text, ...member }, index) => ({ ...member, ...judgments[index],
55
+ flagged: judgments[index].defect !== "none_or_uncertain" && judgments[index].confidence >= 0.9 }));
56
+ const recurring = ["wrapper", "encoding", "boilerplate"].flatMap(defect => {
57
+ const examples = members.filter(member => member.flagged && member.defect === defect);
58
+ return examples.length >= 2 ? [{ defect, examples: examples.map(member => ({ path: member.path, from: member.from, fingerprint: member.fingerprint })) }] : [];
59
+ });
60
+ return { status: "ok", runId: snapshot.runId, clusterId: params.clusterId, members, recurring,
61
+ sampled: sample.length, considered: snapshot.considered, clusterSize: snapshot.clusterSize,
62
+ policy: "jev-1.13.0:cluster-defects-v1", scope: "Center/edge sample of approved complete chunks only. Recurring labels are hypotheses, not proof of a shared cause or permission to change any member. Unreviewed members remain unknown." };
63
+ });
64
+ }
@@ -36,6 +36,10 @@ export type UnblockMemoryConfig = {
36
36
  corpora: readonly string[];
37
37
  minNoise: number;
38
38
  };
39
+ evidenceReview: {
40
+ enabled: boolean;
41
+ corpora: readonly string[];
42
+ };
39
43
  people: {
40
44
  enabled: boolean;
41
45
  whisperer: {
@@ -54,6 +58,7 @@ export type UnblockMemoryConfig = {
54
58
  };
55
59
  memoryWhisperer: {
56
60
  enabled: boolean;
61
+ complementaryHints: boolean;
57
62
  corpora: readonly string[];
58
63
  historyMessages: number;
59
64
  minUsefulness: number;
@@ -79,7 +79,7 @@ const DEFAULT_SKILL_WHISPERER = {
79
79
  cooldownTurns: 10,
80
80
  };
81
81
  const DEFAULT_MEMORY_WHISPERER = {
82
- enabled: false, corpora: [], historyMessages: 5, minUsefulness: 0.9,
82
+ enabled: false, complementaryHints: false, corpora: [], historyMessages: 5, minUsefulness: 0.9,
83
83
  maxHints: 2, cooldownTurns: 10, timeoutMs: 3000,
84
84
  };
85
85
  function resolveMemoryWhisperer(value, corpora) {
@@ -93,6 +93,9 @@ function resolveMemoryWhisperer(value, corpora) {
93
93
  const enabled = config.enabled ?? false;
94
94
  if (typeof enabled !== "boolean")
95
95
  throw new Error("unblock-memory memoryWhisperer.enabled must be a boolean");
96
+ const complementaryHints = config.complementaryHints ?? false;
97
+ if (typeof complementaryHints !== "boolean")
98
+ throw new Error("memoryWhisperer.complementaryHints must be a boolean");
96
99
  const selected = config.corpora ?? [];
97
100
  if (!Array.isArray(selected) || !selected.every((name) => typeof name === "string" && corpora.some(corpus => corpus.name === name && corpus.kind !== "skills"))) {
98
101
  throw new Error("unblock-memory memoryWhisperer.corpora must list configured non-skill corpora");
@@ -112,7 +115,7 @@ function resolveMemoryWhisperer(value, corpora) {
112
115
  throw new Error("unblock-memory memoryWhisperer.minUsefulness must be between 0 and 1");
113
116
  }
114
117
  return {
115
- enabled, corpora: [...new Set(selected)], historyMessages, cooldownTurns, minUsefulness,
118
+ enabled, complementaryHints, corpora: [...new Set(selected)], historyMessages, cooldownTurns, minUsefulness,
116
119
  maxHints: positiveInteger(config.maxHints, 2, "memoryWhisperer.maxHints", 2),
117
120
  timeoutMs: positiveInteger(config.timeoutMs, 3000, "memoryWhisperer.timeoutMs", 10_000),
118
121
  };
@@ -258,6 +261,7 @@ export function resolveConfig(value) {
258
261
  analysis: {},
259
262
  typesafe: { ...DEFAULT_TYPESAFE_CONFIG },
260
263
  qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
264
+ evidenceReview: { enabled: false, corpora: [] },
261
265
  people: DEFAULT_PEOPLE_CONFIG,
262
266
  skillWhisperer: DEFAULT_SKILL_WHISPERER,
263
267
  memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER },
@@ -267,9 +271,23 @@ export function resolveConfig(value) {
267
271
  throw new Error("unblock-memory config must be an object");
268
272
  }
269
273
  const config = value;
270
- assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit"], "config");
274
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit", "evidenceReview"], "config");
271
275
  const corpora = resolveCorpora(config.corpora);
272
276
  const people = resolvePeople(config.people);
277
+ let evidenceReview = { enabled: false, corpora: [] };
278
+ if (config.evidenceReview !== undefined) {
279
+ const value = config.evidenceReview;
280
+ if (!value || typeof value !== "object" || Array.isArray(value))
281
+ throw new Error("evidenceReview must be an object");
282
+ assertOnlyKeys(value, ["enabled", "corpora"], "evidenceReview");
283
+ try {
284
+ const approved = resolveQualityAudit(value, corpora);
285
+ evidenceReview = { enabled: approved.enabled, corpora: approved.corpora };
286
+ }
287
+ catch {
288
+ throw new Error("evidenceReview requires a boolean enabled and explicit configured non-skill corpora when enabled");
289
+ }
290
+ }
273
291
  if (config.keepEmbeddingModelWarm !== undefined &&
274
292
  typeof config.keepEmbeddingModelWarm !== "boolean") {
275
293
  throw new Error("unblock-memory keepEmbeddingModelWarm must be a boolean");
@@ -328,5 +346,6 @@ export function resolveConfig(value) {
328
346
  }
329
347
  return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, skillWhisperer,
330
348
  qualityAudit: resolveQualityAudit(config.qualityAudit, corpora),
349
+ evidenceReview,
331
350
  memoryWhisperer: resolveMemoryWhisperer(config.memoryWhisperer, corpora), typesafe: resolveTypeSafe(config.typesafe) };
332
351
  }
@@ -155,7 +155,10 @@ export class CurationStore {
155
155
  return this.#db.prepare(`
156
156
  SELECT * FROM maintenance_tasks
157
157
  WHERE status = ?
158
- ORDER BY created_at, id
158
+ ORDER BY CASE WHEN type = 'quality_review' AND json_valid(detail) THEN
159
+ CASE WHEN json_extract(detail, '$.evidence') >= 0.8 AND
160
+ (json_extract(detail, '$.noise') >= 0.8 OR reason = 'possible_double_encoded_message') THEN 0 ELSE 1 END
161
+ ELSE 1 END, created_at, id
159
162
  LIMIT ?
160
163
  `).all(status, limit).map((row) => task(row));
161
164
  }
@@ -0,0 +1,39 @@
1
+ type Whisperer = "skill" | "memory";
2
+ type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable";
3
+ /** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
4
+ export declare class WhispererDiagnostics {
5
+ #private;
6
+ record(agentId: string, whisperer: Whisperer, outcome: Outcome): void;
7
+ snapshot(agentId: string): {
8
+ skill: {
9
+ unavailable?: number | undefined;
10
+ rejected?: number | undefined;
11
+ failed?: number | undefined;
12
+ missing_key?: number | undefined;
13
+ typesafe_disabled?: number | undefined;
14
+ no_candidates?: number | undefined;
15
+ cooldown?: number | undefined;
16
+ emitted?: number | undefined;
17
+ timed_out?: number | undefined;
18
+ cancelled?: number | undefined;
19
+ payload_limit?: number | undefined;
20
+ redundancy_unavailable?: number | undefined;
21
+ };
22
+ memory: {
23
+ unavailable?: number | undefined;
24
+ rejected?: number | undefined;
25
+ failed?: number | undefined;
26
+ missing_key?: number | undefined;
27
+ typesafe_disabled?: number | undefined;
28
+ no_candidates?: number | undefined;
29
+ cooldown?: number | undefined;
30
+ emitted?: number | undefined;
31
+ timed_out?: number | undefined;
32
+ cancelled?: number | undefined;
33
+ payload_limit?: number | undefined;
34
+ redundancy_unavailable?: number | undefined;
35
+ };
36
+ scope: string;
37
+ };
38
+ }
39
+ export {};
@@ -0,0 +1,18 @@
1
+ /** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
2
+ export class WhispererDiagnostics {
3
+ #agents = new Map();
4
+ record(agentId, whisperer, outcome) {
5
+ let entry = this.#agents.get(agentId);
6
+ if (!entry) {
7
+ if (this.#agents.size >= 100)
8
+ this.#agents.delete(this.#agents.keys().next().value);
9
+ entry = { skill: {}, memory: {} };
10
+ this.#agents.set(agentId, entry);
11
+ }
12
+ entry[whisperer][outcome] = Math.min(Number.MAX_SAFE_INTEGER, (entry[whisperer][outcome] ?? 0) + 1);
13
+ }
14
+ snapshot(agentId) {
15
+ const entry = this.#agents.get(agentId);
16
+ return { skill: { ...entry?.skill }, memory: { ...entry?.memory }, scope: "process lifetime; up to 100 agents" };
17
+ }
18
+ }
@@ -0,0 +1,41 @@
1
+ import type { QMDStore } from "@unblocklabs/qmd";
2
+ import { type ResolvedSource } from "./sources.js";
3
+ export type EvidenceCitation = {
4
+ path: string;
5
+ from: number;
6
+ lines: number;
7
+ };
8
+ export declare function reviewIndexedClaim(params: {
9
+ db: QMDStore["internal"]["db"];
10
+ sources: readonly ResolvedSource[];
11
+ claim: string;
12
+ citations: readonly EvidenceCitation[];
13
+ apiKey: string;
14
+ timeoutMs: number;
15
+ signal: AbortSignal;
16
+ read?: <T>(run: () => T) => Promise<T>;
17
+ }): Promise<{
18
+ status: "unavailable";
19
+ verdict: "insufficient_evidence";
20
+ needsReview: boolean;
21
+ reason: string;
22
+ } | {
23
+ evidence: {
24
+ path: string;
25
+ from: number;
26
+ lines: number;
27
+ documentHash: string;
28
+ excerptHash: string;
29
+ }[];
30
+ policy: string;
31
+ scope: string;
32
+ verdict: "supports" | "contradicts" | "insufficient_evidence";
33
+ confidence: number;
34
+ probabilities: {
35
+ supports: number;
36
+ contradicts: number;
37
+ insufficient_evidence: number;
38
+ };
39
+ needsReview: boolean;
40
+ status: "ok";
41
+ }>;
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { parseSafeVirtualPath } from "./sources.js";
3
+ import { reviewTypeSafeClaim } from "./typesafe-review.js";
4
+ export async function reviewIndexedClaim(params) {
5
+ const unavailable = (reason) => ({ status: "unavailable", verdict: "insufficient_evidence", needsReview: true, reason });
6
+ if (!params.claim.trim() || params.claim.length > 2000 || !params.citations.length || params.citations.length > 3)
7
+ return unavailable("Invalid review bounds");
8
+ params.signal.throwIfAborted();
9
+ const sources = new Map(params.sources.filter(source => source.kind !== "skills").map(source => [source.collection, source]));
10
+ const read = params.read ?? (async (run) => run());
11
+ const snapshot = await read(() => {
12
+ const evidence = [];
13
+ for (const citation of params.citations) {
14
+ const safe = parseSafeVirtualPath(citation.path, sources);
15
+ if (!safe || !Number.isInteger(citation.from) || citation.from < 1 || !Number.isInteger(citation.lines) || citation.lines < 1 || citation.lines > 120)
16
+ return unavailable("Evidence is unavailable or outside approved corpora");
17
+ const row = params.db.prepare(`SELECT d.hash, c.doc FROM documents d JOIN content c ON c.hash = d.hash
18
+ WHERE d.active = 1 AND d.collection = ? AND d.path = ?`).get(safe.source.collection, safe.relativePath);
19
+ if (!row)
20
+ return unavailable("Evidence is not indexed");
21
+ const lines = row.doc.split("\n");
22
+ if (citation.from > lines.length)
23
+ return unavailable("Evidence range is outside the indexed source");
24
+ const text = lines.slice(citation.from - 1, citation.from - 1 + citation.lines).join("\n");
25
+ if (!text.trim())
26
+ return unavailable("Evidence range is empty");
27
+ evidence.push({ path: safe.normalized, from: citation.from, lines: Math.min(citation.lines, lines.length - citation.from + 1),
28
+ text, documentHash: row.hash, excerptHash: createHash("sha256").update(text).digest("hex") });
29
+ }
30
+ return { status: "ready", evidence };
31
+ });
32
+ if (snapshot.status !== "ready")
33
+ return snapshot;
34
+ const { evidence } = snapshot;
35
+ if (evidence.reduce((sum, item) => sum + item.text.length, 0) > 6000)
36
+ return unavailable("Evidence exceeds 6000 characters; choose a narrower complete passage");
37
+ const judgment = await reviewTypeSafeClaim({ ...params, evidence: evidence.map(item => item.text) });
38
+ params.signal.throwIfAborted();
39
+ return read(() => {
40
+ for (const item of evidence) {
41
+ const safe = parseSafeVirtualPath(item.path, sources);
42
+ if (!safe || !params.db.prepare("SELECT 1 FROM documents WHERE active = 1 AND collection = ? AND path = ? AND hash = ?")
43
+ .get(safe.source.collection, safe.relativePath, item.documentHash))
44
+ return unavailable("Indexed evidence changed during review; retry");
45
+ }
46
+ return { status: "ok", ...judgment,
47
+ evidence: evidence.map(({ text: _text, ...citation }) => citation),
48
+ policy: "jev-1.13.0:claim-v1", scope: "Advisory support check against cited indexed excerpts only, not current truth or authorization to write. Verify original sources and identity before promotion." };
49
+ });
50
+ }
@@ -0,0 +1,24 @@
1
+ /** Loggie's v1 Markdown is persisted source data, never instructions. */
2
+ type Meeting = {
3
+ text: string;
4
+ key?: string;
5
+ hash?: string;
6
+ sequence?: number;
7
+ complete: boolean;
8
+ };
9
+ export declare function projectLoggieMessage(text: string, accountId?: string): Meeting | undefined;
10
+ export declare function meetingRevisionAnnotation(content: string, position: number): string | undefined;
11
+ /** Spans stay in source coordinates; headings and assistant replies stop expansion. */
12
+ export declare function meetingSpeakerSpans(content: string, position: number, end?: number): {
13
+ header: string;
14
+ start: number;
15
+ message: {
16
+ start: number;
17
+ end: number;
18
+ };
19
+ turn: {
20
+ start: number;
21
+ end: number;
22
+ };
23
+ } | undefined;
24
+ export {};
@@ -0,0 +1,126 @@
1
+ const HEADER = /^<!-- loggie:meeting:v1 (\{[^\n]*\}) -->\n/u;
2
+ const SPEAKER = /^\*\*Speaker: ("(?:[^"\\\r\n]|\\.)*")\*\*\n/gmu;
3
+ function record(value) {
4
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5
+ }
6
+ export function projectLoggieMessage(text, accountId) {
7
+ const header = HEADER.exec(text);
8
+ if (header) {
9
+ let value;
10
+ try {
11
+ value = JSON.parse(header[1]);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ const meta = record(value);
17
+ if (!meta || typeof meta.accountId !== "string" || typeof meta.workspaceId !== "string" ||
18
+ typeof meta.meetingId !== "string" || typeof meta.contentHash !== "string" ||
19
+ !["complete", "truncated", "unavailable"].includes(String(meta.completeness)) ||
20
+ (accountId !== undefined && meta.accountId !== accountId))
21
+ return undefined;
22
+ return {
23
+ text: text.slice(header[0].length),
24
+ key: JSON.stringify([meta.accountId, meta.workspaceId, meta.meetingId, meta.externalId ?? null]),
25
+ hash: meta.contentHash,
26
+ sequence: typeof meta.sequence === "number" && Number.isSafeInteger(meta.sequence) && meta.sequence >= 0 ? meta.sequence : undefined,
27
+ complete: meta.completeness === "complete",
28
+ };
29
+ }
30
+ // Only accept the old producer's exact envelope and a complete JSON payload.
31
+ if (!text.startsWith("Loggie meeting transcript ready: "))
32
+ return undefined;
33
+ const delimiter = "\nTranscript Detail:\n";
34
+ const at = text.indexOf(delimiter);
35
+ if (at < 0)
36
+ return undefined;
37
+ let value;
38
+ try {
39
+ value = JSON.parse(text.slice(at + delimiter.length));
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ const transcript = record(record(value)?.transcript);
45
+ if (!transcript || typeof transcript.text !== "string")
46
+ return undefined;
47
+ const names = Array.isArray(transcript.participants) ? transcript.participants.flatMap(item => {
48
+ const name = record(item)?.name;
49
+ return typeof name === "string" && name.trim() ? [name.trim()] : [];
50
+ }) : [];
51
+ const raw = transcript.text.trim();
52
+ const speech = raw === "[No transcript provided]" ? "" : raw;
53
+ const blocks = [];
54
+ let speaker = "Unattributed";
55
+ let lines = [];
56
+ const flush = () => {
57
+ const content = lines.join("\n").trim();
58
+ if (content)
59
+ blocks.push(`**Speaker: ${JSON.stringify(speaker)}**\n` + content.split("\n").map(line => `> ${line}`).join("\n"));
60
+ lines = [];
61
+ };
62
+ for (const line of speech.replace(/\r\n?/gu, "\n").split("\n")) {
63
+ const match = /^([^:\n]{1,160}):[ \t]+(.*)$/u.exec(line);
64
+ const label = match?.[1]?.trim();
65
+ const recognized = label && (names.some(name => label === name || label.startsWith(`${name} [`)) ||
66
+ /^(?:Speaker|Person)\s+[\p{L}\d]+(?:\s|$)/u.test(label) ||
67
+ /^[\p{Lu}\p{Lt}][\p{L}'’.-]+(?:\s+[\p{Lu}\p{Lt}][\p{L}'’.-]+)+(?:\s*\[.*\])?$/u.test(label));
68
+ if (match && recognized) {
69
+ flush();
70
+ speaker = label;
71
+ lines.push(match[2]);
72
+ }
73
+ else
74
+ lines.push(line);
75
+ }
76
+ flush();
77
+ const sections = [speech ? `## Transcript\n\n${blocks.join("\n\n")}` : "Transcript status: unavailable. No verbatim speech was supplied."];
78
+ for (const [key, title] of [["summary", "Summary (generated)"], ["outline", "Outline (generated)"]]) {
79
+ const content = transcript[key];
80
+ if (typeof content === "string" && content.trim())
81
+ sections.push(`## ${title}\n\n${content.trim().split(/\r?\n/u).map(line => `> ${line}`).join("\n")}`);
82
+ }
83
+ const title = text.split("\n")[0].slice("Loggie meeting transcript ready: ".length);
84
+ const date = /^Meeting Date: (.*)$/mu.exec(text.slice(0, at))?.[1];
85
+ return { text: `# Meeting: ${title}\n\n${date ? `Meeting date: ${date}\n\n` : ""}${sections.join("\n\n")}`, complete: false };
86
+ }
87
+ export function meetingRevisionAnnotation(content, position) {
88
+ const enclosingMessage = content.lastIndexOf("\n## User — ", position);
89
+ return /^Transcript revision \d+ \(superseded by revision \d+\)\.$/mu.exec(content.slice(Math.max(0, enclosingMessage), position))?.[0];
90
+ }
91
+ /** Spans stay in source coordinates; headings and assistant replies stop expansion. */
92
+ export function meetingSpeakerSpans(content, position, end = position) {
93
+ const markers = [...content.matchAll(SPEAKER)].flatMap(match => {
94
+ try {
95
+ const speaker = JSON.parse(match[1]);
96
+ return typeof speaker === "string" ? [{ start: match.index, header: match[0].trimEnd() }] : [];
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ });
102
+ const ranges = markers.map(marker => {
103
+ const rest = content.slice(marker.start + marker.header.length + 1);
104
+ const quoted = /^(?:>[^\n]*(?:\n|$))+/u.exec(rest)?.[0];
105
+ return { ...marker, end: marker.start + marker.header.length + 1 + (quoted?.length ?? 0) };
106
+ });
107
+ const first = ranges.findIndex(range => range.start <= position && range.end > position);
108
+ if (first < 0)
109
+ return undefined;
110
+ let last = first;
111
+ while (ranges[last].end < end && ranges[last + 1] && !content.slice(ranges[last].end, ranges[last + 1].start).trim())
112
+ last++;
113
+ if (ranges[last].end < end && content.slice(ranges[last].end, end).trim())
114
+ return undefined;
115
+ const prev = ranges[first - 1];
116
+ const next = ranges[last + 1];
117
+ return {
118
+ header: ranges[first].header,
119
+ start: ranges[first].start,
120
+ message: { start: ranges[first].start, end: Math.max(end, ranges[last].end) },
121
+ turn: {
122
+ start: prev && !content.slice(prev.end, ranges[first].start).trim() ? prev.start : ranges[first].start,
123
+ end: next && !content.slice(ranges[last].end, next.start).trim() ? next.end : Math.max(end, ranges[last].end),
124
+ },
125
+ };
126
+ }