@graphorin/memory 0.6.1 → 0.7.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.
- package/CHANGELOG.md +71 -0
- package/README.md +1 -1
- package/dist/conflict/index.js +0 -1
- package/dist/conflict/pipeline.js +0 -1
- package/dist/conflict/pipeline.js.map +1 -1
- package/dist/conflict/stages/stage3-heuristic-regex.js +0 -1
- package/dist/conflict/stages/stage3-heuristic-regex.js.map +1 -1
- package/dist/conflict/types.d.ts +1 -1
- package/dist/conflict/types.js.map +1 -1
- package/dist/consolidator/index.js +2 -0
- package/dist/consolidator/index.js.map +1 -1
- package/dist/consolidator/lock.js +7 -1
- package/dist/consolidator/lock.js.map +1 -1
- package/dist/consolidator/phases/deep.js +19 -12
- package/dist/consolidator/phases/deep.js.map +1 -1
- package/dist/consolidator/phases/induce.d.ts.map +1 -1
- package/dist/consolidator/phases/induce.js +2 -3
- package/dist/consolidator/phases/induce.js.map +1 -1
- package/dist/consolidator/phases/reflect.js +35 -8
- package/dist/consolidator/phases/reflect.js.map +1 -1
- package/dist/consolidator/phases/standard.js +199 -28
- package/dist/consolidator/phases/standard.js.map +1 -1
- package/dist/consolidator/reconcile.js +16 -15
- package/dist/consolidator/reconcile.js.map +1 -1
- package/dist/consolidator/runtime.d.ts.map +1 -1
- package/dist/consolidator/runtime.js +129 -47
- package/dist/consolidator/runtime.js.map +1 -1
- package/dist/consolidator/scheduler.d.ts +17 -3
- package/dist/consolidator/scheduler.d.ts.map +1 -1
- package/dist/consolidator/scheduler.js.map +1 -1
- package/dist/consolidator/types.d.ts +30 -1
- package/dist/consolidator/types.d.ts.map +1 -1
- package/dist/consolidator/types.js +10 -0
- package/dist/consolidator/types.js.map +1 -1
- package/dist/context-engine/compaction/clear-tool-results.d.ts +1 -1
- package/dist/context-engine/compaction/clear-tool-results.js +1 -1
- package/dist/context-engine/compaction/clear-tool-results.js.map +1 -1
- package/dist/context-engine/compaction/compactor.d.ts +1 -0
- package/dist/context-engine/compaction/compactor.d.ts.map +1 -1
- package/dist/context-engine/compaction/compactor.js +32 -8
- package/dist/context-engine/compaction/compactor.js.map +1 -1
- package/dist/context-engine/compaction/hooks/types.d.ts +1 -1
- package/dist/context-engine/compaction/index.d.ts +1 -0
- package/dist/context-engine/compaction/templates/marker.d.ts +33 -0
- package/dist/context-engine/compaction/templates/marker.d.ts.map +1 -0
- package/dist/context-engine/compaction/templates/marker.js +34 -0
- package/dist/context-engine/compaction/templates/marker.js.map +1 -0
- package/dist/context-engine/compaction/templates/summary-9-section.d.ts +1 -1
- package/dist/context-engine/compaction/templates/summary-9-section.d.ts.map +1 -1
- package/dist/context-engine/compaction/templates/summary-9-section.js +4 -3
- package/dist/context-engine/compaction/templates/summary-9-section.js.map +1 -1
- package/dist/context-engine/engine.d.ts +7 -176
- package/dist/context-engine/engine.d.ts.map +1 -1
- package/dist/context-engine/engine.js +0 -2
- package/dist/context-engine/engine.js.map +1 -1
- package/dist/context-engine/index.d.ts +8 -49
- package/dist/context-engine/io-types.d.ts +144 -0
- package/dist/context-engine/io-types.d.ts.map +1 -0
- package/dist/context-engine/locale-packs/types.d.ts +1 -1
- package/dist/context-engine/locale-packs/types.js.map +1 -1
- package/dist/context-engine/privacy-filter.d.ts +1 -2
- package/dist/context-engine/privacy-filter.d.ts.map +1 -1
- package/dist/context-engine/privacy-filter.js +1 -2
- package/dist/context-engine/privacy-filter.js.map +1 -1
- package/dist/errors/index.d.ts +2 -2
- package/dist/errors/index.js +2 -2
- package/dist/errors/index.js.map +1 -1
- package/dist/facade.d.ts +43 -52
- package/dist/facade.d.ts.map +1 -1
- package/dist/facade.js +5 -3
- package/dist/facade.js.map +1 -1
- package/dist/graph/entity-resolver.js +1 -1
- package/dist/graph/entity-resolver.js.map +1 -1
- package/dist/index.d.ts +16 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/envelope.js +57 -0
- package/dist/internal/envelope.js.map +1 -0
- package/dist/internal/injection-heuristics.js +0 -0
- package/dist/internal/injection-heuristics.js.map +1 -1
- package/dist/internal/llm-json.js +40 -0
- package/dist/internal/llm-json.js.map +1 -0
- package/dist/internal/storage-adapter.d.ts +23 -10
- package/dist/internal/storage-adapter.d.ts.map +1 -1
- package/dist/memory-interface.d.ts +131 -0
- package/dist/memory-interface.d.ts.map +1 -0
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/search/iterative.d.ts.map +1 -1
- package/dist/search/iterative.js +2 -3
- package/dist/search/iterative.js.map +1 -1
- package/dist/search/query-transform.d.ts.map +1 -1
- package/dist/search/query-transform.js +2 -3
- package/dist/search/query-transform.js.map +1 -1
- package/dist/tiers/episodic-memory.js +3 -3
- package/dist/tiers/episodic-memory.js.map +1 -1
- package/dist/tiers/index.d.ts +2 -2
- package/dist/tiers/insight-memory.js +1 -1
- package/dist/tiers/insight-memory.js.map +1 -1
- package/dist/tiers/procedural-memory.d.ts +1 -1
- package/dist/tiers/procedural-memory.js +2 -2
- package/dist/tiers/procedural-memory.js.map +1 -1
- package/dist/tiers/semantic-memory.d.ts +58 -4
- package/dist/tiers/semantic-memory.d.ts.map +1 -1
- package/dist/tiers/semantic-memory.js +103 -20
- package/dist/tiers/semantic-memory.js.map +1 -1
- package/dist/tools/block-tools.d.ts +19 -57
- package/dist/tools/block-tools.d.ts.map +1 -1
- package/dist/tools/block-tools.js.map +1 -1
- package/dist/tools/fact-tools.d.ts +53 -214
- package/dist/tools/fact-tools.d.ts.map +1 -1
- package/dist/tools/fact-tools.js +3 -2
- package/dist/tools/fact-tools.js.map +1 -1
- package/dist/tools/recall-tools.d.ts +34 -175
- package/dist/tools/recall-tools.d.ts.map +1 -1
- package/dist/tools/recall-tools.js +5 -4
- package/dist/tools/recall-tools.js.map +1 -1
- package/dist/tools/runbook-tools.d.ts +15 -58
- package/dist/tools/runbook-tools.d.ts.map +1 -1
- package/dist/tools/runbook-tools.js.map +1 -1
- package/dist/tools/types.d.ts +1 -1
- package/package.json +18 -17
- package/src/conflict/index.ts +29 -0
- package/src/conflict/locale-packs/en.ts +142 -0
- package/src/conflict/locale-packs/index.ts +17 -0
- package/src/conflict/locale-packs/types.ts +138 -0
- package/src/conflict/pipeline.ts +371 -0
- package/src/conflict/stages/helpers.ts +65 -0
- package/src/conflict/stages/stage1-exact-dedup.ts +40 -0
- package/src/conflict/stages/stage2-embedding-three-zone.ts +54 -0
- package/src/conflict/stages/stage3-heuristic-regex.ts +51 -0
- package/src/conflict/stages/stage4-subject-predicate.ts +46 -0
- package/src/conflict/stages/stage5-defer-to-deep.ts +34 -0
- package/src/conflict/types.ts +263 -0
- package/src/consolidator/budget.ts +312 -0
- package/src/consolidator/decay.ts +246 -0
- package/src/consolidator/dlq.ts +61 -0
- package/src/consolidator/errors.ts +87 -0
- package/src/consolidator/idempotency.ts +27 -0
- package/src/consolidator/index.ts +269 -0
- package/src/consolidator/lock.ts +141 -0
- package/src/consolidator/noise-filter.ts +235 -0
- package/src/consolidator/phases/deep.ts +308 -0
- package/src/consolidator/phases/induce.ts +423 -0
- package/src/consolidator/phases/learned-context.ts +233 -0
- package/src/consolidator/phases/light.ts +166 -0
- package/src/consolidator/phases/reflect.ts +504 -0
- package/src/consolidator/phases/standard.ts +1002 -0
- package/src/consolidator/reconcile.ts +273 -0
- package/src/consolidator/runtime.ts +1049 -0
- package/src/consolidator/scheduler.ts +182 -0
- package/src/consolidator/triggers.ts +134 -0
- package/src/consolidator/types.ts +622 -0
- package/src/context-engine/annotations.ts +161 -0
- package/src/context-engine/auto-recall.ts +92 -0
- package/src/context-engine/compaction/clear-tool-results.ts +232 -0
- package/src/context-engine/compaction/compactor.ts +403 -0
- package/src/context-engine/compaction/hooks/reanchor-persona-block.ts +55 -0
- package/src/context-engine/compaction/hooks/reanchor-pinned-facts.ts +66 -0
- package/src/context-engine/compaction/hooks/reanchor-project-rules.ts +57 -0
- package/src/context-engine/compaction/hooks/reanchor-recent-results.ts +116 -0
- package/src/context-engine/compaction/hooks/types.ts +68 -0
- package/src/context-engine/compaction/index.ts +60 -0
- package/src/context-engine/compaction/templates/marker.ts +33 -0
- package/src/context-engine/compaction/templates/summary-9-section.ts +223 -0
- package/src/context-engine/compaction/thresholds.ts +107 -0
- package/src/context-engine/compaction/types.ts +255 -0
- package/src/context-engine/engine.ts +970 -0
- package/src/context-engine/index.ts +118 -0
- package/src/context-engine/io-types.ts +162 -0
- package/src/context-engine/locale-packs/en.ts +107 -0
- package/src/context-engine/locale-packs/index.ts +19 -0
- package/src/context-engine/locale-packs/resolver.ts +197 -0
- package/src/context-engine/locale-packs/types.ts +177 -0
- package/src/context-engine/metadata.ts +114 -0
- package/src/context-engine/preambles/inbound-en.ts +19 -0
- package/src/context-engine/privacy-filter.ts +190 -0
- package/src/context-engine/templates/base-en.ts +19 -0
- package/src/context-engine/templates/composer.ts +91 -0
- package/src/context-engine/token-budget.ts +319 -0
- package/src/context-engine/token-counter.ts +157 -0
- package/src/errors/index.ts +273 -0
- package/src/facade.ts +750 -0
- package/src/graph/entity-resolver.ts +364 -0
- package/src/graph/index.ts +24 -0
- package/src/index.ts +259 -0
- package/src/internal/contextualize.ts +173 -0
- package/src/internal/embedder-binding.ts +53 -0
- package/src/internal/envelope.ts +70 -0
- package/src/internal/id.ts +23 -0
- package/src/internal/injection-heuristics.ts +0 -0
- package/src/internal/llm-json.ts +38 -0
- package/src/internal/spans.ts +35 -0
- package/src/internal/storage-adapter.ts +963 -0
- package/src/memory-interface.ts +158 -0
- package/src/migration/embedder-migration.ts +237 -0
- package/src/migration/index.ts +13 -0
- package/src/search/explain.ts +123 -0
- package/src/search/fit-weights.ts +109 -0
- package/src/search/graph-ppr.ts +38 -0
- package/src/search/index.ts +55 -0
- package/src/search/iterative.ts +658 -0
- package/src/search/query-transform.ts +285 -0
- package/src/search/rrf.ts +225 -0
- package/src/search/trust.ts +55 -0
- package/src/search/types.ts +50 -0
- package/src/tiers/episodic-memory.ts +463 -0
- package/src/tiers/index.ts +50 -0
- package/src/tiers/insight-memory.ts +208 -0
- package/src/tiers/procedural-memory.ts +482 -0
- package/src/tiers/semantic-memory.ts +1729 -0
- package/src/tiers/session-memory.ts +271 -0
- package/src/tiers/shared-memory.ts +62 -0
- package/src/tiers/working-memory.ts +368 -0
- package/src/tools/block-tools.ts +172 -0
- package/src/tools/fact-tools.ts +473 -0
- package/src/tools/index.ts +109 -0
- package/src/tools/recall-tools.ts +303 -0
- package/src/tools/runbook-tools.ts +107 -0
- package/src/tools/taint.ts +39 -0
- package/src/tools/types.ts +37 -0
- package/dist/conflict/locale-packs/index.js +0 -4
- package/dist/context-engine/compaction/index.js +0 -10
- package/dist/context-engine/index.d.ts.map +0 -1
- package/dist/context-engine/index.js +0 -25
- package/dist/context-engine/locale-packs/index.js +0 -4
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InsightMemory` - the thin, read-only surface over the reflection
|
|
3
|
+
* insights the consolidator synthesizes (P1-1). Insights are *derived*
|
|
4
|
+
* (quarantined + `provenance: 'reflection'`), carry mandatory citations,
|
|
5
|
+
* and are retrieval-ranked **below the primary facts they cite**. The
|
|
6
|
+
* write + salience-management paths live in the consolidator's
|
|
7
|
+
* reflection pass; this tier only lists / searches for the validation /
|
|
8
|
+
* inspector path.
|
|
9
|
+
*
|
|
10
|
+
* Search is FTS-only - insights are a soft inspector surface, not
|
|
11
|
+
* primary recall, so no embedder is wired here.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Fact, Insight, MemoryHit, SessionScope, Tracer } from '@graphorin/core';
|
|
17
|
+
import { QuarantinePromotionRefusedError } from '../errors/index.js';
|
|
18
|
+
import { detectMemoryInjection } from '../internal/injection-heuristics.js';
|
|
19
|
+
import { withMemorySpan } from '../internal/spans.js';
|
|
20
|
+
import type { MemoryStoreAdapter } from '../internal/storage-adapter.js';
|
|
21
|
+
|
|
22
|
+
/** Per-call options accepted by {@link InsightMemory.search}. */
|
|
23
|
+
export interface InsightSearchOptions {
|
|
24
|
+
readonly topK?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Include quarantined insights (P1-4). Defaults to `false`. Since
|
|
27
|
+
* reflection-synthesized insights *always* land quarantined, set this
|
|
28
|
+
* `true` to surface them for the validation / inspector path - never
|
|
29
|
+
* for auto-recall fed back into the model.
|
|
30
|
+
*/
|
|
31
|
+
readonly includeQuarantined?: boolean;
|
|
32
|
+
readonly signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-call options accepted by {@link InsightMemory.list}. */
|
|
36
|
+
export interface InsightListOptions {
|
|
37
|
+
readonly limit?: number;
|
|
38
|
+
/** Include quarantined insights (validation / inspector path). */
|
|
39
|
+
readonly includeQuarantined?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `InsightMemory` - list / search reflection insights. A no-op (returns
|
|
44
|
+
* empty) when the storage adapter does not expose the optional
|
|
45
|
+
* `insights` surface.
|
|
46
|
+
*
|
|
47
|
+
* @stable
|
|
48
|
+
*/
|
|
49
|
+
export class InsightMemory {
|
|
50
|
+
readonly #store: MemoryStoreAdapter;
|
|
51
|
+
readonly #tracer: Tracer;
|
|
52
|
+
|
|
53
|
+
constructor(args: { store: MemoryStoreAdapter; tracer: Tracer }) {
|
|
54
|
+
this.#store = args.store;
|
|
55
|
+
this.#tracer = args.tracer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** FTS keyword search over insight text. */
|
|
59
|
+
async search(
|
|
60
|
+
scope: SessionScope,
|
|
61
|
+
query: string,
|
|
62
|
+
opts: InsightSearchOptions = {},
|
|
63
|
+
): Promise<ReadonlyArray<MemoryHit<Insight>>> {
|
|
64
|
+
return withMemorySpan(
|
|
65
|
+
this.#tracer,
|
|
66
|
+
'memory.search.insight',
|
|
67
|
+
scope,
|
|
68
|
+
{ 'memory.search.query_length': query.length },
|
|
69
|
+
async (span) => {
|
|
70
|
+
const store = this.#store.insights;
|
|
71
|
+
if (store === undefined) {
|
|
72
|
+
span.setAttributes({ 'memory.search.insight.count': 0 });
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const hits = await store.search(scope, query, {
|
|
76
|
+
topK: opts.topK ?? 10,
|
|
77
|
+
...(opts.includeQuarantined !== undefined
|
|
78
|
+
? { includeQuarantined: opts.includeQuarantined }
|
|
79
|
+
: {}),
|
|
80
|
+
});
|
|
81
|
+
// MCON-16: retrieval reinforces (ExpeL) - a recalled insight gains
|
|
82
|
+
// +1 salience, offsetting the per-reflection-pass -1 decay, so
|
|
83
|
+
// used insights persist while unused ones slide to the prune
|
|
84
|
+
// floor. Best-effort bookkeeping: never break the read path.
|
|
85
|
+
for (const hit of hits) {
|
|
86
|
+
try {
|
|
87
|
+
await store.bumpSalience(hit.record.id, 1, 'retrieval');
|
|
88
|
+
} catch {
|
|
89
|
+
// Advisory only.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
span.setAttributes({ 'memory.search.insight.count': hits.length });
|
|
93
|
+
return hits;
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Most-recent insights for the scope (newest first). */
|
|
99
|
+
async list(scope: SessionScope, opts: InsightListOptions = {}): Promise<ReadonlyArray<Insight>> {
|
|
100
|
+
return withMemorySpan(this.#tracer, 'memory.read.insight', scope, {}, async (span) => {
|
|
101
|
+
const store = this.#store.insights;
|
|
102
|
+
if (store === undefined) {
|
|
103
|
+
span.setAttributes({ 'memory.read.insight.count': 0 });
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const out = await store.list(scope, {
|
|
107
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
108
|
+
...(opts.includeQuarantined !== undefined
|
|
109
|
+
? { includeQuarantined: opts.includeQuarantined }
|
|
110
|
+
: {}),
|
|
111
|
+
});
|
|
112
|
+
span.setAttributes({ 'memory.read.insight.count': out.length });
|
|
113
|
+
return out;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lookup a single insight by id. */
|
|
118
|
+
async get(id: string): Promise<Insight | null> {
|
|
119
|
+
const store = this.#store.insights;
|
|
120
|
+
if (store === undefined || typeof store.get !== 'function') return null;
|
|
121
|
+
return store.get(id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Promote a quarantined insight out of quarantine (MCON-2). Mirrors
|
|
126
|
+
* {@link SemanticMemory.validate}: re-derives the injection verdict from the
|
|
127
|
+
* stored text and **refuses** promotion of an injection-flagged insight
|
|
128
|
+
* unless an operator passes `{ force: true }` from a trusted context.
|
|
129
|
+
*/
|
|
130
|
+
async validate(
|
|
131
|
+
scope: SessionScope,
|
|
132
|
+
insightId: string,
|
|
133
|
+
reason?: string,
|
|
134
|
+
options?: { readonly force?: boolean },
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await withMemorySpan(
|
|
137
|
+
this.#tracer,
|
|
138
|
+
'memory.write.insight',
|
|
139
|
+
scope,
|
|
140
|
+
{ 'memory.insight.action': 'validate', 'memory.insight.insight_id': insightId },
|
|
141
|
+
async (span) => {
|
|
142
|
+
const store = this.#store.insights;
|
|
143
|
+
if (store === undefined || typeof store.setStatus !== 'function') {
|
|
144
|
+
throw new TypeError(
|
|
145
|
+
'[graphorin/memory] InsightMemory.validate(...) requires a storage adapter that implements `insights.setStatus(id, status)`. ' +
|
|
146
|
+
'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via InsightMemoryStoreExt.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const force = options?.force === true;
|
|
150
|
+
const existing = typeof store.get === 'function' ? await store.get(insightId) : null;
|
|
151
|
+
if (existing !== null && !force) {
|
|
152
|
+
const injection = detectMemoryInjection(existing.text);
|
|
153
|
+
if (injection.flagged) {
|
|
154
|
+
span.setAttributes({ 'memory.insight.validate.refused': true });
|
|
155
|
+
throw new QuarantinePromotionRefusedError(insightId, injection.markers);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
span.setAttributes({ 'memory.insight.validate.forced': force });
|
|
159
|
+
await store.setStatus(insightId, 'active', reason, scope);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Tie-break margin keeping a capped insight strictly below its cited fact. */
|
|
166
|
+
const RANK_CAP_EPSILON = 1e-6;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Enforce the P1-1 rank ceiling: an insight may never outrank a primary
|
|
170
|
+
* fact **it cites**. For each insight hit, if any fact it cites is
|
|
171
|
+
* present in `factHits`, its score is lowered to strictly below that
|
|
172
|
+
* cited fact's score - so concatenating the two lists and sorting by
|
|
173
|
+
* score descending can never place the insight above the evidence it
|
|
174
|
+
* was synthesized from. Insights whose cited facts are absent from
|
|
175
|
+
* `factHits` are returned unchanged; this is a relative, not a global,
|
|
176
|
+
* cap (per the execution plan: "never outrank the primary facts they
|
|
177
|
+
* cite").
|
|
178
|
+
*
|
|
179
|
+
* Pure + deterministic - does not mutate its inputs.
|
|
180
|
+
*
|
|
181
|
+
* @stable
|
|
182
|
+
*/
|
|
183
|
+
export function capInsightsBelowFacts(
|
|
184
|
+
factHits: ReadonlyArray<MemoryHit<Fact>>,
|
|
185
|
+
insightHits: ReadonlyArray<MemoryHit<Insight>>,
|
|
186
|
+
): ReadonlyArray<MemoryHit<Insight>> {
|
|
187
|
+
if (factHits.length === 0 || insightHits.length === 0) return insightHits;
|
|
188
|
+
const factScoreById = new Map<string, number>();
|
|
189
|
+
for (const hit of factHits) {
|
|
190
|
+
const prev = factScoreById.get(hit.record.id);
|
|
191
|
+
if (prev === undefined || hit.score > prev) factScoreById.set(hit.record.id, hit.score);
|
|
192
|
+
}
|
|
193
|
+
return insightHits.map((hit) => {
|
|
194
|
+
let citedMax = Number.NEGATIVE_INFINITY;
|
|
195
|
+
for (const id of hit.record.cites) {
|
|
196
|
+
const score = factScoreById.get(id);
|
|
197
|
+
if (score !== undefined && score > citedMax) citedMax = score;
|
|
198
|
+
}
|
|
199
|
+
// No cited fact present in the fused set, or already below it ⇒ keep.
|
|
200
|
+
if (citedMax === Number.NEGATIVE_INFINITY || hit.score < citedMax) return hit;
|
|
201
|
+
const capped = citedMax - RANK_CAP_EPSILON;
|
|
202
|
+
return {
|
|
203
|
+
...hit,
|
|
204
|
+
score: capped,
|
|
205
|
+
signals: Object.freeze({ ...(hit.signals ?? {}), 'rank-cap': capped }),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MemoryHit,
|
|
3
|
+
MemoryOwner,
|
|
4
|
+
MemoryProvenance,
|
|
5
|
+
MemoryStatus,
|
|
6
|
+
Rule,
|
|
7
|
+
RunState,
|
|
8
|
+
Sensitivity,
|
|
9
|
+
SessionScope,
|
|
10
|
+
Tracer,
|
|
11
|
+
} from '@graphorin/core';
|
|
12
|
+
import {
|
|
13
|
+
type InducedProcedure,
|
|
14
|
+
runWorkflowInduction,
|
|
15
|
+
type Trajectory,
|
|
16
|
+
trajectoryFromRunState,
|
|
17
|
+
type WorkflowInducer,
|
|
18
|
+
} from '../consolidator/phases/induce.js';
|
|
19
|
+
import {
|
|
20
|
+
ProcedureInductionNotConfiguredError,
|
|
21
|
+
QuarantinePromotionRefusedError,
|
|
22
|
+
} from '../errors/index.js';
|
|
23
|
+
import { newMemoryId } from '../internal/id.js';
|
|
24
|
+
import { detectMemoryInjection } from '../internal/injection-heuristics.js';
|
|
25
|
+
import { withMemorySpan } from '../internal/spans.js';
|
|
26
|
+
import type { MemoryStoreAdapter } from '../internal/storage-adapter.js';
|
|
27
|
+
|
|
28
|
+
/** Default priority for an *induced* procedure - below the author default
|
|
29
|
+
* (50) because an induced, still-quarantined procedure is provisional. */
|
|
30
|
+
const INDUCED_PRIORITY = 40;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Author-time rule payload accepted by {@link ProceduralMemory.define}.
|
|
34
|
+
*
|
|
35
|
+
* @stable
|
|
36
|
+
*/
|
|
37
|
+
export interface RuleInput {
|
|
38
|
+
readonly text: string;
|
|
39
|
+
/**
|
|
40
|
+
* Free-form predicate evaluated by {@link ProceduralMemory.activate}.
|
|
41
|
+
* The predicate language is intentionally narrow in v0.1: either
|
|
42
|
+
* the literal string `'always'`, or a `'topic=...'` / `'tag=...'`
|
|
43
|
+
* shorthand. Custom predicates should be expressed as a callable
|
|
44
|
+
* matched in `activate(...)`'s `customMatchers` argument.
|
|
45
|
+
*/
|
|
46
|
+
readonly condition?: string;
|
|
47
|
+
/** Default `'public'` per DEC-126 - rules are NOT user data. */
|
|
48
|
+
readonly sensitivity?: Sensitivity;
|
|
49
|
+
readonly priority?: number;
|
|
50
|
+
readonly tags?: ReadonlyArray<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Optional structured workflow payload (P2-2). Usually set by
|
|
53
|
+
* {@link ProceduralMemory.induce}, but accepted here so an author can
|
|
54
|
+
* round-trip a hand-written procedure. See {@link Rule.steps}.
|
|
55
|
+
*/
|
|
56
|
+
readonly steps?: ReadonlyArray<string>;
|
|
57
|
+
/** Variable names abstracted into {@link RuleInput.steps} (P2-2). */
|
|
58
|
+
readonly variables?: ReadonlyArray<string>;
|
|
59
|
+
/** Verifiable success criteria stored with the procedure (P2-2). */
|
|
60
|
+
readonly successCriteria?: ReadonlyArray<string>;
|
|
61
|
+
/** Principal dimension (D3). Absent ⇒ NULL (treated `'user'`). */
|
|
62
|
+
readonly owner?: MemoryOwner;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Options for {@link ProceduralMemory.induce}.
|
|
67
|
+
*
|
|
68
|
+
* @stable
|
|
69
|
+
*/
|
|
70
|
+
export interface InduceOptions {
|
|
71
|
+
/** Sensitivity of the stored procedure. Default `'internal'`. */
|
|
72
|
+
readonly sensitivity?: Sensitivity;
|
|
73
|
+
/** Priority of the stored procedure. Default `INDUCED_PRIORITY` (40). */
|
|
74
|
+
readonly priority?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Predicate context passed to {@link ProceduralMemory.activate}.
|
|
79
|
+
*
|
|
80
|
+
* @stable
|
|
81
|
+
*/
|
|
82
|
+
export interface RuleActivationContext {
|
|
83
|
+
readonly topic?: string;
|
|
84
|
+
readonly tags?: ReadonlyArray<string>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `ProceduralMemory` - standing orders activated when the agent's
|
|
89
|
+
* current context matches the rule's predicate. The activation rules
|
|
90
|
+
* are deterministic so the agent runtime + ContextEngine can render
|
|
91
|
+
* the active set into the system prompt every step.
|
|
92
|
+
*
|
|
93
|
+
* P2-2 adds {@link ProceduralMemory.induce}: distil a reusable workflow
|
|
94
|
+
* from a successful agent trajectory and store it **quarantined** (it must
|
|
95
|
+
* not drive actions until validated). Quarantined procedures are excluded
|
|
96
|
+
* from {@link ProceduralMemory.activate} but remain visible to
|
|
97
|
+
* {@link ProceduralMemory.list}.
|
|
98
|
+
*
|
|
99
|
+
* @stable
|
|
100
|
+
*/
|
|
101
|
+
export class ProceduralMemory {
|
|
102
|
+
readonly #store: MemoryStoreAdapter;
|
|
103
|
+
readonly #tracer: Tracer;
|
|
104
|
+
/** Opt-in workflow inducer (P2-2). `null` ⇒ {@link induce} throws. */
|
|
105
|
+
readonly #inducer: WorkflowInducer | null;
|
|
106
|
+
/**
|
|
107
|
+
* Promotion-by-demonstrated-success threshold (MCON-2 part 4).
|
|
108
|
+
* `null` ⇒ outcomes are counted but never auto-promote.
|
|
109
|
+
*/
|
|
110
|
+
readonly #promoteAfterSuccesses: number | null;
|
|
111
|
+
|
|
112
|
+
constructor(args: {
|
|
113
|
+
store: MemoryStoreAdapter;
|
|
114
|
+
tracer: Tracer;
|
|
115
|
+
inducer?: WorkflowInducer | null;
|
|
116
|
+
promoteAfterSuccesses?: number | null;
|
|
117
|
+
}) {
|
|
118
|
+
this.#store = args.store;
|
|
119
|
+
this.#tracer = args.tracer;
|
|
120
|
+
this.#inducer = args.inducer ?? null;
|
|
121
|
+
const k = args.promoteAfterSuccesses;
|
|
122
|
+
this.#promoteAfterSuccesses =
|
|
123
|
+
typeof k === 'number' && Number.isFinite(k) && k >= 1 ? Math.floor(k) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Record the outcome of one demonstrated reuse of a procedure
|
|
128
|
+
* (MCON-2 part 4). A success increments the rule's persistent
|
|
129
|
+
* `successCount`; when `procedurePromotion.afterSuccesses` is
|
|
130
|
+
* configured and a QUARANTINED procedure reaches the threshold it is
|
|
131
|
+
* promoted through {@link validate} - the injection gate still
|
|
132
|
+
* applies, so a flagged text refuses promotion (surfaced as
|
|
133
|
+
* `refused: true`) no matter how many successes accumulate.
|
|
134
|
+
* Failures are observed but not persisted (no negative counter yet).
|
|
135
|
+
*
|
|
136
|
+
* Callers decide what "success" means - typically
|
|
137
|
+
* `checkSuccessCriteria(...)` over the procedure's stored
|
|
138
|
+
* `successCriteria`, or an operator's judgement.
|
|
139
|
+
*
|
|
140
|
+
* @stable
|
|
141
|
+
*/
|
|
142
|
+
async recordOutcome(
|
|
143
|
+
scope: SessionScope,
|
|
144
|
+
ruleId: string,
|
|
145
|
+
succeeded: boolean,
|
|
146
|
+
): Promise<{
|
|
147
|
+
readonly successCount: number;
|
|
148
|
+
readonly promoted: boolean;
|
|
149
|
+
readonly refused: boolean;
|
|
150
|
+
}> {
|
|
151
|
+
return withMemorySpan(
|
|
152
|
+
this.#tracer,
|
|
153
|
+
'memory.write.procedural',
|
|
154
|
+
scope,
|
|
155
|
+
{ 'memory.procedural.action': 'record-outcome', 'memory.procedural.rule_id': ruleId },
|
|
156
|
+
async (span) => {
|
|
157
|
+
const store = this.#store.procedural;
|
|
158
|
+
if (!succeeded || typeof store.recordSuccess !== 'function') {
|
|
159
|
+
span.setAttributes({ 'memory.procedural.outcome.counted': false });
|
|
160
|
+
return { successCount: 0, promoted: false, refused: false };
|
|
161
|
+
}
|
|
162
|
+
const successCount = await store.recordSuccess(ruleId);
|
|
163
|
+
span.setAttributes({ 'memory.procedural.outcome.success_count': successCount });
|
|
164
|
+
const threshold = this.#promoteAfterSuccesses;
|
|
165
|
+
if (threshold === null || successCount < threshold) {
|
|
166
|
+
return { successCount, promoted: false, refused: false };
|
|
167
|
+
}
|
|
168
|
+
const existing = (await store.list(scope)).find((rule) => rule.id === ruleId) ?? null;
|
|
169
|
+
if (existing === null || existing.status !== 'quarantined') {
|
|
170
|
+
return { successCount, promoted: false, refused: false };
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await this.validate(
|
|
174
|
+
scope,
|
|
175
|
+
ruleId,
|
|
176
|
+
`demonstrated-success: ${successCount} >= ${threshold}`,
|
|
177
|
+
);
|
|
178
|
+
span.setAttributes({ 'memory.procedural.outcome.promoted': true });
|
|
179
|
+
return { successCount, promoted: true, refused: false };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof QuarantinePromotionRefusedError) {
|
|
182
|
+
span.setAttributes({ 'memory.procedural.outcome.refused': true });
|
|
183
|
+
return { successCount, promoted: false, refused: true };
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Persist a rule. Returns the stored record. */
|
|
192
|
+
async define(scope: SessionScope, input: RuleInput): Promise<Rule> {
|
|
193
|
+
return withMemorySpan(this.#tracer, 'memory.write.procedural', scope, {}, async (span) => {
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
const rule: Rule = {
|
|
196
|
+
id: newMemoryId('rule'),
|
|
197
|
+
kind: 'procedural',
|
|
198
|
+
userId: scope.userId,
|
|
199
|
+
...(scope.sessionId !== undefined ? { sessionId: scope.sessionId } : {}),
|
|
200
|
+
...(scope.agentId !== undefined ? { agentId: scope.agentId } : {}),
|
|
201
|
+
sensitivity: input.sensitivity ?? 'public',
|
|
202
|
+
text: input.text,
|
|
203
|
+
...(input.condition !== undefined ? { condition: input.condition } : {}),
|
|
204
|
+
priority: input.priority ?? 50,
|
|
205
|
+
...(input.tags !== undefined ? { tags: Object.freeze([...input.tags]) } : {}),
|
|
206
|
+
...(input.steps !== undefined ? { steps: Object.freeze([...input.steps]) } : {}),
|
|
207
|
+
...(input.variables !== undefined
|
|
208
|
+
? { variables: Object.freeze([...input.variables]) }
|
|
209
|
+
: {}),
|
|
210
|
+
...(input.successCriteria !== undefined
|
|
211
|
+
? { successCriteria: Object.freeze([...input.successCriteria]) }
|
|
212
|
+
: {}),
|
|
213
|
+
...(input.owner !== undefined ? { owner: input.owner } : {}),
|
|
214
|
+
createdAt: now,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
};
|
|
217
|
+
await this.#store.procedural.add(rule);
|
|
218
|
+
span.setAttributes({
|
|
219
|
+
'memory.procedural.priority': rule.priority,
|
|
220
|
+
'memory.procedural.text_length': input.text.length,
|
|
221
|
+
});
|
|
222
|
+
return rule;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Induce a reusable procedure (P2-2) from a successful agent trajectory
|
|
228
|
+
* and store it **quarantined** + `provenance: 'induction'` (P1-4). Returns
|
|
229
|
+
* the stored {@link Rule}, or `null` when the trajectory was unsuccessful /
|
|
230
|
+
* empty or the inducer produced nothing inducible.
|
|
231
|
+
*
|
|
232
|
+
* Throws {@link ProcedureInductionNotConfiguredError} when no inducer was
|
|
233
|
+
* configured (`createMemory({ procedureInduction: { provider } })`).
|
|
234
|
+
*/
|
|
235
|
+
async induce(
|
|
236
|
+
scope: SessionScope,
|
|
237
|
+
trajectory: Trajectory,
|
|
238
|
+
opts: InduceOptions = {},
|
|
239
|
+
): Promise<Rule | null> {
|
|
240
|
+
const inducer = this.#inducer;
|
|
241
|
+
if (inducer === null) throw new ProcedureInductionNotConfiguredError();
|
|
242
|
+
return withMemorySpan(
|
|
243
|
+
this.#tracer,
|
|
244
|
+
'memory.write.procedural',
|
|
245
|
+
scope,
|
|
246
|
+
{ 'memory.procedural.action': 'induce' },
|
|
247
|
+
async (span) => {
|
|
248
|
+
const induced = await runWorkflowInduction(trajectory, inducer, {});
|
|
249
|
+
if (induced === null) {
|
|
250
|
+
span.setAttributes({ 'memory.procedural.induced': 0 });
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const rule: Rule = {
|
|
255
|
+
id: newMemoryId('rule'),
|
|
256
|
+
kind: 'procedural',
|
|
257
|
+
userId: scope.userId,
|
|
258
|
+
...(scope.sessionId !== undefined ? { sessionId: scope.sessionId } : {}),
|
|
259
|
+
...(scope.agentId !== undefined ? { agentId: scope.agentId } : {}),
|
|
260
|
+
// Induced from a run ⇒ user-derived, so 'internal' (not the public
|
|
261
|
+
// default for author-defined rules).
|
|
262
|
+
sensitivity: opts.sensitivity ?? 'internal',
|
|
263
|
+
text: renderProcedureText(induced),
|
|
264
|
+
priority: opts.priority ?? INDUCED_PRIORITY,
|
|
265
|
+
steps: Object.freeze([...induced.steps]),
|
|
266
|
+
variables: Object.freeze([...induced.variables]),
|
|
267
|
+
successCriteria: Object.freeze([...induced.successCriteria]),
|
|
268
|
+
// Highest-poisoning-risk write in the system - procedures drive
|
|
269
|
+
// actions, so it lands quarantined + provenance-tagged (P1-4).
|
|
270
|
+
provenance: 'induction' satisfies MemoryProvenance,
|
|
271
|
+
status: 'quarantined' satisfies MemoryStatus,
|
|
272
|
+
// D3: an induced procedure is the agent's own inference.
|
|
273
|
+
owner: 'agent',
|
|
274
|
+
createdAt: now,
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
};
|
|
277
|
+
await this.#store.procedural.add(rule);
|
|
278
|
+
span.setAttributes({
|
|
279
|
+
'memory.procedural.induced': 1,
|
|
280
|
+
'memory.procedural.steps': induced.steps.length,
|
|
281
|
+
'memory.procedural.variables': induced.variables.length,
|
|
282
|
+
});
|
|
283
|
+
return rule;
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convenience over {@link induce}: distil the {@link Trajectory} from a
|
|
290
|
+
* completed {@link RunState} (the agent's already-emitted run state) and
|
|
291
|
+
* induce a procedure. The success signal is `status === 'completed'`.
|
|
292
|
+
*/
|
|
293
|
+
async induceFromRun(
|
|
294
|
+
scope: SessionScope,
|
|
295
|
+
run: RunState,
|
|
296
|
+
opts: InduceOptions = {},
|
|
297
|
+
): Promise<Rule | null> {
|
|
298
|
+
return this.induce(scope, trajectoryFromRunState(run), opts);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Soft-delete a rule. */
|
|
302
|
+
async remove(scope: SessionScope, ruleId: string, reason?: string): Promise<void> {
|
|
303
|
+
await withMemorySpan(
|
|
304
|
+
this.#tracer,
|
|
305
|
+
'memory.write.procedural',
|
|
306
|
+
scope,
|
|
307
|
+
{ 'memory.procedural.action': 'remove', 'memory.procedural.rule_id': ruleId },
|
|
308
|
+
async () => {
|
|
309
|
+
await this.#store.procedural.remove(ruleId, reason);
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** List every active (non-deleted) rule for the supplied scope. */
|
|
315
|
+
async list(scope: SessionScope): Promise<ReadonlyArray<Rule>> {
|
|
316
|
+
return withMemorySpan(this.#tracer, 'memory.read.procedural', scope, {}, async (span) => {
|
|
317
|
+
const out = await this.#store.procedural.list(scope);
|
|
318
|
+
span.setAttributes({ 'memory.read.procedural.count': out.length });
|
|
319
|
+
return out;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Return the rules active under `context`. Rules without a
|
|
325
|
+
* `condition` are always active; the bundled predicate vocabulary
|
|
326
|
+
* supports the literals `'always'`, `'topic=<topic>'`, and
|
|
327
|
+
* `'tag=<tag>'`. Anything outside that grammar is treated as
|
|
328
|
+
* always-active so callers do not silently lose rules.
|
|
329
|
+
*
|
|
330
|
+
* **Quarantined procedures are excluded** (P1-4 / P2-2): an induced
|
|
331
|
+
* procedure must not drive actions until validated, so activation - which
|
|
332
|
+
* feeds the system prompt - never surfaces it.
|
|
333
|
+
*/
|
|
334
|
+
async activate(
|
|
335
|
+
scope: SessionScope,
|
|
336
|
+
context: RuleActivationContext = {},
|
|
337
|
+
): Promise<ReadonlyArray<Rule>> {
|
|
338
|
+
const rules = await this.list(scope);
|
|
339
|
+
return rules
|
|
340
|
+
.filter((rule) => rule.status !== 'quarantined' && predicateMatches(rule, context))
|
|
341
|
+
.sort((a, b) => b.priority - a.priority);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Runbook content search (D3): "find the procedure for this task" -
|
|
346
|
+
* lexical recall over rule text, as opposed to predicate
|
|
347
|
+
* {@link activate}. Returns **whole validated procedures** (the full
|
|
348
|
+
* {@link Rule} incl. steps / variables / success criteria) so a match
|
|
349
|
+
* can be followed file-style rather than re-synthesized from
|
|
350
|
+
* fragments. Quarantined (unvalidated induced) procedures are
|
|
351
|
+
* excluded - they must not drive actions - unless the inspector opts
|
|
352
|
+
* in via `includeQuarantined`.
|
|
353
|
+
*
|
|
354
|
+
* Uses the storage adapter's FTS surface when available
|
|
355
|
+
* (`procedural.search`, the default `@graphorin/store-sqlite` adapter
|
|
356
|
+
* implements it via migration 028); otherwise degrades to an offline
|
|
357
|
+
* in-memory token-overlap scan over {@link list}, so custom adapters
|
|
358
|
+
* keep working without the index.
|
|
359
|
+
*/
|
|
360
|
+
async search(
|
|
361
|
+
scope: SessionScope,
|
|
362
|
+
query: string,
|
|
363
|
+
opts: { readonly topK?: number; readonly includeQuarantined?: boolean } = {},
|
|
364
|
+
): Promise<ReadonlyArray<MemoryHit<Rule>>> {
|
|
365
|
+
return withMemorySpan(
|
|
366
|
+
this.#tracer,
|
|
367
|
+
'memory.read.procedural',
|
|
368
|
+
scope,
|
|
369
|
+
{ 'memory.procedural.action': 'search' },
|
|
370
|
+
async (span) => {
|
|
371
|
+
const topK = opts.topK ?? 5;
|
|
372
|
+
const store = this.#store.procedural;
|
|
373
|
+
if (typeof store.search === 'function') {
|
|
374
|
+
const hits = await store.search(scope, query, {
|
|
375
|
+
topK,
|
|
376
|
+
...(opts.includeQuarantined === true ? { includeQuarantined: true } : {}),
|
|
377
|
+
});
|
|
378
|
+
span.setAttributes({
|
|
379
|
+
'memory.read.procedural.count': hits.length,
|
|
380
|
+
'memory.read.procedural.search_backend': 'store',
|
|
381
|
+
});
|
|
382
|
+
return hits;
|
|
383
|
+
}
|
|
384
|
+
// Fallback: offline token-overlap scoring over list(). Coarse by
|
|
385
|
+
// design - it exists so the runbook surface degrades instead of
|
|
386
|
+
// disappearing on adapters without an FTS index.
|
|
387
|
+
const tokens = tokenizeForRunbook(query);
|
|
388
|
+
const rules = await this.list(scope);
|
|
389
|
+
const scored = rules
|
|
390
|
+
.filter((rule) => opts.includeQuarantined === true || rule.status !== 'quarantined')
|
|
391
|
+
.map((rule) => {
|
|
392
|
+
const haystack = tokenizeForRunbook(
|
|
393
|
+
[rule.text, ...(rule.steps ?? []), ...(rule.tags ?? [])].join(' '),
|
|
394
|
+
);
|
|
395
|
+
const overlap = tokens.filter((t) => haystack.includes(t)).length;
|
|
396
|
+
return { rule, overlap };
|
|
397
|
+
})
|
|
398
|
+
.filter((entry) => entry.overlap > 0)
|
|
399
|
+
.sort((a, b) => b.overlap - a.overlap || (a.rule.id < b.rule.id ? -1 : 1))
|
|
400
|
+
.slice(0, topK);
|
|
401
|
+
span.setAttributes({
|
|
402
|
+
'memory.read.procedural.count': scored.length,
|
|
403
|
+
'memory.read.procedural.search_backend': 'fallback',
|
|
404
|
+
});
|
|
405
|
+
return scored.map((entry) => ({
|
|
406
|
+
record: entry.rule,
|
|
407
|
+
score: tokens.length === 0 ? 0 : entry.overlap / tokens.length,
|
|
408
|
+
signals: { lexical: entry.overlap },
|
|
409
|
+
}));
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Promote a quarantined (induced) procedure into `activate()` (MCON-2).
|
|
416
|
+
* Mirrors {@link SemanticMemory.validate}: re-derives the injection verdict
|
|
417
|
+
* from the stored rule text and **refuses** promotion of an injection-flagged
|
|
418
|
+
* procedure unless an operator passes `{ force: true }`. Induced procedures
|
|
419
|
+
* drive *actions*, so this gate matters most for them.
|
|
420
|
+
*/
|
|
421
|
+
async validate(
|
|
422
|
+
scope: SessionScope,
|
|
423
|
+
ruleId: string,
|
|
424
|
+
reason?: string,
|
|
425
|
+
options?: { readonly force?: boolean },
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
await withMemorySpan(
|
|
428
|
+
this.#tracer,
|
|
429
|
+
'memory.write.procedural',
|
|
430
|
+
scope,
|
|
431
|
+
{ 'memory.procedural.action': 'validate', 'memory.procedural.rule_id': ruleId },
|
|
432
|
+
async (span) => {
|
|
433
|
+
const store = this.#store.procedural;
|
|
434
|
+
if (typeof store.setStatus !== 'function') {
|
|
435
|
+
throw new TypeError(
|
|
436
|
+
'[graphorin/memory] ProceduralMemory.validate(...) requires a storage adapter that implements `procedural.setStatus(id, status)`. ' +
|
|
437
|
+
'The default `@graphorin/store-sqlite` adapter implements it; custom adapters can opt in via ProceduralMemoryStoreExt.',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const force = options?.force === true;
|
|
441
|
+
// No store get-by-id for rules - list() already surfaces quarantined.
|
|
442
|
+
const existing = (await store.list(scope)).find((rule) => rule.id === ruleId) ?? null;
|
|
443
|
+
if (existing !== null && !force) {
|
|
444
|
+
const injection = detectMemoryInjection(existing.text);
|
|
445
|
+
if (injection.flagged) {
|
|
446
|
+
span.setAttributes({ 'memory.procedural.validate.refused': true });
|
|
447
|
+
throw new QuarantinePromotionRefusedError(ruleId, injection.markers);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
span.setAttributes({ 'memory.procedural.validate.forced': force });
|
|
451
|
+
await store.setStatus(ruleId, 'active', reason, scope);
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Render an induced procedure into the human-readable `Rule.text`. */
|
|
458
|
+
function renderProcedureText(procedure: InducedProcedure): string {
|
|
459
|
+
const lines = [procedure.title, ...procedure.steps.map((step, i) => `${i + 1}. ${step}`)];
|
|
460
|
+
return lines.join('\n');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Lowercased word tokens for the offline runbook-search fallback (D3). */
|
|
464
|
+
function tokenizeForRunbook(text: string): ReadonlyArray<string> {
|
|
465
|
+
return text
|
|
466
|
+
.toLowerCase()
|
|
467
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
468
|
+
.filter((token) => token.length > 1);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function predicateMatches(rule: Rule, context: RuleActivationContext): boolean {
|
|
472
|
+
if (rule.condition === undefined || rule.condition === 'always') return true;
|
|
473
|
+
if (rule.condition.startsWith('topic=')) {
|
|
474
|
+
const wanted = rule.condition.slice('topic='.length).trim();
|
|
475
|
+
return context.topic === wanted;
|
|
476
|
+
}
|
|
477
|
+
if (rule.condition.startsWith('tag=')) {
|
|
478
|
+
const wanted = rule.condition.slice('tag='.length).trim();
|
|
479
|
+
return (context.tags ?? []).includes(wanted);
|
|
480
|
+
}
|
|
481
|
+
return true;
|
|
482
|
+
}
|