@unblocklabs/unblock-memory 0.3.16 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -524,18 +524,105 @@ directory.
524
524
 
525
525
  ### People Whisperer
526
526
 
527
+ #### Optional People Dossier Primer
528
+
529
+ `memory_people_prime({ personId, agentName? })` prepares evidence for an existing person;
530
+ it does **not** generate claims, update dossiers, or inject context. With
531
+ `people.enabled: true`, opt in separately:
532
+
533
+ ```json
534
+ {
535
+ "peoplePrimer": {
536
+ "enabled": true,
537
+ "corpora": ["memory", "knowledge", "sessions"],
538
+ "hitsPerQuestion": 30,
539
+ "minScore": 0.35,
540
+ "minUsefulness": 0.8,
541
+ "maxEvidencePerQuestion": 3,
542
+ "timeoutMs": 30000
543
+ }
544
+ }
545
+ ```
546
+
547
+ List only configured, approved non-skill corpora. The feature is **off by
548
+ default** and requires shared TypeSafe credentials. Disabled TypeSafe or missing/
549
+ unreadable credentials safely skip the primer; agents can still research normally.
550
+ Enabling it approves sending the person's identity, retrieved excerpts and optional
551
+ draft snippet to TypeSafe. Existing dossiers are not sent as grading evidence.
552
+ Sessions includes all indexed conversations;
553
+ results are available to the agent's tool callers, so scope approval accordingly.
554
+
555
+ Three default questions cover explicit role/organization, enduring organizational
556
+ background, and the person's relationship to the agent (not its business mission).
557
+ Preferences, working styles, priorities, feedback and task history are excluded.
558
+ Each uses QMD vector search (no query expansion) for up to 30 hits, configurable
559
+ up to 40. All unique eligible hits above the vector threshold are graded, not just
560
+ the final top three. Complete excerpts over 6,000 characters are counted and skipped,
561
+ not silently truncated. Duplicate source spans across questions share a request;
562
+ Independent attribution, explicit-background, durability, recognition-value and
563
+ question-usefulness judgments run together; every dimension must pass the threshold.
564
+ Every candidate is graded against all three questions, regardless of which search
565
+ found it. Mixed excerpts may supply a useful background fact without making their
566
+ surrounding behavioral content eligible for the snippet.
567
+ Provider concurrency is four, with a two-minute overall tool deadline.
568
+
569
+ Supply the agent's human-facing name when no identity name is configured; otherwise
570
+ questions use "the assistant", never an internal routing ID such as `main`.
571
+ The output includes a deduplicated source-linked excerpt list referenced by each
572
+ question's evidence IDs, a bounded uncertain-review shortlist,
573
+ and retrieval/cache/failure counts. Coverage is `evidence_found`, `uncertain` or
574
+ `unknown`, not a claim that a question has been definitively answered. Partial
575
+ provider failures are explicit; absence of selected hits does not prove absence of
576
+ evidence. The agent must verify dates, speakers and contradictions before writing.
577
+ Memory evidence never grants permissions or establishes that an old request is
578
+ still open.
579
+
580
+ `memory_people_update({ action: "replace_dossier", personId, dossier, reason,
581
+ agentName? })` automatically checks the proposed blurb before saving. Exact
582
+ `qmd://path#Lstart-Lend` claim evidence locators supply up to three indexed ranges
583
+ from the primer's approved corpora (120 lines each, 6,000 characters total).
584
+ Support confidence and background-only/explicit-support probabilities must all
585
+ be >=0.9. `needs_review` or `review_unavailable` leaves the dossier and history
586
+ unchanged; missing keys and failures never count as approval. A concurrent dossier
587
+ edit/deletion returns `conflict` instead of overwriting the newer change.
588
+
589
+ After independently verifying every assertion and background eligibility, an agent
590
+ can supply a source-specific `manualVerification` explanation (up to 400 characters)
591
+ for direct human corrections, non-indexed evidence or disabled/unavailable/incorrect
592
+ reviews. This explicit path skips TypeSafe, records manual provenance in change
593
+ history and keeps all structural limits. It is not a provider pass. Normal success
594
+ returns `status: "ok"`, `saved: true` and `verification: "typesafe" | "manual"`.
595
+ The skill documents when to use each path. Sources outside approved corpora are
596
+ rejected before egress; no separate `evidenceReview` toggle is needed.
597
+
598
+ For optional read-only diagnostics, `memory_people_prime({ personId, agentName?,
599
+ draft: { blurb, citations: [{ path, from, lines }] } })` still reviews a snippet
600
+ without writing. Agents do not need this extra call in the normal update workflow.
601
+
602
+ Judgments are cached privately in `people.sqlite` (maximum 2,000 entries), keyed
603
+ by person, agent, exact evidence/context, questions,
604
+ and judge version. No source text or credentials are stored in the cache.
605
+ Retrieval reruns against the current index; unchanged judgments are reused.
606
+ This is on-demand preparation, not a new scheduler or incremental session scanner.
607
+ Use it from an existing People Whisperer maintenance cron. Refresh stale session
608
+ indexes with `memory_sync_sessions` before priming when needed.
609
+
610
+ #### People store and maintenance
611
+
527
612
  PeopleSQL is an optional agent-local people store. When `people.enabled` is
528
613
  true, incoming Slack messages with a canonical agent session key and exact
529
614
  account and sender IDs create or refresh an injection-enabled person record.
530
615
  Incomplete Slack identities create a bounded, deduplicated todo without storing
531
616
  message content. Other channels are ignored.
532
617
 
533
- PeopleSQL registers three tools when enabled:
618
+ PeopleSQL registers these tools when enabled:
534
619
 
535
620
  - `memory_people_inspect` lists active people, reads one exact person, reads one
536
621
  person's dossier change history, or lists bounded actionable todos;
537
622
  - `memory_people_update` replaces or deletes dossiers, toggles one person's
538
- injection, and manages company, todo, deletion, or restoration state; and
623
+ injection, and manages company, todo, deletion, or restoration state;
624
+ - `memory_people_prime` prepares evidence when the separately opted-in primer is
625
+ enabled, otherwise returns disabled; and
539
626
  - the optional `memory_people_sync` enriches one active OpenClaw Slack account;
540
627
  its tool input accepts an account ID, not a token.
541
628
 
@@ -551,10 +638,18 @@ person's current dossier, search ordinary memory and sessions with
551
638
  `memory_search`/`memory_get`, and replace the dossier when that would improve a
552
639
  future conversation. The plugin owns no dossier-maintenance workflow or refresh
553
640
  schedule. A dossier's `reviewedAt` value records its last successful write; it
554
- is not scheduling state. The plugin performs no model call for dossier
555
- maintenance or prompt injection.
556
-
557
- Every `replace_dossier` and `delete_dossier` action requires a concise `reason`.
641
+ is not scheduling state. Dossier generation belongs to the agent; prompt injection
642
+ performs no model call. The optional primer grades evidence and reviews draft snippets.
643
+ The goal is recognition, not a behavioral profile: one short paragraph of at most
644
+ 70 words identifying the person and their enduring organization/agent relationship.
645
+ New writes allow only `role`/`relationship` sections and explicit `observed`/`reported`
646
+ claims; priorities, preferences and inferred profiles belong outside dossiers.
647
+ Legacy dossiers remain readable, but must be deliberately rewritten by the agent
648
+ before replacement. No automatic destructive migration or blanket deletion occurs.
649
+
650
+ Every `replace_dossier` and `delete_dossier` action requires a concise `reason`
651
+ (up to 500 characters for replacements, 1,000 for deletions). Replacement history
652
+ also records whether TypeSafe checks passed or a manual attestation was used.
558
653
  The plugin transactionally records that reason with its authoritative before and
559
654
  after dossier snapshots. List small newest-first summaries with
560
655
  `memory_people_inspect({ view: "dossier_changes", personId, limit?, offset? })`,
@@ -577,20 +672,15 @@ The package includes a `$people-whisperer` skill with the canonical agent
577
672
  procedure and dossier shape. For a manual refresh, ask:
578
673
 
579
674
  ```text
580
- Use $people-whisperer to improve your understanding of this person. Search memory
581
- and recent sessions, inspect their current PeopleSQL dossier, and update it only
582
- if the result would make future conversations meaningfully better.
675
+ Use $people-whisperer to maintain this person's brief background snippet.
583
676
  ```
584
677
 
585
678
  For an optional cron or isolated agent session, use this goal:
586
679
 
587
680
  ```text
588
- Use $people-whisperer to improve your understanding of people you interact with.
589
-
590
- Search recent sessions and memory for meaningful information about people. Inspect
591
- their existing PeopleSQL dossiers when useful. Update a dossier only when doing so
592
- would make future conversations meaningfully better. Ignore routine conversation,
593
- repetition, and weak inference. You may update several people or nobody.
681
+ Use $people-whisperer to maintain brief background snippets for people you interact
682
+ with. Follow the packaged skill, including source verification and write results.
683
+ Update only when useful; several people or nobody is fine. Report changes and gaps.
594
684
  ```
595
685
 
596
686
  Choose any cadence appropriate for the agent; the plugin does not require or
@@ -1,4 +1,5 @@
1
1
  import { type ResponseAuditConfig } from "./response-config.js";
2
+ import { type PeoplePrimerConfig } from "./people-primer-config.js";
2
3
  export type FileCorpusConfig = {
3
4
  name: string;
4
5
  kind: "files";
@@ -42,6 +43,7 @@ export type UnblockMemoryConfig = {
42
43
  corpora: readonly string[];
43
44
  };
44
45
  responseAudit: ResponseAuditConfig;
46
+ peoplePrimer: PeoplePrimerConfig;
45
47
  people: {
46
48
  enabled: boolean;
47
49
  whisperer: {
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { resolveResponseAudit } from "./response-config.js";
3
+ import { resolvePeoplePrimer } from "./people-primer-config.js";
3
4
  const DEFAULT_PATHS = ["MEMORY.md", "USER.md", "memory/**/*.md"];
4
5
  const DEFAULT_SESSION_MAX_EXPANDED_TOKENS = 500;
5
6
  const MAX_SESSION_MAX_EXPANDED_TOKENS = 10_000;
@@ -264,6 +265,7 @@ export function resolveConfig(value) {
264
265
  qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
265
266
  evidenceReview: { enabled: false, corpora: [] },
266
267
  responseAudit: resolveResponseAudit(undefined, DEFAULT_CORPORA),
268
+ peoplePrimer: resolvePeoplePrimer(undefined, DEFAULT_CORPORA, false),
267
269
  people: DEFAULT_PEOPLE_CONFIG,
268
270
  skillWhisperer: DEFAULT_SKILL_WHISPERER,
269
271
  memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER },
@@ -273,9 +275,10 @@ export function resolveConfig(value) {
273
275
  throw new Error("unblock-memory config must be an object");
274
276
  }
275
277
  const config = value;
276
- assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit", "evidenceReview", "responseAudit"], "config");
278
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "peoplePrimer", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit", "evidenceReview", "responseAudit"], "config");
277
279
  const corpora = resolveCorpora(config.corpora);
278
280
  const people = resolvePeople(config.people);
281
+ const peoplePrimer = resolvePeoplePrimer(config.peoplePrimer, corpora, people.enabled);
279
282
  let evidenceReview = { enabled: false, corpora: [] };
280
283
  if (config.evidenceReview !== undefined) {
281
284
  const value = config.evidenceReview;
@@ -346,7 +349,7 @@ export function resolveConfig(value) {
346
349
  if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
347
350
  throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
348
351
  }
349
- return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, skillWhisperer,
352
+ return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, peoplePrimer, skillWhisperer,
350
353
  qualityAudit: resolveQualityAudit(config.qualityAudit, corpora),
351
354
  evidenceReview,
352
355
  responseAudit: resolveResponseAudit(config.responseAudit, corpora),
@@ -13,6 +13,10 @@ export declare function reviewIndexedClaim(params: {
13
13
  apiKey: string;
14
14
  timeoutMs: number;
15
15
  signal: AbortSignal;
16
+ personBackground?: {
17
+ name: string;
18
+ agentName: string;
19
+ };
16
20
  read?: <T>(run: () => T) => Promise<T>;
17
21
  }): Promise<{
18
22
  status: "unavailable";
@@ -29,6 +33,11 @@ export declare function reviewIndexedClaim(params: {
29
33
  }[];
30
34
  policy: string;
31
35
  scope: string;
36
+ needsReview: boolean;
37
+ background?: {
38
+ backgroundOnly: number;
39
+ explicitSupport: number;
40
+ } | undefined;
32
41
  verdict: "supports" | "contradicts" | "insufficient_evidence";
33
42
  confidence: number;
34
43
  probabilities: {
@@ -36,6 +45,5 @@ export declare function reviewIndexedClaim(params: {
36
45
  contradicts: number;
37
46
  insufficient_evidence: number;
38
47
  };
39
- needsReview: boolean;
40
48
  status: "ok";
41
49
  }>;
@@ -45,6 +45,7 @@ export async function reviewIndexedClaim(params) {
45
45
  }
46
46
  return { status: "ok", ...judgment,
47
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." };
48
+ policy: params.personBackground ? "jev-1.13.0:person-background-v2" : "jev-1.13.0:claim-v1",
49
+ 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
  });
50
51
  }
@@ -85,6 +85,11 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
85
85
  }[];
86
86
  policy: string;
87
87
  scope: string;
88
+ needsReview: boolean;
89
+ background?: {
90
+ backgroundOnly: number;
91
+ explicitSupport: number;
92
+ } | undefined;
88
93
  verdict: "supports" | "contradicts" | "insufficient_evidence";
89
94
  confidence: number;
90
95
  probabilities: {
@@ -92,7 +97,6 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
92
97
  contradicts: number;
93
98
  insufficient_evidence: number;
94
99
  };
95
- needsReview: boolean;
96
100
  status: "ok";
97
101
  }>;
98
102
  reviewCluster(params: Omit<Parameters<typeof reviewClusterIngestion>[0], "db" | "sources" | "read"> & {
@@ -0,0 +1,3 @@
1
+ /** A recognition aid, never a behavioral profile. Existing records remain readable. */
2
+ export declare const PEOPLE_BACKGROUND_MAX_WORDS = 70;
3
+ export declare function backgroundWordCount(text: string): number;
@@ -0,0 +1,5 @@
1
+ /** A recognition aid, never a behavioral profile. Existing records remain readable. */
2
+ export const PEOPLE_BACKGROUND_MAX_WORDS = 70;
3
+ export function backgroundWordCount(text) {
4
+ return text.trim().split(/\s+/u).filter(Boolean).length;
5
+ }
@@ -0,0 +1,41 @@
1
+ import type { UnblockMemoryConfig } from "./config.js";
2
+ import type { Person, PersonDossier } from "./people-store.js";
3
+ import type { QmdMemoryRuntime } from "./runtime.js";
4
+ import type { getContext } from "./tool-context.js";
5
+ /** Review the injected blurb using only exact indexed references already on its claims. */
6
+ export declare function reviewPersonDossier(params: {
7
+ config: UnblockMemoryConfig;
8
+ runtime: QmdMemoryRuntime;
9
+ active: NonNullable<ReturnType<typeof getContext>>;
10
+ person: Person;
11
+ dossier: PersonDossier;
12
+ agentName?: string;
13
+ signal?: AbortSignal;
14
+ }): Promise<{
15
+ evidence: {
16
+ path: string;
17
+ from: number;
18
+ lines: number;
19
+ documentHash: string;
20
+ excerptHash: string;
21
+ }[];
22
+ policy: string;
23
+ scope: string;
24
+ needsReview: boolean;
25
+ background?: {
26
+ backgroundOnly: number;
27
+ explicitSupport: number;
28
+ } | undefined;
29
+ verdict: "supports" | "contradicts" | "insufficient_evidence";
30
+ confidence: number;
31
+ probabilities: {
32
+ supports: number;
33
+ contradicts: number;
34
+ insufficient_evidence: number;
35
+ };
36
+ status: "ok";
37
+ } | {
38
+ status: "unavailable";
39
+ needsReview: boolean;
40
+ reason: string;
41
+ }>;
@@ -0,0 +1,47 @@
1
+ import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
2
+ import { abortable } from "./abortable.js";
3
+ import { resolveTypeSafeApiKey } from "./typesafe.js";
4
+ /** Review the injected blurb using only exact indexed references already on its claims. */
5
+ export async function reviewPersonDossier(params) {
6
+ const unavailable = (reason) => ({ status: "unavailable", needsReview: true, reason });
7
+ const { config, runtime, active, person, dossier } = params;
8
+ if (!config.peoplePrimer.enabled || !config.typesafe.enabled) {
9
+ return unavailable("TypeSafe background review is disabled; verify manually before saving");
10
+ }
11
+ const citations = new Map();
12
+ for (const section of dossier.sections)
13
+ for (const claim of section.claims)
14
+ for (const ref of claim.evidence) {
15
+ const match = /^(qmd:\/\/[^#]+)#L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(ref.locator);
16
+ if (!match)
17
+ return unavailable("Evidence needs exact qmd://path#Lstart-Lend references or explicit manual verification");
18
+ const from = Number(match[2]), end = Number(match[3] ?? match[2]);
19
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(end) || end < from || end - from >= 120) {
20
+ return unavailable("Evidence ranges must contain 1–120 lines");
21
+ }
22
+ const citation = { path: match[1], from, lines: end - from + 1 };
23
+ citations.set(JSON.stringify(citation), citation);
24
+ }
25
+ if (!citations.size || citations.size > 3)
26
+ return unavailable("Choose 1–3 distinct indexed evidence ranges or verify manually");
27
+ const deadline = AbortSignal.timeout(120_000);
28
+ const signal = params.signal ? AbortSignal.any([params.signal, deadline]) : deadline;
29
+ try {
30
+ signal.throwIfAborted();
31
+ const apiKey = await abortable(resolveTypeSafeApiKey(config.typesafe), signal);
32
+ if (!apiKey)
33
+ return unavailable("TypeSafe API key not configured; verify manually before saving");
34
+ const { manager } = await abortable(runtime.getMemorySearchManager(active), signal);
35
+ if (!manager)
36
+ return unavailable("Memory index unavailable; verify manually before saving");
37
+ return await abortable(manager.reviewClaim({
38
+ claim: dossier.blurb, citations: [...citations.values()],
39
+ personBackground: { name: person.preferredName ?? person.displayName,
40
+ agentName: resolveAgentIdentity(active.cfg, active.agentId)?.name?.trim() || params.agentName?.trim() || "the assistant" },
41
+ corpora: config.peoplePrimer.corpora, apiKey, timeoutMs: config.peoplePrimer.timeoutMs, signal,
42
+ }), signal);
43
+ }
44
+ catch {
45
+ return unavailable("Background review failed or was cancelled; no dossier written");
46
+ }
47
+ }
@@ -0,0 +1,13 @@
1
+ import { Type, type Static } from "typebox";
2
+ import type { CorpusConfig } from "./config.js";
3
+ export declare const peoplePrimerSchema: Type.TObject<{
4
+ enabled: Type.TBoolean;
5
+ corpora: Type.TArray<Type.TString>;
6
+ hitsPerQuestion: Type.TInteger;
7
+ minScore: Type.TNumber;
8
+ minUsefulness: Type.TNumber;
9
+ maxEvidencePerQuestion: Type.TInteger;
10
+ timeoutMs: Type.TInteger;
11
+ }>;
12
+ export type PeoplePrimerConfig = Static<typeof peoplePrimerSchema>;
13
+ export declare function resolvePeoplePrimer(value: unknown, corpora: readonly CorpusConfig[], peopleEnabled: boolean): PeoplePrimerConfig;
@@ -0,0 +1,33 @@
1
+ import { Type } from "typebox";
2
+ import { Value } from "typebox/value";
3
+ export const peoplePrimerSchema = Type.Object({
4
+ enabled: Type.Boolean({ default: false }),
5
+ corpora: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
6
+ hitsPerQuestion: Type.Integer({ minimum: 1, maximum: 40, default: 30 }),
7
+ minScore: Type.Number({ minimum: 0, maximum: 1, default: 0.35 }),
8
+ minUsefulness: Type.Number({ minimum: 0.5, maximum: 1, default: 0.8 }),
9
+ maxEvidencePerQuestion: Type.Integer({ minimum: 1, maximum: 10, default: 3 }),
10
+ timeoutMs: Type.Integer({ minimum: 1, maximum: 60000, default: 30000 }),
11
+ }, { additionalProperties: false });
12
+ export function resolvePeoplePrimer(value, corpora, peopleEnabled) {
13
+ if (value === undefined)
14
+ return { enabled: false, corpora: [], hitsPerQuestion: 30,
15
+ minScore: 0.35, minUsefulness: 0.8, maxEvidencePerQuestion: 3, timeoutMs: 30000 };
16
+ let config;
17
+ try {
18
+ const withDefaults = Value.Default(peoplePrimerSchema, value);
19
+ if (!Value.Check(peoplePrimerSchema, withDefaults))
20
+ throw new Error("Invalid config");
21
+ config = withDefaults;
22
+ }
23
+ catch {
24
+ throw new Error("Invalid peoplePrimer configuration");
25
+ }
26
+ if (config.corpora.some(name => !corpora.some(c => c.name === name && c.kind !== "skills"))) {
27
+ throw new Error("peoplePrimer.corpora must list configured non-skill corpora");
28
+ }
29
+ if (config.enabled && (!peopleEnabled || !config.corpora.length)) {
30
+ throw new Error("peoplePrimer requires people.enabled and explicit approved corpora");
31
+ }
32
+ return { ...config, corpora: [...new Set(config.corpora)] };
33
+ }
@@ -0,0 +1,5 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { UnblockMemoryConfig } from "./config.js";
3
+ import type { PeopleStores } from "./people-store.js";
4
+ import type { QmdMemoryRuntime } from "./runtime.js";
5
+ export declare function registerPeoplePrimerTool(api: OpenClawPluginApi, runtime: QmdMemoryRuntime, stores: PeopleStores, config: UnblockMemoryConfig): void;
@@ -0,0 +1,78 @@
1
+ import { Type } from "typebox";
2
+ import { Value } from "typebox/value";
3
+ import { jsonResult, resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
4
+ import { getContext } from "./tool-context.js";
5
+ import { resolveTypeSafeApiKey } from "./typesafe.js";
6
+ import { primePersonDossier } from "./people-primer.js";
7
+ import { abortable } from "./abortable.js";
8
+ import { backgroundWordCount, PEOPLE_BACKGROUND_MAX_WORDS } from "./people-background.js";
9
+ const parameters = Type.Object({
10
+ personId: Type.String({ pattern: "\\S", maxLength: 1000 }),
11
+ agentName: Type.Optional(Type.String({ pattern: "\\S", maxLength: 100,
12
+ description: "Your human-facing name, e.g. Bill, when no agent identity name is configured. Never a person's name guessed from search results." })),
13
+ draft: Type.Optional(Type.Object({
14
+ blurb: Type.String({ pattern: "\\S", maxLength: 1200, description: "Final background-only snippet, at most 70 words. Supplying a draft reviews it instead of running searches." }),
15
+ citations: Type.Array(Type.Object({
16
+ path: Type.String({ pattern: "^qmd://", maxLength: 2000 }),
17
+ from: Type.Integer({ minimum: 1 }), lines: Type.Integer({ minimum: 1, maximum: 120 }),
18
+ }, { additionalProperties: false }), { minItems: 1, maxItems: 3 }),
19
+ }, { additionalProperties: false })),
20
+ }, { additionalProperties: false });
21
+ export function registerPeoplePrimerTool(api, runtime, stores, config) {
22
+ const running = new Set();
23
+ api.registerTool(ctx => {
24
+ const active = getContext(ctx);
25
+ if (!active)
26
+ return null;
27
+ return {
28
+ name: "memory_people_prime", label: "Prime Person Dossier",
29
+ description: "Prepare background-only evidence using three identity/organization/agent-relationship questions. Supply draft to review a final <=70-word snippet against indexed citations instead of searching. Requires peoplePrimer opt-in and approved corpora. Sends identity, approved excerpts and optional draft to TypeSafe, never the existing dossier. Advisory; never writes dossiers.",
30
+ parameters,
31
+ async execute(_id, params, signal) {
32
+ const { personId, agentName: suppliedName, draft } = Value.Parse(parameters, params);
33
+ if (!config.people.enabled || !config.peoplePrimer.enabled || !config.typesafe.enabled)
34
+ return jsonResult({ status: "disabled" });
35
+ if (draft && backgroundWordCount(draft.blurb) > PEOPLE_BACKGROUND_MAX_WORDS) {
36
+ return jsonResult({ status: "invalid", needsReview: true, reason: "Background snippet must not exceed 70 words" });
37
+ }
38
+ const key = JSON.stringify([active.agentId, personId]);
39
+ if (running.has(key))
40
+ return jsonResult({ status: "busy" });
41
+ running.add(key);
42
+ const deadline = AbortSignal.timeout(120_000);
43
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
44
+ try {
45
+ combined.throwIfAborted();
46
+ const apiKey = await abortable(resolveTypeSafeApiKey(config.typesafe), combined);
47
+ combined.throwIfAborted();
48
+ if (!apiKey)
49
+ return jsonResult({ status: "unavailable", reason: "TypeSafe API key not configured" });
50
+ const store = stores.get(active.agentId);
51
+ const person = store.getPerson(personId);
52
+ if (!person || person.status !== "active")
53
+ return jsonResult({ status: "not_found" });
54
+ const identities = store.listIdentities(personId);
55
+ if (identities.length && identities.every(i => i.isBot === true || i.isDeactivated))
56
+ return jsonResult({ status: "unavailable", reason: "No active human identity" });
57
+ const { manager } = await abortable(runtime.getMemorySearchManager(active), combined);
58
+ combined.throwIfAborted();
59
+ if (!manager)
60
+ return jsonResult({ status: "unavailable", reason: "Memory unavailable" });
61
+ const agentName = resolveAgentIdentity(active.cfg, active.agentId)?.name?.trim() || suppliedName?.trim() || "the assistant";
62
+ if (draft)
63
+ return jsonResult(await manager.reviewClaim({ claim: draft.blurb, citations: draft.citations,
64
+ personBackground: { name: person.preferredName ?? person.displayName, agentName },
65
+ corpora: config.peoplePrimer.corpora, apiKey, timeoutMs: config.peoplePrimer.timeoutMs, signal: combined }));
66
+ return jsonResult(await primePersonDossier({ personId, agentName, store, config: config.peoplePrimer, apiKey, signal: combined,
67
+ search: (query, options) => manager.search(query, { ...options, requestContext: active.requestContext }) }));
68
+ }
69
+ catch {
70
+ return jsonResult({ status: "unavailable", needsReview: true, reason: "Primer failed or was cancelled; no dossier written" });
71
+ }
72
+ finally {
73
+ running.delete(key);
74
+ }
75
+ },
76
+ };
77
+ }, { names: ["memory_people_prime"] });
78
+ }
@@ -0,0 +1,94 @@
1
+ import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
2
+ import type { PeoplePrimerConfig } from "./people-primer-config.js";
3
+ import type { PeopleStore } from "./people-store.js";
4
+ /** Evidence preparation only. Search is local; approved excerpts go to TypeSafe.
5
+ * No identity inference, generated claims, dossier writes or automatic injection. */
6
+ export declare function primePersonDossier(params: {
7
+ personId: string;
8
+ agentName: string;
9
+ store: Pick<PeopleStore, "getPerson" | "listIdentities" | "getPrimerJudgment" | "cachePrimerJudgment">;
10
+ search: (query: string, options: CorpusSearchOptions) => Promise<CorpusMemorySearchResult[]>;
11
+ config: PeoplePrimerConfig;
12
+ apiKey: string;
13
+ signal: AbortSignal;
14
+ }): Promise<{
15
+ status: "disabled";
16
+ reason?: undefined;
17
+ personId?: undefined;
18
+ name?: undefined;
19
+ version?: undefined;
20
+ advisory?: undefined;
21
+ stats?: undefined;
22
+ questions?: undefined;
23
+ evidence?: undefined;
24
+ } | {
25
+ status: "not_found";
26
+ reason?: undefined;
27
+ personId?: undefined;
28
+ name?: undefined;
29
+ version?: undefined;
30
+ advisory?: undefined;
31
+ stats?: undefined;
32
+ questions?: undefined;
33
+ evidence?: undefined;
34
+ } | {
35
+ status: "unavailable";
36
+ reason: string;
37
+ personId?: undefined;
38
+ name?: undefined;
39
+ version?: undefined;
40
+ advisory?: undefined;
41
+ stats?: undefined;
42
+ questions?: undefined;
43
+ evidence?: undefined;
44
+ } | {
45
+ status: "ok" | "partial";
46
+ personId: string;
47
+ name: string;
48
+ version: string;
49
+ advisory: string;
50
+ stats: {
51
+ uniqueCandidates: number;
52
+ requests: number;
53
+ cached: number;
54
+ failed: number;
55
+ elapsedMs: number;
56
+ };
57
+ questions: {
58
+ graded: number;
59
+ qualifying: number;
60
+ coverage: "unknown" | "evidence_found" | "uncertain";
61
+ evidence: {
62
+ evidenceId: string;
63
+ vectorScore: number;
64
+ usefulness: number;
65
+ aboutPerson: number;
66
+ explicitBackground: number;
67
+ enduring: number;
68
+ recognition: number;
69
+ }[];
70
+ review: {
71
+ evidenceId: string;
72
+ vectorScore: number;
73
+ usefulness: number;
74
+ aboutPerson: number;
75
+ explicitBackground: number;
76
+ enduring: number;
77
+ recognition: number;
78
+ }[];
79
+ retrieved?: number | undefined;
80
+ eligible?: number | undefined;
81
+ oversized?: number | undefined;
82
+ id: string;
83
+ question: string;
84
+ }[];
85
+ evidence: {
86
+ id: string;
87
+ path: string;
88
+ from: number;
89
+ lines: number;
90
+ excerpt: string;
91
+ corpus: string;
92
+ }[];
93
+ reason?: undefined;
94
+ }>;