@graphorin/memory 0.6.0 → 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 +83 -0
- package/README.md +24 -7
- 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 +17 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -7
- 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 +6 -0
- package/dist/package.js.map +1 -0
- 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,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-compaction subsystem core (RB-46 / suggested DEC-162 /
|
|
3
|
+
* ADR-050). Trims the in-flight ContextEngine message buffer when
|
|
4
|
+
* the assembled token count crosses a per-provider threshold; the
|
|
5
|
+
* trim is summarized via the configured section template; the
|
|
6
|
+
* trim is followed by synchronous-await firing of registered
|
|
7
|
+
* post-compaction hooks (lifecycle owned by Phase 12; this module
|
|
8
|
+
* exposes the trim primitive).
|
|
9
|
+
*
|
|
10
|
+
* Disjoint from the Phase 10c consolidator: never writes to
|
|
11
|
+
* `semantic_facts` / `episodic_episodes` / `procedural_rules`;
|
|
12
|
+
* reads only `memory.metadata(...)` / `memory.semantic.search(...)`
|
|
13
|
+
* / `memory.procedural.activate(...)` / `memory.working.read(...)`
|
|
14
|
+
* (non-mutating).
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Message, ModelSpec, SystemMessage } from '@graphorin/core';
|
|
20
|
+
import {
|
|
21
|
+
type ImperativeScanResult as ScanResult,
|
|
22
|
+
scanImperativePatterns,
|
|
23
|
+
} from '@graphorin/observability/redaction';
|
|
24
|
+
import { UNTRUSTED_CONTENT_OPEN_PREFIX, wrapUntrusted } from '../../internal/envelope.js';
|
|
25
|
+
import type { ContextLocalePack } from '../locale-packs/index.js';
|
|
26
|
+
import {
|
|
27
|
+
type ContextTokenCounter,
|
|
28
|
+
countMessageTokens,
|
|
29
|
+
HEURISTIC_TOKEN_COUNTER,
|
|
30
|
+
renderMessageText,
|
|
31
|
+
} from '../token-counter.js';
|
|
32
|
+
import { clearOldToolResults } from './clear-tool-results.js';
|
|
33
|
+
import {
|
|
34
|
+
buildSummarizerPrompt,
|
|
35
|
+
type CompactionMetadataPayload,
|
|
36
|
+
renderFinalSummary,
|
|
37
|
+
SUMMARY_TEMPLATE_NAME,
|
|
38
|
+
SUMMARY_TEMPLATE_VERSION,
|
|
39
|
+
} from './templates/summary-9-section.js';
|
|
40
|
+
import type {
|
|
41
|
+
CompactionContext,
|
|
42
|
+
CompactionResult,
|
|
43
|
+
CompactionSource,
|
|
44
|
+
CompactionStrategy,
|
|
45
|
+
CompactionSummarizer,
|
|
46
|
+
} from './types.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default count of recent turns preserved verbatim by the
|
|
50
|
+
* `summarize-old-preserve-recent` strategy.
|
|
51
|
+
*
|
|
52
|
+
* @stable
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_PRESERVE_RECENT_TURNS = 6;
|
|
55
|
+
|
|
56
|
+
/** Opening marker of the inbound-untrusted envelope (tools sanitize layer). */
|
|
57
|
+
const UNTRUSTED_MARKER = UNTRUSTED_CONTENT_OPEN_PREFIX;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wall-clock budget for the CE-15 injection scan of the summarizer
|
|
61
|
+
* output. The scanner's 5ms default exists for the per-tool-result
|
|
62
|
+
* hot path; a compaction already paid for an LLM call, the summary
|
|
63
|
+
* body is a single bounded text, and the pattern catalogue is fixed,
|
|
64
|
+
* so the scan here runs UNBUDGETED - a wall-clock budget only turned
|
|
65
|
+
* scheduler noise into a `null` verdict (a 50ms budget was observed
|
|
66
|
+
* expiring on a degraded CI runner, silently failing the check open).
|
|
67
|
+
*/
|
|
68
|
+
const COMPACTION_SCAN_BUDGET_MS = Number.POSITIVE_INFINITY;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* CE-15 trust decision for a freshly produced summary. Exported for
|
|
72
|
+
* unit tests because the `scan === null` branch (scanner budget
|
|
73
|
+
* exceeded) MUST fail closed: the scanner's contract is "null = the
|
|
74
|
+
* caller applies its own best-effort degradation", and treating null
|
|
75
|
+
* as "no hits" would commit a poisoned summary as trusted exactly on
|
|
76
|
+
* the contended hosts where the budget expires.
|
|
77
|
+
*
|
|
78
|
+
* @internal
|
|
79
|
+
*/
|
|
80
|
+
export function resolveSummaryTrust(
|
|
81
|
+
windowUntrusted: boolean,
|
|
82
|
+
scan: ScanResult | null,
|
|
83
|
+
): 'trusted' | 'untrusted-derived' {
|
|
84
|
+
if (windowUntrusted) return 'untrusted-derived';
|
|
85
|
+
if (scan === null) return 'untrusted-derived';
|
|
86
|
+
return scan.hits.length > 0 ? 'untrusted-derived' : 'trusted';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** CE-15: does the compacted window carry inbound-untrusted envelopes? */
|
|
90
|
+
function windowContainsUntrusted(messages: ReadonlyArray<Message>): boolean {
|
|
91
|
+
return messages.some((message) => renderMessageText(message).includes(UNTRUSTED_MARKER));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* CE-15: wrap the LLM-authored summary body in a derived-trust
|
|
96
|
+
* envelope. Envelope marker sequences inside the body are neutralized
|
|
97
|
+
* first so summarizer output influenced by injected text cannot break
|
|
98
|
+
* out of the envelope and masquerade as authoritative system text.
|
|
99
|
+
* Delegates to the shared `internal/envelope.ts` helper (W-083) so the
|
|
100
|
+
* whole memory package neutralizes markers with one scheme; the
|
|
101
|
+
* output is byte-identical to the historical inline implementation on
|
|
102
|
+
* literal-marker inputs.
|
|
103
|
+
*/
|
|
104
|
+
function wrapSummaryAsDerived(body: string): string {
|
|
105
|
+
return wrapUntrusted(body, { trust: 'derived', tool: 'compaction-summarizer' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Trim the in-flight buffer using the
|
|
110
|
+
* `summarize-old-preserve-recent` strategy. Returns the trimmed
|
|
111
|
+
* messages + summary metadata; the caller is responsible for
|
|
112
|
+
* appending the summary to the message buffer (Phase 12 owns the
|
|
113
|
+
* lifecycle; this module exposes the primitive).
|
|
114
|
+
*
|
|
115
|
+
* @stable
|
|
116
|
+
*/
|
|
117
|
+
export interface ExecuteCompactionInput {
|
|
118
|
+
readonly messages: ReadonlyArray<Message>;
|
|
119
|
+
readonly source: CompactionSource;
|
|
120
|
+
readonly strategy: CompactionStrategy;
|
|
121
|
+
readonly localePack: ContextLocalePack;
|
|
122
|
+
readonly summarizer: CompactionSummarizer;
|
|
123
|
+
readonly tokenCounter?: ContextTokenCounter;
|
|
124
|
+
readonly thresholdTokens: number;
|
|
125
|
+
readonly runId: string;
|
|
126
|
+
readonly sessionId: string;
|
|
127
|
+
readonly agentId: string;
|
|
128
|
+
readonly scope: import('@graphorin/core').SessionScope;
|
|
129
|
+
readonly providerTrust?: import('@graphorin/core').LocalProviderTrust;
|
|
130
|
+
readonly now?: () => number;
|
|
131
|
+
readonly signal?: AbortSignal;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Perform a compaction call. Returns the result envelope containing
|
|
136
|
+
* the produced summary, the dropped/preserved message slices, and
|
|
137
|
+
* the per-event metadata. Phase 12 / `agent.compact()` is the
|
|
138
|
+
* lifecycle owner; this function is the trim primitive.
|
|
139
|
+
*
|
|
140
|
+
* @stable
|
|
141
|
+
*/
|
|
142
|
+
export async function executeCompaction(input: ExecuteCompactionInput): Promise<CompactionResult> {
|
|
143
|
+
const counter = input.tokenCounter ?? HEURISTIC_TOKEN_COUNTER;
|
|
144
|
+
const now = input.now ?? Date.now;
|
|
145
|
+
const startTs = now();
|
|
146
|
+
const beforeTokens = await countMessageTokens(input.messages, counter);
|
|
147
|
+
|
|
148
|
+
if (input.strategy.kind === 'custom') {
|
|
149
|
+
const ctx: CompactionContext = {
|
|
150
|
+
runId: input.runId,
|
|
151
|
+
sessionId: input.sessionId,
|
|
152
|
+
agentId: input.agentId,
|
|
153
|
+
scope: input.scope,
|
|
154
|
+
source: input.source,
|
|
155
|
+
messages: input.messages,
|
|
156
|
+
beforeTokens,
|
|
157
|
+
thresholdTokens: input.thresholdTokens,
|
|
158
|
+
preserveRecentTurns: DEFAULT_PRESERVE_RECENT_TURNS,
|
|
159
|
+
providerTrust: input.providerTrust ?? 'public-tls',
|
|
160
|
+
};
|
|
161
|
+
const result = await input.strategy.compact(ctx);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (input.strategy.kind === 'clear-old-tool-results') {
|
|
166
|
+
return executeClearStrategy(input, input.strategy, counter, now, startTs, beforeTokens);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const preserveRecentTurns = input.strategy.preserveRecentTurns ?? DEFAULT_PRESERVE_RECENT_TURNS;
|
|
170
|
+
const summarizerModelInput = input.strategy.summarizerModel;
|
|
171
|
+
const summarizerTimeoutMs = input.strategy.summarizerTimeoutMs;
|
|
172
|
+
|
|
173
|
+
// Slice the older portion + preserve the recent N turns. We treat
|
|
174
|
+
// a "turn" as a single message for the v0.1 surface; the spec
|
|
175
|
+
// notes more nuanced segmentation is post-MVP per Q-121.
|
|
176
|
+
let olderCount = Math.max(0, input.messages.length - preserveRecentTurns);
|
|
177
|
+
// context-engine-01: never split an assistant/tool pair. A purely
|
|
178
|
+
// positional slice can strand a `role:'tool'` message at the head of
|
|
179
|
+
// the preserved window while its `tool_calls` assistant partner is
|
|
180
|
+
// summarized away - OpenAI-compatible servers 400 on the orphan and
|
|
181
|
+
// `invalid-request` is fallback-ineligible, so the run would die right
|
|
182
|
+
// after an otherwise successful compaction. Snap the boundary backward
|
|
183
|
+
// until the preserved slice starts on a non-`tool` message: preserving
|
|
184
|
+
// more than requested is always transcript-safe. (The
|
|
185
|
+
// clear-tool-results strategy is immune by construction - it replaces
|
|
186
|
+
// content in place and never removes messages.)
|
|
187
|
+
while (olderCount > 0 && input.messages[olderCount]?.role === 'tool') {
|
|
188
|
+
olderCount -= 1;
|
|
189
|
+
}
|
|
190
|
+
const olderRaw = input.messages.slice(0, olderCount);
|
|
191
|
+
const preservedTail = input.messages.slice(olderCount);
|
|
192
|
+
|
|
193
|
+
// C4: keep the most recent USER messages verbatim across compaction -
|
|
194
|
+
// only assistant/tool content gets summarized away. User words are the
|
|
195
|
+
// task statement; a paraphrase in the summary routinely drops the
|
|
196
|
+
// constraint that mattered. The carried messages are removed from the
|
|
197
|
+
// summarizer's input (no double-counting) and re-inserted between the
|
|
198
|
+
// summary and the preserved tail, keeping their relative order.
|
|
199
|
+
const preserveUserMessages = Math.max(0, input.strategy.preserveUserMessages ?? 2);
|
|
200
|
+
const carriedUserIndices = new Set<number>();
|
|
201
|
+
if (preserveUserMessages > 0) {
|
|
202
|
+
for (let i = olderRaw.length - 1; i >= 0 && carriedUserIndices.size < preserveUserMessages; ) {
|
|
203
|
+
if (olderRaw[i]?.role === 'user') carriedUserIndices.add(i);
|
|
204
|
+
i -= 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Degenerate guard: on a tiny (user-dominated) window, carrying users
|
|
208
|
+
// could empty the summarizable slice and turn the whole compaction into
|
|
209
|
+
// a no-op - there, positional summarization wins.
|
|
210
|
+
if (carriedUserIndices.size > 0 && carriedUserIndices.size === olderRaw.length) {
|
|
211
|
+
carriedUserIndices.clear();
|
|
212
|
+
}
|
|
213
|
+
const olderMessages = olderRaw.filter((_, idx) => !carriedUserIndices.has(idx));
|
|
214
|
+
const carriedUserMessages = olderRaw.filter((_, idx) => carriedUserIndices.has(idx));
|
|
215
|
+
const preservedMessages = [...carriedUserMessages, ...preservedTail];
|
|
216
|
+
|
|
217
|
+
if (olderMessages.length === 0) {
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
summary: '',
|
|
220
|
+
summaryTokens: 0,
|
|
221
|
+
beforeTokens,
|
|
222
|
+
afterTokens: beforeTokens,
|
|
223
|
+
droppedMessageIds: Object.freeze([]),
|
|
224
|
+
droppedMessageIndices: Object.freeze([]),
|
|
225
|
+
preservedMessages: Object.freeze([...preservedMessages]),
|
|
226
|
+
trimmedMessages: Object.freeze([...preservedMessages]),
|
|
227
|
+
source: input.source,
|
|
228
|
+
durationMs: now() - startTs,
|
|
229
|
+
hooksFiredCount: 0,
|
|
230
|
+
summaryTrust: 'trusted',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const template = {
|
|
235
|
+
preamble: input.localePack.compactionSummaryTemplate.preamble,
|
|
236
|
+
sections: input.localePack.compactionSummaryTemplate.sections,
|
|
237
|
+
};
|
|
238
|
+
const prompt = buildSummarizerPrompt({
|
|
239
|
+
template,
|
|
240
|
+
olderMessages,
|
|
241
|
+
...(input.strategy.summarizerInputCharBudget !== undefined
|
|
242
|
+
? { maxDumpChars: input.strategy.summarizerInputCharBudget }
|
|
243
|
+
: {}),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const summarizerInput: {
|
|
247
|
+
prompt: string;
|
|
248
|
+
model?: ModelSpec | string;
|
|
249
|
+
signal?: AbortSignal;
|
|
250
|
+
timeoutMs?: number;
|
|
251
|
+
} = { prompt };
|
|
252
|
+
if (summarizerModelInput !== undefined) summarizerInput.model = summarizerModelInput;
|
|
253
|
+
if (input.signal !== undefined) summarizerInput.signal = input.signal;
|
|
254
|
+
if (summarizerTimeoutMs !== undefined) summarizerInput.timeoutMs = summarizerTimeoutMs;
|
|
255
|
+
const summarized = await input.summarizer.summarize(summarizerInput);
|
|
256
|
+
|
|
257
|
+
// Section 9 metadata payload - stable shape so consumers can
|
|
258
|
+
// deserialize and reason about a compaction event.
|
|
259
|
+
const metadata: CompactionMetadataPayload = {
|
|
260
|
+
compactedAtIso: new Date(startTs).toISOString(),
|
|
261
|
+
// CE-14: positional labels, prefixed with the compaction instant so
|
|
262
|
+
// they no longer collide across compaction events (core Message has
|
|
263
|
+
// no id - these are indices into THIS compaction's dropped slice).
|
|
264
|
+
compactedFromMessageIds: Object.freeze(olderMessages.map((_, idx) => `c${startTs}_msg_${idx}`)),
|
|
265
|
+
compactedFromMessageIndices: Object.freeze(olderMessages.map((_, idx) => idx)),
|
|
266
|
+
compactedFromTokens: await countMessageTokens(olderMessages, counter),
|
|
267
|
+
summaryTokens: summarized.usageTokens ?? (await counter.countText(summarized.text)),
|
|
268
|
+
summarizerModel: resolveSummarizerModelLabel(summarizerModelInput, input.summarizer.id),
|
|
269
|
+
templateName: SUMMARY_TEMPLATE_NAME,
|
|
270
|
+
templateVersion: SUMMARY_TEMPLATE_VERSION,
|
|
271
|
+
preserveRecentTurns,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// CE-15: the summary must not launder untrusted content into a
|
|
275
|
+
// trusted system message. When the compacted window carried
|
|
276
|
+
// untrusted envelopes - or the injection heuristics flag the
|
|
277
|
+
// summarizer output itself - the LLM-authored body is committed
|
|
278
|
+
// inside a derived-trust envelope (sticky across re-compactions:
|
|
279
|
+
// the envelope re-triggers this detection next time around).
|
|
280
|
+
const summaryScan = scanImperativePatterns(summarized.text, undefined, COMPACTION_SCAN_BUDGET_MS);
|
|
281
|
+
const summaryTrust = resolveSummaryTrust(windowContainsUntrusted(olderMessages), summaryScan);
|
|
282
|
+
const finalSummary = renderFinalSummary({
|
|
283
|
+
template,
|
|
284
|
+
summaryFromLlm:
|
|
285
|
+
summaryTrust === 'untrusted-derived'
|
|
286
|
+
? wrapSummaryAsDerived(summarized.text)
|
|
287
|
+
: summarized.text,
|
|
288
|
+
preservedMessages,
|
|
289
|
+
metadata,
|
|
290
|
+
});
|
|
291
|
+
const summaryTokens = await counter.countText(finalSummary);
|
|
292
|
+
|
|
293
|
+
const summaryMessage: SystemMessage = {
|
|
294
|
+
role: 'system',
|
|
295
|
+
content: finalSummary,
|
|
296
|
+
};
|
|
297
|
+
const trimmedMessages: ReadonlyArray<Message> = Object.freeze([
|
|
298
|
+
summaryMessage,
|
|
299
|
+
...preservedMessages,
|
|
300
|
+
]);
|
|
301
|
+
const afterTokens = await countMessageTokens(trimmedMessages, counter);
|
|
302
|
+
const durationMs = now() - startTs;
|
|
303
|
+
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
summary: finalSummary,
|
|
306
|
+
summaryTokens,
|
|
307
|
+
beforeTokens,
|
|
308
|
+
afterTokens,
|
|
309
|
+
droppedMessageIds: metadata.compactedFromMessageIds,
|
|
310
|
+
droppedMessageIndices: metadata.compactedFromMessageIndices,
|
|
311
|
+
preservedMessages: Object.freeze([...preservedMessages]),
|
|
312
|
+
trimmedMessages,
|
|
313
|
+
source: input.source,
|
|
314
|
+
durationMs,
|
|
315
|
+
hooksFiredCount: 0,
|
|
316
|
+
summaryTrust,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* SOTA-1: clear the oldest tool results (zero-LLM), then summarize only if the
|
|
322
|
+
* cleared buffer is still over the threshold. The summarizer runs on the
|
|
323
|
+
* already-reduced window, so a few-tool-result buffer compacts for free.
|
|
324
|
+
*/
|
|
325
|
+
async function executeClearStrategy(
|
|
326
|
+
input: ExecuteCompactionInput,
|
|
327
|
+
strategy: Extract<CompactionStrategy, { kind: 'clear-old-tool-results' }>,
|
|
328
|
+
counter: ContextTokenCounter,
|
|
329
|
+
now: () => number,
|
|
330
|
+
startTs: number,
|
|
331
|
+
beforeTokens: number,
|
|
332
|
+
): Promise<CompactionResult> {
|
|
333
|
+
const outcome = await clearOldToolResults(
|
|
334
|
+
input.messages,
|
|
335
|
+
{
|
|
336
|
+
...(strategy.keepToolUses !== undefined ? { keepToolUses: strategy.keepToolUses } : {}),
|
|
337
|
+
...(strategy.clearAtLeast !== undefined ? { clearAtLeast: strategy.clearAtLeast } : {}),
|
|
338
|
+
...(strategy.excludeTools !== undefined ? { excludeTools: strategy.excludeTools } : {}),
|
|
339
|
+
...(strategy.externalize !== undefined ? { externalize: strategy.externalize } : {}),
|
|
340
|
+
...(strategy.clearToolInputs !== undefined
|
|
341
|
+
? { clearToolInputs: strategy.clearToolInputs }
|
|
342
|
+
: {}),
|
|
343
|
+
...(strategy.readResultToolName !== undefined
|
|
344
|
+
? { readResultToolName: strategy.readResultToolName }
|
|
345
|
+
: {}),
|
|
346
|
+
},
|
|
347
|
+
counter,
|
|
348
|
+
);
|
|
349
|
+
const afterClearTokens = await countMessageTokens(outcome.messages, counter);
|
|
350
|
+
|
|
351
|
+
const fallback = strategy.summarizeFallback;
|
|
352
|
+
if (fallback !== false && afterClearTokens > input.thresholdTokens) {
|
|
353
|
+
// Clearing did not reclaim enough - summarize the ALREADY-cleared buffer.
|
|
354
|
+
const summarized = await executeCompaction({
|
|
355
|
+
...input,
|
|
356
|
+
messages: outcome.messages,
|
|
357
|
+
strategy: { kind: 'summarize-old-preserve-recent', ...(fallback ?? {}) },
|
|
358
|
+
});
|
|
359
|
+
return Object.freeze({
|
|
360
|
+
...summarized,
|
|
361
|
+
// Report against the original buffer + carry the cleared indices.
|
|
362
|
+
beforeTokens,
|
|
363
|
+
droppedMessageIndices: Object.freeze([
|
|
364
|
+
...outcome.clearedIndices,
|
|
365
|
+
...summarized.droppedMessageIndices.filter((i) => !outcome.clearedIndices.includes(i)),
|
|
366
|
+
]),
|
|
367
|
+
durationMs: now() - startTs,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Zero-LLM clear-only outcome: content replaced in place, nothing summarized.
|
|
372
|
+
const clearedSet = new Set(outcome.clearedIndices);
|
|
373
|
+
const preservedMessages = outcome.messages.filter((_, i) => !clearedSet.has(i));
|
|
374
|
+
return Object.freeze({
|
|
375
|
+
summary: '',
|
|
376
|
+
summaryTokens: 0,
|
|
377
|
+
beforeTokens,
|
|
378
|
+
afterTokens: afterClearTokens,
|
|
379
|
+
droppedMessageIds: Object.freeze(outcome.clearedIndices.map((i) => `c${startTs}_cleared_${i}`)),
|
|
380
|
+
droppedMessageIndices: Object.freeze([...outcome.clearedIndices]),
|
|
381
|
+
preservedMessages: Object.freeze(preservedMessages),
|
|
382
|
+
trimmedMessages: Object.freeze([...outcome.messages]),
|
|
383
|
+
source: input.source,
|
|
384
|
+
durationMs: now() - startTs,
|
|
385
|
+
hooksFiredCount: 0,
|
|
386
|
+
summaryTrust: 'trusted',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resolveSummarizerModelLabel(
|
|
391
|
+
modelInput: ModelSpec | string | undefined,
|
|
392
|
+
summarizerId: string | undefined,
|
|
393
|
+
): string | null {
|
|
394
|
+
if (typeof modelInput === 'string') return modelInput;
|
|
395
|
+
if (modelInput !== undefined) {
|
|
396
|
+
const provider =
|
|
397
|
+
'provider' in modelInput
|
|
398
|
+
? `${modelInput.provider.name}:${modelInput.model}`
|
|
399
|
+
: `${modelInput.name}:${modelInput.modelId}`;
|
|
400
|
+
return provider;
|
|
401
|
+
}
|
|
402
|
+
return summarizerId ?? null;
|
|
403
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reanchorPersonaBlock({ blockLabel = 'persona' })` - built-in
|
|
3
|
+
* post-compaction hook. Queries `memory.working.read(...)` for the
|
|
4
|
+
* persona block and returns it as a system-content fragment for
|
|
5
|
+
* re-injection.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MessageContent } from '@graphorin/core';
|
|
11
|
+
import type { HookDeps, NamedPostCompactionHook } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a `reanchorPersonaBlock` hook.
|
|
15
|
+
*
|
|
16
|
+
* @stable
|
|
17
|
+
*/
|
|
18
|
+
export function reanchorPersonaBlock(
|
|
19
|
+
options: { readonly blockLabel?: string } = {},
|
|
20
|
+
): NamedPostCompactionHook {
|
|
21
|
+
const blockLabel = options.blockLabel ?? 'persona';
|
|
22
|
+
return {
|
|
23
|
+
id: 'reanchorPersonaBlock',
|
|
24
|
+
async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
|
|
25
|
+
const value = await deps.memory.working.read(deps.scope, blockLabel);
|
|
26
|
+
if (value === null || value.length === 0) return [];
|
|
27
|
+
// context-engine-02: the D2 privacy filter gates re-anchored content
|
|
28
|
+
// exactly as it gates the assembled prompt - a secret-tier persona
|
|
29
|
+
// block the assembly withheld must not ship to the provider via the
|
|
30
|
+
// post-compaction splice. `read()` returns only the value, so the
|
|
31
|
+
// sensitivity comes from the block listing; a block absent from the
|
|
32
|
+
// listing (unwritten, default-value fallback) is evaluated at the
|
|
33
|
+
// engine's default sensitivity.
|
|
34
|
+
if (deps.allowSensitivity !== undefined) {
|
|
35
|
+
const blocks = await deps.memory.working.list(deps.scope);
|
|
36
|
+
const block = blocks.find((b) => b.label === blockLabel);
|
|
37
|
+
if (!deps.allowSensitivity(block?.sensitivity)) return [];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: 'text',
|
|
42
|
+
text: `<memory_blocks anchor="post-compaction">\n <block label="${escapeXml(blockLabel)}">\n ${escapeXml(value)}\n </block>\n</memory_blocks>`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeXml(value: string): string {
|
|
50
|
+
return value
|
|
51
|
+
.replace(/&/g, '&')
|
|
52
|
+
.replace(/</g, '<')
|
|
53
|
+
.replace(/>/g, '>')
|
|
54
|
+
.replace(/"/g, '"');
|
|
55
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reanchorPinnedFacts({ pinnedFactIds, maxTokens })` - built-in
|
|
3
|
+
* post-compaction hook. Looks up the explicitly-pinned fact IDs
|
|
4
|
+
* via `memory.semantic.get(...)` and returns them as a
|
|
5
|
+
* system-content fragment with their tags.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MessageContent } from '@graphorin/core';
|
|
11
|
+
import { type ContextTokenCounter, HEURISTIC_TOKEN_COUNTER } from '../../token-counter.js';
|
|
12
|
+
import type { HookDeps, NamedPostCompactionHook } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a `reanchorPinnedFacts` hook.
|
|
16
|
+
*
|
|
17
|
+
* @stable
|
|
18
|
+
*/
|
|
19
|
+
export function reanchorPinnedFacts(options: {
|
|
20
|
+
readonly pinnedFactIds: ReadonlyArray<string>;
|
|
21
|
+
readonly maxTokens?: number;
|
|
22
|
+
readonly tokenCounter?: ContextTokenCounter;
|
|
23
|
+
}): NamedPostCompactionHook {
|
|
24
|
+
const ids = options.pinnedFactIds;
|
|
25
|
+
const maxTokens = Math.max(0, options.maxTokens ?? 2000);
|
|
26
|
+
const counter = options.tokenCounter ?? HEURISTIC_TOKEN_COUNTER;
|
|
27
|
+
return {
|
|
28
|
+
id: 'reanchorPinnedFacts',
|
|
29
|
+
async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
|
|
30
|
+
if (ids.length === 0) return [];
|
|
31
|
+
const lines = ['<memory_pinned_facts anchor="post-compaction">'];
|
|
32
|
+
let runningTokens = 0;
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
const fact = await deps.memory.semantic.get(deps.scope, id).catch(() => null);
|
|
35
|
+
if (fact === null || fact.deletedAt !== undefined) continue;
|
|
36
|
+
// context-engine-02: pinning is not a privacy override - a
|
|
37
|
+
// secret-tier fact the D2 filter withholds from the assembled
|
|
38
|
+
// prompt must not re-enter via the post-compaction splice.
|
|
39
|
+
if (deps.allowSensitivity !== undefined && !deps.allowSensitivity(fact.sensitivity)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const tagBlob =
|
|
43
|
+
fact.tags !== undefined ? ` tags="${escapeXml((fact.tags ?? []).join(','))}"` : '';
|
|
44
|
+
const fragment = ` <fact id="${escapeXml(fact.id)}"${tagBlob}>${escapeXml(fact.text)}</fact>`;
|
|
45
|
+
const fragmentTokens = await counter.countText(fragment);
|
|
46
|
+
if (maxTokens > 0 && runningTokens + fragmentTokens > maxTokens) {
|
|
47
|
+
lines.push(' <!-- additional pinned facts truncated to fit budget -->');
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
runningTokens += fragmentTokens;
|
|
51
|
+
lines.push(fragment);
|
|
52
|
+
}
|
|
53
|
+
if (lines.length === 1) return [];
|
|
54
|
+
lines.push('</memory_pinned_facts>');
|
|
55
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeXml(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/&/g, '&')
|
|
63
|
+
.replace(/</g, '<')
|
|
64
|
+
.replace(/>/g, '>')
|
|
65
|
+
.replace(/"/g, '"');
|
|
66
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reanchorProjectRules({ ruleTagsAllowlist? })` - built-in
|
|
3
|
+
* post-compaction hook. Queries `memory.procedural.activate(...)`
|
|
4
|
+
* and returns the active rule set as a system-content fragment for
|
|
5
|
+
* re-injection into the trimmed buffer.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MessageContent } from '@graphorin/core';
|
|
11
|
+
import type { HookDeps, NamedPostCompactionHook } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a `reanchorProjectRules` hook.
|
|
15
|
+
*
|
|
16
|
+
* @stable
|
|
17
|
+
*/
|
|
18
|
+
export function reanchorProjectRules(
|
|
19
|
+
options: { readonly ruleTagsAllowlist?: ReadonlyArray<string> } = {},
|
|
20
|
+
): NamedPostCompactionHook {
|
|
21
|
+
const allowlist =
|
|
22
|
+
options.ruleTagsAllowlist !== undefined ? new Set(options.ruleTagsAllowlist) : null;
|
|
23
|
+
return {
|
|
24
|
+
id: 'reanchorProjectRules',
|
|
25
|
+
async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
|
|
26
|
+
const procedural = deps.procedural ?? {};
|
|
27
|
+
const rules = await deps.memory.procedural.activate(deps.scope, procedural);
|
|
28
|
+
// context-engine-02: apply the same D2 privacy decision `assemble()`
|
|
29
|
+
// applies to active rules - the hook's output ships to the provider.
|
|
30
|
+
const visible =
|
|
31
|
+
deps.allowSensitivity === undefined
|
|
32
|
+
? rules
|
|
33
|
+
: rules.filter((rule) => deps.allowSensitivity?.(rule.sensitivity) === true);
|
|
34
|
+
const filtered =
|
|
35
|
+
allowlist === null
|
|
36
|
+
? visible
|
|
37
|
+
: visible.filter((rule) => (rule.tags ?? []).some((tag) => allowlist.has(tag)));
|
|
38
|
+
if (filtered.length === 0) return [];
|
|
39
|
+
const lines = ['<memory_rules anchor="post-compaction">'];
|
|
40
|
+
for (const rule of filtered) {
|
|
41
|
+
lines.push(
|
|
42
|
+
` <rule priority="${rule.priority}" tags="${escapeXml((rule.tags ?? []).join(','))}">${escapeXml(rule.text)}</rule>`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
lines.push('</memory_rules>');
|
|
46
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeXml(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"');
|
|
57
|
+
}
|