@oscharko-dev/keiko-memory-capture 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/_constants.d.ts +2 -0
  3. package/dist/_constants.d.ts.map +1 -0
  4. package/dist/_constants.js +6 -0
  5. package/dist/_envelopes.d.ts +30 -0
  6. package/dist/_envelopes.d.ts.map +1 -0
  7. package/dist/_envelopes.js +67 -0
  8. package/dist/capture-safety.d.ts +4 -0
  9. package/dist/capture-safety.d.ts.map +1 -0
  10. package/dist/capture-safety.js +17 -0
  11. package/dist/capture.d.ts +4 -0
  12. package/dist/capture.d.ts.map +1 -0
  13. package/dist/capture.js +66 -0
  14. package/dist/errors.d.ts +6 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +18 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +14 -0
  20. package/dist/intent-ambient.d.ts +3 -0
  21. package/dist/intent-ambient.d.ts.map +1 -0
  22. package/dist/intent-ambient.js +112 -0
  23. package/dist/intent-explicit.d.ts +6 -0
  24. package/dist/intent-explicit.d.ts.map +1 -0
  25. package/dist/intent-explicit.js +204 -0
  26. package/dist/intent-workflow.d.ts +3 -0
  27. package/dist/intent-workflow.d.ts.map +1 -0
  28. package/dist/intent-workflow.js +69 -0
  29. package/dist/policy.d.ts +12 -0
  30. package/dist/policy.d.ts.map +1 -0
  31. package/dist/policy.js +43 -0
  32. package/dist/salience.d.ts +13 -0
  33. package/dist/salience.d.ts.map +1 -0
  34. package/dist/salience.js +268 -0
  35. package/dist/scope-inference.d.ts +9 -0
  36. package/dist/scope-inference.d.ts.map +1 -0
  37. package/dist/scope-inference.js +45 -0
  38. package/dist/secret-patterns.d.ts +3 -0
  39. package/dist/secret-patterns.d.ts.map +1 -0
  40. package/dist/secret-patterns.js +124 -0
  41. package/dist/types.d.ts +61 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +6 -0
  44. package/dist/version.d.ts +2 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/dist/version.js +1 -0
  47. package/package.json +31 -0
@@ -0,0 +1,204 @@
1
+ // Explicit-intent extractors for keiko-memory-capture (Epic #204 child #207).
2
+ //
3
+ // Each `tryExtract*` is a pure function that returns either a CaptureOutcome (one of: candidate,
4
+ // update, forget, supersession, rejected) or `null` for "this text is not this intent kind".
5
+ // The top-level capture function in capture.ts probes them in a fixed order — first non-null
6
+ // wins. Regex patterns are intentionally narrow: ambiguous matches return null so the next
7
+ // extractor (or the no-intent fallthrough) gets a chance.
8
+ //
9
+ // Pure: no clock, no randomness, no IO. All time and IDs come from CaptureContext.
10
+ import { buildForget, buildProposal, buildUpdate } from "./_envelopes.js";
11
+ import { applyPolicy } from "./policy.js";
12
+ import { inferScopeFromContext } from "./scope-inference.js";
13
+ import { scanForSecrets } from "./secret-patterns.js";
14
+ // ─── Regex catalogue (narrow, anchored, single-quantifier) ────────────────────
15
+ // All patterns are anchored and use a single open or bounded quantifier. The phrase trailing
16
+ // the imperative is captured greedily ON A SINGLE LINE (no `s` flag) so embedded newlines
17
+ // terminate the match — this prevents a multi-line paste from being absorbed into one body.
18
+ const REMEMBER_RE = /^\s*remember(?:\s+that)?\s+(.+?)\s*$/i;
19
+ const REMEMBER_ABOUT_RE = /^\s*remember\s+about\s+(?:this\s+(?:project|workspace)[:,\s]+)?(.+?)\s*$/i;
20
+ const FORGET_RE = /^\s*forget(?:\s+about)?\s+(.+?)\s*$/i;
21
+ const UPDATE_RE = /^\s*update\s+(?:memory|the\s+memory)\s+about\s+(.+?)\s+(?:to\s+be|with|:)\s+(.+?)\s*$/i;
22
+ const ACTUALLY_RE = /^\s*actually,?\s+(.+?)\s*$/i;
23
+ const CORRECTION_LABEL_RE = /^\s*correction:\s*(.+?)\s*$/i;
24
+ const THATS_WRONG_RE = /^\s*that(?:'s|\s+is)\s+wrong[,.]?\s+(.+?)\s+(?:is|are|should\s+be)\s+(.+?)\s*$/i;
25
+ // Helper: secret scan + reject the body if it fires. Length enforcement happens in capture.ts
26
+ // preflight before the explicit extractors run.
27
+ function rejectIfUnsafe(body, policy) {
28
+ const reason = scanForSecrets(body, policy.customerIdentifierMatchers ?? []);
29
+ if (reason !== null) {
30
+ return { kind: "rejected", reason };
31
+ }
32
+ return null;
33
+ }
34
+ function scopeOrReject(context, policy) {
35
+ const scope = inferScopeFromContext(context, {
36
+ ...(policy.scopeKind !== undefined && { scopeKind: policy.scopeKind }),
37
+ ...(policy.allowGlobalScope !== undefined && { allowGlobalScope: policy.allowGlobalScope }),
38
+ });
39
+ if (scope === null) {
40
+ return { ok: false, outcome: { kind: "rejected", reason: "scope-not-resolvable" } };
41
+ }
42
+ return { ok: true, scope };
43
+ }
44
+ // Helper: pick the first resolver-match by id with a defined-narrowed type. Returns the typed
45
+ // id or null when the array is empty or its first slot is somehow undefined (defensive narrow
46
+ // for noUncheckedIndexedAccess; the resolver contract is `readonly MemoryId[]`, not sparse).
47
+ function firstResolvedId(matches) {
48
+ const head = matches[0];
49
+ return head ?? null;
50
+ }
51
+ function resolveTarget(policy, target, scope) {
52
+ const resolver = policy.resolver;
53
+ if (resolver === undefined) {
54
+ return { kind: "none" };
55
+ }
56
+ const matches = resolver(target, scope);
57
+ if (matches.length === 0) {
58
+ return { kind: "none" };
59
+ }
60
+ if (matches.length > 1) {
61
+ return { kind: "ambiguous" };
62
+ }
63
+ const head = firstResolvedId(matches);
64
+ return head === null ? { kind: "none" } : { kind: "unique", memoryId: head };
65
+ }
66
+ // ─── tryExtractRemember ──────────────────────────────────────────────────────
67
+ // "remember about this project: X" → project scope hint. "remember that X" / "remember X" →
68
+ // implicit scope from context. Emits a preference-type proposal — explicit user instructions
69
+ // are the canonical preference source per #205 source-kind taxonomy.
70
+ export function tryExtractRemember(text, context, policy = {}) {
71
+ const aboutMatch = REMEMBER_ABOUT_RE.exec(text);
72
+ const plainMatch = aboutMatch === null ? REMEMBER_RE.exec(text) : null;
73
+ const body = aboutMatch?.[1] ?? plainMatch?.[1];
74
+ if (body === undefined) {
75
+ return null;
76
+ }
77
+ const rejection = rejectIfUnsafe(body, policy);
78
+ if (rejection !== null) {
79
+ return rejection;
80
+ }
81
+ const scopeResolution = scopeOrReject(context, policy);
82
+ if (!scopeResolution.ok) {
83
+ return scopeResolution.outcome;
84
+ }
85
+ const decision = applyPolicy(body, {
86
+ ...(policy.defaultSensitivity !== undefined && {
87
+ defaultSensitivity: policy.defaultSensitivity,
88
+ }),
89
+ });
90
+ const proposal = buildProposal({
91
+ context,
92
+ scope: scopeResolution.scope,
93
+ body,
94
+ type: "preference",
95
+ sensitivity: decision.sensitivity,
96
+ sourceKind: "explicit-user-instruction",
97
+ }, 1.0);
98
+ return { kind: "candidate", proposal, requiresApproval: decision.requiresApproval };
99
+ }
100
+ // ─── tryExtractForget ────────────────────────────────────────────────────────
101
+ export function tryExtractForget(text, context, policy = {}) {
102
+ const match = FORGET_RE.exec(text);
103
+ if (match === null) {
104
+ return null;
105
+ }
106
+ const target = match[1];
107
+ if (target === undefined) {
108
+ return null;
109
+ }
110
+ const scopeResolution = scopeOrReject(context, policy);
111
+ if (!scopeResolution.ok) {
112
+ return scopeResolution.outcome;
113
+ }
114
+ const resolved = resolveTarget(policy, target, scopeResolution.scope);
115
+ if (resolved.kind === "none") {
116
+ return null;
117
+ }
118
+ if (resolved.kind === "ambiguous") {
119
+ return { kind: "rejected", reason: "ambiguous-forget" };
120
+ }
121
+ const operation = buildForget({ context, memoryId: resolved.memoryId, reason: target });
122
+ return { kind: "forget", operation, requiresConfirmation: true };
123
+ }
124
+ // ─── tryExtractUpdate ────────────────────────────────────────────────────────
125
+ export function tryExtractUpdate(text, context, policy = {}) {
126
+ const match = UPDATE_RE.exec(text);
127
+ if (match === null) {
128
+ return null;
129
+ }
130
+ const target = match[1];
131
+ const newValue = match[2];
132
+ if (target === undefined || newValue === undefined) {
133
+ return null;
134
+ }
135
+ const rejection = rejectIfUnsafe(newValue, policy);
136
+ if (rejection !== null) {
137
+ return rejection;
138
+ }
139
+ const scopeResolution = scopeOrReject(context, policy);
140
+ if (!scopeResolution.ok) {
141
+ return scopeResolution.outcome;
142
+ }
143
+ const resolved = resolveTarget(policy, target, scopeResolution.scope);
144
+ if (resolved.kind === "none") {
145
+ return null;
146
+ }
147
+ if (resolved.kind === "ambiguous") {
148
+ return { kind: "rejected", reason: "ambiguous-update" };
149
+ }
150
+ const operation = buildUpdate({
151
+ context,
152
+ memoryId: resolved.memoryId,
153
+ bodyPatch: newValue,
154
+ });
155
+ return { kind: "update", operation };
156
+ }
157
+ // ─── tryExtractCorrection ─────────────────────────────────────────────────────
158
+ // Emits a correction-type proposal. We do NOT emit a MemorySupersession envelope here:
159
+ // supersession requires knowing the OLD memory id, which requires a resolver lookup analogous
160
+ // to update/forget. A correction proposal is the lowest-friction default — the acceptance
161
+ // layer (#212) can elevate it to a supersession when it knows the prior fact.
162
+ function extractCorrectionBody(text) {
163
+ const actuallyMatch = ACTUALLY_RE.exec(text);
164
+ if (actuallyMatch?.[1] !== undefined) {
165
+ return actuallyMatch[1];
166
+ }
167
+ const labelMatch = CORRECTION_LABEL_RE.exec(text);
168
+ if (labelMatch?.[1] !== undefined) {
169
+ return labelMatch[1];
170
+ }
171
+ const wrongMatch = THATS_WRONG_RE.exec(text);
172
+ if (wrongMatch?.[1] !== undefined && wrongMatch[2] !== undefined) {
173
+ return `${wrongMatch[1]} is ${wrongMatch[2]}`;
174
+ }
175
+ return null;
176
+ }
177
+ export function tryExtractCorrection(text, context, policy = {}) {
178
+ const body = extractCorrectionBody(text);
179
+ if (body === null) {
180
+ return null;
181
+ }
182
+ const rejection = rejectIfUnsafe(body, policy);
183
+ if (rejection !== null) {
184
+ return rejection;
185
+ }
186
+ const scopeResolution = scopeOrReject(context, policy);
187
+ if (!scopeResolution.ok) {
188
+ return scopeResolution.outcome;
189
+ }
190
+ const decision = applyPolicy(body, {
191
+ ...(policy.defaultSensitivity !== undefined && {
192
+ defaultSensitivity: policy.defaultSensitivity,
193
+ }),
194
+ });
195
+ const proposal = buildProposal({
196
+ context,
197
+ scope: scopeResolution.scope,
198
+ body,
199
+ type: "correction",
200
+ sensitivity: decision.sensitivity,
201
+ sourceKind: "accepted-correction",
202
+ }, 1.0);
203
+ return { kind: "candidate", proposal, requiresApproval: decision.requiresApproval };
204
+ }
@@ -0,0 +1,3 @@
1
+ import type { CaptureContext, CaptureOutcome, CapturePolicyOptions, WorkflowOutcomeInput } from "./types.js";
2
+ export declare function extractWorkflowOutcomeCandidates(outcome: WorkflowOutcomeInput, context: CaptureContext, policy?: CapturePolicyOptions): readonly CaptureOutcome[];
3
+ //# sourceMappingURL=intent-workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intent-workflow.d.ts","sourceRoot":"","sources":["../src/intent-workflow.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EACV,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,YAAY,CAAC;AA+DpB,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,oBAAoB,EAC7B,OAAO,EAAE,cAAc,EACvB,MAAM,GAAE,oBAAyB,GAChC,SAAS,cAAc,EAAE,CAU3B"}
@@ -0,0 +1,69 @@
1
+ // Workflow-outcome candidate extractor for keiko-memory-capture (Epic #204 child #207).
2
+ //
3
+ // Workflow outcomes are the second canonical capture trigger: a workflow run that completed
4
+ // (success) or that the user reviewed and corrected (corrected) produces a candidate memory.
5
+ // Failed runs are intentionally NOT a learning surface — they would teach the system from
6
+ // incomplete information, which inverts the governance contract.
7
+ //
8
+ // Output sensitivity follows the policy classifier on the structured report. Workflow scope is
9
+ // preferred when the workflow definition id is available (so the memory rides with the workflow,
10
+ // not with a single conversation); we fall back to user scope when no workflow id is present.
11
+ import { buildProposal } from "./_envelopes.js";
12
+ import { applyPolicy } from "./policy.js";
13
+ import { inferScopeFromContext } from "./scope-inference.js";
14
+ import { scanForSecrets } from "./secret-patterns.js";
15
+ // Confidence for workflow-derived candidates. Lower than 1.0 because the system inferred the
16
+ // learning rather than the user stating it explicitly. The retrieval layer (#210) is expected
17
+ // to weight by provenance.confidence; a lower value means a workflow-derived memory ranks below
18
+ // an equivalent explicit-user-instruction memory.
19
+ const WORKFLOW_CONFIDENCE = 0.6;
20
+ // Scope inference for workflow extraction: prefer workflow scope, fall back to user.
21
+ function resolveWorkflowScope(context, policy) {
22
+ const hint = policy.scopeKind ?? (context.workflowDefinitionId !== undefined ? "workflow" : "user");
23
+ return inferScopeFromContext(context, {
24
+ scopeKind: hint,
25
+ ...(policy.allowGlobalScope !== undefined && { allowGlobalScope: policy.allowGlobalScope }),
26
+ });
27
+ }
28
+ function emitWorkflowCandidate(body, type, runId, capturedAt, context, policy) {
29
+ const reason = scanForSecrets(body, policy.customerIdentifierMatchers ?? []);
30
+ if (reason !== null) {
31
+ return { kind: "rejected", reason };
32
+ }
33
+ const scope = resolveWorkflowScope(context, policy);
34
+ if (scope === null) {
35
+ return { kind: "rejected", reason: "scope-not-resolvable" };
36
+ }
37
+ const decision = applyPolicy(body, {
38
+ ...(policy.defaultSensitivity !== undefined && {
39
+ defaultSensitivity: policy.defaultSensitivity,
40
+ }),
41
+ });
42
+ const sourceKind = type === "correction" ? "accepted-correction" : "workflow-outcome";
43
+ const proposal = buildProposal({
44
+ context,
45
+ scope,
46
+ body,
47
+ type,
48
+ sensitivity: decision.sensitivity,
49
+ sourceKind,
50
+ capturedAt,
51
+ validFrom: capturedAt,
52
+ sourceWorkflowRunId: runId,
53
+ }, WORKFLOW_CONFIDENCE);
54
+ return { kind: "candidate", proposal, requiresApproval: decision.requiresApproval };
55
+ }
56
+ // Pure: no IO, no clock; the structured report and runId come from `outcome`, the clock and id
57
+ // factory come from `context`. `failed` outcomes return `[]` deliberately so a caller iterating
58
+ // many runs sees nothing rather than a placeholder.
59
+ export function extractWorkflowOutcomeCandidates(outcome, context, policy = {}) {
60
+ if (outcome.outcomeKind === "failed") {
61
+ return [];
62
+ }
63
+ const body = outcome.structuredReport.trim();
64
+ if (body.length === 0) {
65
+ return [{ kind: "rejected", reason: "empty-content" }];
66
+ }
67
+ const type = outcome.outcomeKind === "corrected" ? "correction" : "semantic-fact";
68
+ return [emitWorkflowCandidate(body, type, outcome.runId, outcome.capturedAt, context, policy)];
69
+ }
@@ -0,0 +1,12 @@
1
+ import type { MemorySensitivity } from "@oscharko-dev/keiko-contracts/memory";
2
+ interface ApplyPolicyInput {
3
+ readonly defaultSensitivity?: MemorySensitivity;
4
+ }
5
+ export interface PolicyDecision {
6
+ readonly sensitivity: MemorySensitivity;
7
+ readonly requiresApproval: boolean;
8
+ }
9
+ export declare function classifySensitivity(body: string, defaultSensitivity?: MemorySensitivity): MemorySensitivity;
10
+ export declare function applyPolicy(body: string, options?: ApplyPolicyInput): PolicyDecision;
11
+ export {};
12
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sCAAsC,CAAC;AAU9E,UAAU,gBAAgB;IACxB,QAAQ,CAAC,kBAAkB,CAAC,EAAE,iBAAiB,CAAC;CACjD;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,WAAW,EAAE,iBAAiB,CAAC;IACxC,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACpC;AAMD,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,kBAAkB,GAAE,iBAA4B,GAC/C,iBAAiB,CAKnB;AAKD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,cAAc,CAQxF"}
package/dist/policy.js ADDED
@@ -0,0 +1,43 @@
1
+ // Sensitivity classification + per-call policy decisions for keiko-memory-capture.
2
+ //
3
+ // Sensitivity is a SOURCE-side label (lives on MemoryProvenance per ADR-0019 contracts) — once
4
+ // assigned, the audit and retention layers honour it without re-classifying. This module's job
5
+ // is therefore two-fold:
6
+ //
7
+ // 1. Pick the right initial sensitivity for a body based on heuristic signals
8
+ // (contact data, explicit markers).
9
+ // 2. Decide whether the resulting candidate must be gated behind explicit user approval before
10
+ // it can land in storage. ANY non-public sensitivity flips the approval flag — `confidential`
11
+ // requires a confirmation prompt; `restricted` is rejected upstream (see capture.ts).
12
+ //
13
+ // The heuristics are deliberately narrow (high precision over high recall) so the layer does not
14
+ // reject benign user memories. The wider secret-rejection net lives in secret-patterns.ts; this
15
+ // module's classifier covers PII-shaped content the secret scanner doesn't catch (email,
16
+ // phone-shape numbers, marker words like "confidential").
17
+ // Linear single-character-class patterns; no nesting. The phone-shape pattern accepts an optional
18
+ // leading +, then 7–14 digits with separators (space, dash, dot) so the total digit run isn't
19
+ // long enough to trip the secret scanner's PAN detector. The email pattern is a conservative
20
+ // local@host shape — anything resembling a routable address triggers `confidential`.
21
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
22
+ const PHONE_RE = /\+?\d[\d\s.-]{6,14}\d/;
23
+ const CONFIDENTIAL_MARKER_RE = /\b(confidential|internal\s+only|internal[:\s]|private[:\s])/i;
24
+ // Returns the sensitivity class for `body`. `defaultSensitivity` is the floor for benign text —
25
+ // it never DEMOTES a body that triggered a marker. `"restricted"` is intentionally NOT a valid
26
+ // default: a deployment that wants every capture to require approval should pass
27
+ // `"confidential"`. `applyPolicy` enforces this with a thrown CaptureRejection-style error.
28
+ export function classifySensitivity(body, defaultSensitivity = "public") {
29
+ if (CONFIDENTIAL_MARKER_RE.test(body) || EMAIL_RE.test(body) || PHONE_RE.test(body)) {
30
+ return "confidential";
31
+ }
32
+ return defaultSensitivity;
33
+ }
34
+ // Returns the policy decision for `body`: sensitivity + whether downstream must show an approval
35
+ // prompt. `restricted` is reserved for caller-side rejection (see capture.ts) — passing it as
36
+ // the default would silently swallow the rejection path here, so we throw a programmer-error.
37
+ export function applyPolicy(body, options = {}) {
38
+ if (options.defaultSensitivity === "restricted") {
39
+ throw new Error("policy.defaultSensitivity must not be 'restricted'; capture rejects restricted candidates upstream");
40
+ }
41
+ const sensitivity = classifySensitivity(body, options.defaultSensitivity);
42
+ return { sensitivity, requiresApproval: sensitivity !== "public" };
43
+ }
@@ -0,0 +1,13 @@
1
+ import type { CaptureOutcome, SalienceDeps, SalienceInput } from "./types.js";
2
+ interface RawSalienceItem {
3
+ readonly body: string;
4
+ readonly type: string;
5
+ readonly confidence: number;
6
+ readonly scope: string;
7
+ readonly tags: readonly string[];
8
+ }
9
+ export declare const SALIENCE_SYSTEM_PROMPT = "You extract durable memories from a chat turn so an assistant can remember the user across future conversations.\n\nReturn ONLY a JSON array (no prose, no markdown fences). Each element:\n{ \"body\": string, \"type\": string, \"confidence\": number, \"scope\": string, \"tags\": string[] }\n\nCapture ONLY facts the USER asserted about THEMSELVES or THEIR work that are durable and worth remembering: identity, stable preferences, project and technology facts, decisions, constraints, goals, environment, team, and recurring workflow lessons. Write each \"body\" as a concise, self-contained, third-person statement (e.g. \"The user is building a fintech app called Atlas in Rust with PostgreSQL\"). Identity statements should be canonicalised the same way every time, for example \"My name is Paul.\" / \"Hallo Keiko, ich bin Paul.\" -> \"The user's name is Paul.\".\n\nCapture LIBERALLY \u2014 the bar is low; when in doubt, include it.\n\nEXCLUDE: questions; one-off ephemeral task requests; anything the ASSISTANT said or suggested (the assistant message is context only, never a source of user facts); general world knowledge; and anything secret or credential-like (passwords, API keys, tokens, private keys).\n\n\"type\" is one of: identity, preference, fact, decision, constraint, goal, lesson, procedural. \"scope\" is one of: user (personal facts/preferences), project (project-specific facts), workspace. \"confidence\" is 0..1. \"tags\" is a short list of lowercase keywords.\n\nIf there is nothing durable to capture, return [].";
10
+ export declare function parseSalienceItems(raw: string): readonly RawSalienceItem[];
11
+ export declare function extractSalientMemories(input: SalienceInput, deps: SalienceDeps): Promise<readonly CaptureOutcome[]>;
12
+ export {};
13
+ //# sourceMappingURL=salience.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"salience.d.ts","sourceRoot":"","sources":["../src/salience.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAEV,cAAc,EAGd,YAAY,EACZ,aAAa,EACd,MAAM,YAAY,CAAC;AAkBpB,UAAU,eAAe;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAGD,eAAO,MAAM,sBAAsB,0gDAagB,CAAC;AAwEpD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,eAAe,EAAE,CAe1E;AAyID,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,aAAa,EACpB,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC,SAAS,cAAc,EAAE,CAAC,CA+BpC"}
@@ -0,0 +1,268 @@
1
+ // Model-assisted salience capture for keiko-memory-capture.
2
+ //
3
+ // The regex intent extractors (intent-explicit.ts) only fire on imperative phrases ("remember
4
+ // that X"). This module captures DURABLE, salient facts the user asserts in NATURAL conversation
5
+ // ("I'm building a fintech app called Atlas in Rust") that the regex path misses entirely.
6
+ //
7
+ // It is the one extractor that needs the model, so it is async — but the boundary stays thin:
8
+ // exactly one `callModel` await, defensive JSON parsing that can NEVER throw (malformed prose →
9
+ // []), then the same deterministic envelope/secret/scope/policy pipeline the regex path uses.
10
+ //
11
+ // Product intent: the capture bar is LOW (over-capture is acceptable; a later decay/consolidation
12
+ // pass prunes). We still apply the full secret-rejection net and clamp confidence into a band
13
+ // that stays retrievable (>0.3 stale floor) but below the 1.0 reserved for explicit user intent.
14
+ import { MEMORY_BODY_MAX_CHARS_DEFAULT } from "./_constants.js";
15
+ import { buildProposal } from "./_envelopes.js";
16
+ import { applyPolicy } from "./policy.js";
17
+ import { inferScopeFromContext } from "./scope-inference.js";
18
+ import { scanForSecrets } from "./secret-patterns.js";
19
+ // Confidence band: floor 0.4 keeps salience candidates above the 0.3 stale-suppression floor so
20
+ // they remain retrievable; ceiling 0.9 keeps them below the 1.0 reserved for explicit user intent.
21
+ const CONFIDENCE_MIN = 0.4;
22
+ const CONFIDENCE_MAX = 0.9;
23
+ // Hard cap on accepted candidates per turn — over-capture is bounded.
24
+ const MAX_CANDIDATES = 6;
25
+ // Jaccard char-bigram similarity at/above which a candidate is treated as a near-duplicate.
26
+ const DEDUP_THRESHOLD = 0.8;
27
+ // Provenance string surfaced in the MemoriaViva detail view (decision 2). Salience reuses the
28
+ // "system-default" source kind (no dedicated conversation-inferred kind exists yet), so this
29
+ // rationale is the explainability signal that the memory was inferred, not user-instructed.
30
+ const SALIENCE_RATIONALE = "Automatically inferred from conversation (salience capture)";
31
+ // ─── Verbatim extraction prompt ──────────────────────────────────────────────
32
+ export const SALIENCE_SYSTEM_PROMPT = `You extract durable memories from a chat turn so an assistant can remember the user across future conversations.
33
+
34
+ Return ONLY a JSON array (no prose, no markdown fences). Each element:
35
+ { "body": string, "type": string, "confidence": number, "scope": string, "tags": string[] }
36
+
37
+ Capture ONLY facts the USER asserted about THEMSELVES or THEIR work that are durable and worth remembering: identity, stable preferences, project and technology facts, decisions, constraints, goals, environment, team, and recurring workflow lessons. Write each "body" as a concise, self-contained, third-person statement (e.g. "The user is building a fintech app called Atlas in Rust with PostgreSQL"). Identity statements should be canonicalised the same way every time, for example "My name is Paul." / "Hallo Keiko, ich bin Paul." -> "The user's name is Paul.".
38
+
39
+ Capture LIBERALLY — the bar is low; when in doubt, include it.
40
+
41
+ EXCLUDE: questions; one-off ephemeral task requests; anything the ASSISTANT said or suggested (the assistant message is context only, never a source of user facts); general world knowledge; and anything secret or credential-like (passwords, API keys, tokens, private keys).
42
+
43
+ "type" is one of: identity, preference, fact, decision, constraint, goal, lesson, procedural. "scope" is one of: user (personal facts/preferences), project (project-specific facts), workspace. "confidence" is 0..1. "tags" is a short list of lowercase keywords.
44
+
45
+ If there is nothing durable to capture, return [].`;
46
+ function buildUserPrompt(userText, assistantText) {
47
+ const assistantBlock = assistantText !== undefined && assistantText.trim().length > 0
48
+ ? `\n\nAssistant said (CONTEXT ONLY — never a source of user facts):\n${assistantText}`
49
+ : "";
50
+ return `User said:\n${userText}${assistantBlock}`;
51
+ }
52
+ // ─── Defensive JSON parsing (never throws) ───────────────────────────────────
53
+ function stripCodeFences(raw) {
54
+ return raw.replace(/```[a-zA-Z]*\n?/g, "").replace(/```/g, "");
55
+ }
56
+ // Locate the first balanced top-level JSON array. Returns the substring or null. Scans for the
57
+ // first "[" then walks to its matching "]" tracking string literals and escapes so a bracket
58
+ // inside a string value does not close the array early.
59
+ function firstBalancedArray(text) {
60
+ const start = text.indexOf("[");
61
+ if (start === -1) {
62
+ return null;
63
+ }
64
+ let depth = 0;
65
+ let inString = false;
66
+ let escaped = false;
67
+ for (let i = start; i < text.length; i += 1) {
68
+ const ch = text[i];
69
+ if (escaped) {
70
+ escaped = false;
71
+ continue;
72
+ }
73
+ if (ch === "\\") {
74
+ escaped = true;
75
+ continue;
76
+ }
77
+ if (ch === '"') {
78
+ inString = !inString;
79
+ continue;
80
+ }
81
+ if (inString) {
82
+ continue;
83
+ }
84
+ if (ch === "[") {
85
+ depth += 1;
86
+ }
87
+ else if (ch === "]") {
88
+ depth -= 1;
89
+ if (depth === 0) {
90
+ return text.slice(start, i + 1);
91
+ }
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ function isRawSalienceItem(value) {
97
+ if (typeof value !== "object" || value === null) {
98
+ return false;
99
+ }
100
+ const item = value;
101
+ return (typeof item.body === "string" &&
102
+ typeof item.type === "string" &&
103
+ typeof item.confidence === "number" &&
104
+ typeof item.scope === "string" &&
105
+ Array.isArray(item.tags) &&
106
+ item.tags.every((tag) => typeof tag === "string"));
107
+ }
108
+ // Parse the model output into validated raw items. ANY failure (no array, bad JSON, wrong element
109
+ // shapes) yields [] — capture must never throw into the chat path.
110
+ export function parseSalienceItems(raw) {
111
+ const arrayText = firstBalancedArray(stripCodeFences(raw));
112
+ if (arrayText === null) {
113
+ return [];
114
+ }
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(arrayText);
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ if (!Array.isArray(parsed)) {
123
+ return [];
124
+ }
125
+ return parsed.filter(isRawSalienceItem);
126
+ }
127
+ // ─── Loose-label → contract mapping ──────────────────────────────────────────
128
+ const TYPE_MAP = {
129
+ identity: "semantic-fact",
130
+ fact: "semantic-fact",
131
+ constraint: "semantic-fact",
132
+ goal: "semantic-fact",
133
+ environment: "semantic-fact",
134
+ team: "semantic-fact",
135
+ preference: "preference",
136
+ decision: "decision",
137
+ lesson: "procedural",
138
+ procedural: "procedural",
139
+ workflow: "procedural",
140
+ };
141
+ function mapType(loose) {
142
+ return TYPE_MAP[loose.trim().toLowerCase()] ?? "semantic-fact";
143
+ }
144
+ function mapScopeKind(loose) {
145
+ const normalized = loose.trim().toLowerCase();
146
+ if (normalized === "project" || normalized === "workspace") {
147
+ return normalized;
148
+ }
149
+ return "user";
150
+ }
151
+ function clampConfidence(value) {
152
+ if (Number.isNaN(value)) {
153
+ return CONFIDENCE_MIN;
154
+ }
155
+ return Math.min(CONFIDENCE_MAX, Math.max(CONFIDENCE_MIN, value));
156
+ }
157
+ // ─── Dedup (pure, char-bigram Jaccard) ───────────────────────────────────────
158
+ function normalizeForDedup(body) {
159
+ return body
160
+ .toLowerCase()
161
+ .replace(/[^a-z0-9]+/g, " ")
162
+ .trim();
163
+ }
164
+ function charBigrams(normalized) {
165
+ const bigrams = new Set();
166
+ for (let i = 0; i < normalized.length - 1; i += 1) {
167
+ bigrams.add(normalized.slice(i, i + 2));
168
+ }
169
+ return bigrams;
170
+ }
171
+ function jaccard(a, b) {
172
+ if (a.size === 0 && b.size === 0) {
173
+ return 1;
174
+ }
175
+ let intersection = 0;
176
+ for (const bigram of a) {
177
+ if (b.has(bigram)) {
178
+ intersection += 1;
179
+ }
180
+ }
181
+ const union = a.size + b.size - intersection;
182
+ return union === 0 ? 0 : intersection / union;
183
+ }
184
+ function isNearDuplicate(candidate, seen) {
185
+ for (const existing of seen) {
186
+ if (jaccard(candidate, existing) >= DEDUP_THRESHOLD) {
187
+ return true;
188
+ }
189
+ }
190
+ return false;
191
+ }
192
+ // ─── Candidate construction ──────────────────────────────────────────────────
193
+ // Effective context overlays deps-supplied clock/id factories onto the caller context so the
194
+ // scripted test (and the audit ledger) deterministically controls time and ids (decision 3).
195
+ function effectiveContext(input, deps) {
196
+ return {
197
+ ...input.context,
198
+ nowMs: deps.now(),
199
+ newMemoryId: deps.newMemoryId,
200
+ newProposalId: deps.newProposalId,
201
+ };
202
+ }
203
+ // Turns one validated raw item into a candidate outcome, or null when it must be dropped (secret,
204
+ // empty/oversize body, or unresolvable scope). Pure given the effective context.
205
+ function buildCandidate(item, context, policy) {
206
+ const body = item.body.trim();
207
+ const max = policy.maxBodyChars ?? MEMORY_BODY_MAX_CHARS_DEFAULT;
208
+ if (body.length === 0 || body.length > max) {
209
+ return null;
210
+ }
211
+ if (scanForSecrets(body, policy.customerIdentifierMatchers ?? []) !== null) {
212
+ return null;
213
+ }
214
+ const scope = inferScopeFromContext(context, {
215
+ scopeKind: mapScopeKind(item.scope),
216
+ ...(policy.allowGlobalScope !== undefined && { allowGlobalScope: policy.allowGlobalScope }),
217
+ });
218
+ if (scope === null) {
219
+ return null;
220
+ }
221
+ const decision = applyPolicy(body, {
222
+ ...(policy.defaultSensitivity !== undefined && {
223
+ defaultSensitivity: policy.defaultSensitivity,
224
+ }),
225
+ });
226
+ const proposal = buildProposal({
227
+ context,
228
+ scope,
229
+ body,
230
+ type: mapType(item.type),
231
+ sensitivity: decision.sensitivity,
232
+ sourceKind: "system-default",
233
+ captureRationale: SALIENCE_RATIONALE,
234
+ }, clampConfidence(item.confidence));
235
+ return {
236
+ kind: "candidate",
237
+ proposal: { ...proposal, tags: [...item.tags] },
238
+ requiresApproval: decision.requiresApproval,
239
+ };
240
+ }
241
+ // ─── Public entry point ──────────────────────────────────────────────────────
242
+ export async function extractSalientMemories(input, deps) {
243
+ if (input.userText.trim().length === 0) {
244
+ return [];
245
+ }
246
+ const raw = await deps.callModel(SALIENCE_SYSTEM_PROMPT, buildUserPrompt(input.userText, input.assistantText));
247
+ const items = parseSalienceItems(raw);
248
+ const context = effectiveContext(input, deps);
249
+ const policy = input.policy ?? {};
250
+ const seen = input.existingBodies.map((body) => charBigrams(normalizeForDedup(body)));
251
+ const accepted = [];
252
+ for (const item of items) {
253
+ if (accepted.length >= MAX_CANDIDATES) {
254
+ break;
255
+ }
256
+ const candidate = buildCandidate(item, context, policy);
257
+ if (candidate === null) {
258
+ continue;
259
+ }
260
+ const bigrams = charBigrams(normalizeForDedup(item.body));
261
+ if (isNearDuplicate(bigrams, seen)) {
262
+ continue;
263
+ }
264
+ seen.push(bigrams);
265
+ accepted.push(candidate);
266
+ }
267
+ return accepted;
268
+ }
@@ -0,0 +1,9 @@
1
+ import type { MemoryScope, MemoryScopeKind } from "@oscharko-dev/keiko-contracts/memory";
2
+ import type { CaptureContext } from "./types.js";
3
+ interface ScopeInferenceOptions {
4
+ readonly scopeKind?: MemoryScopeKind;
5
+ readonly allowGlobalScope?: boolean;
6
+ }
7
+ export declare function inferScopeFromContext(context: CaptureContext, options: ScopeInferenceOptions): MemoryScope | null;
8
+ export {};
9
+ //# sourceMappingURL=scope-inference.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-inference.d.ts","sourceRoot":"","sources":["../src/scope-inference.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AAEzF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,UAAU,qBAAqB;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,eAAe,CAAC;IACrC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;CACrC;AA4CD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,qBAAqB,GAC7B,WAAW,GAAG,IAAI,CAGpB"}