源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { RuntimeEvent } from '@maka/core/runtime-event';
|
||||
import type { ModelMessage } from './model-protocol.js';
|
||||
|
||||
export interface MemoryExtractionEventEntry {
|
||||
readonly ordinal: number;
|
||||
readonly event: RuntimeEvent;
|
||||
}
|
||||
|
||||
export interface MemoryExtractionEvidence {
|
||||
readonly sourceRef: string;
|
||||
readonly type: 'user_message';
|
||||
/** Exact bounded text shown to the model and used for admission. */
|
||||
readonly text: string;
|
||||
readonly events: readonly RuntimeEvent[];
|
||||
/** Full text actually visible in indexed Provider user messages. */
|
||||
readonly providerVisibleTexts?: readonly string[];
|
||||
}
|
||||
|
||||
export interface MemoryCoveragePlan {
|
||||
readonly entries: readonly MemoryExtractionEventEntry[];
|
||||
readonly evidence: readonly MemoryExtractionEvidence[];
|
||||
}
|
||||
|
||||
export const MAX_MEMORY_EVIDENCE_JSON_CHARS = 12_000;
|
||||
const MAX_EVIDENCE_TEXT_CHARS = 4_000;
|
||||
const MIN_EVIDENCE_TEXT_CHARS = 64;
|
||||
const MAX_LOCALIZED_TURNS = 7;
|
||||
const MAX_LOCALIZED_CONTEXT_CHARS = 12_000;
|
||||
const MAX_LOCALIZED_EVENT_CHARS = 2_000;
|
||||
|
||||
/**
|
||||
* Plan one complete trigger range. Tool and Runtime-control Events deliberately
|
||||
* produce no evidence, but remain in the range so the Session Cursor crosses
|
||||
* them instead of repeatedly reconsidering them. If every User evidence record
|
||||
* cannot fit the bounded Evidence Index, fail closed instead of silently
|
||||
* consuming only part of the range.
|
||||
*/
|
||||
export function planMemoryCoverage(input: {
|
||||
readonly pendingEntries: readonly MemoryExtractionEventEntry[];
|
||||
readonly priorityEvidence?: readonly MemoryExtractionEvidence[];
|
||||
readonly maxEvidenceJsonChars?: number;
|
||||
readonly sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>;
|
||||
readonly sourceMessages?: readonly ModelMessage[];
|
||||
}): MemoryCoveragePlan | undefined {
|
||||
if (input.pendingEntries.length === 0) return undefined;
|
||||
const priority = bindProviderVisibleEvidence(
|
||||
input.priorityEvidence ?? [],
|
||||
input.sourceMessages,
|
||||
input.sourceEventMessagePositions,
|
||||
);
|
||||
const budget = input.maxEvidenceJsonChars ?? MAX_MEMORY_EVIDENCE_JSON_CHARS;
|
||||
const fittedPriority = fitMemoryExtractionEvidence(
|
||||
priority,
|
||||
budget,
|
||||
input.sourceEventMessagePositions,
|
||||
);
|
||||
if (!fittedPriority) return undefined;
|
||||
const coverage = bindProviderVisibleEvidence(
|
||||
projectMemoryExtractionEvidence(input.pendingEntries.map(({ event }) => event)),
|
||||
input.sourceMessages,
|
||||
input.sourceEventMessagePositions,
|
||||
);
|
||||
const fitted = fitCoverageAroundPriority(
|
||||
fittedPriority,
|
||||
coverage,
|
||||
budget,
|
||||
input.sourceEventMessagePositions,
|
||||
);
|
||||
if (!fitted) return undefined;
|
||||
const fittedRefs = new Set(fitted.map(({ sourceRef }) => sourceRef));
|
||||
if (coverage.some(({ sourceRef }) => !fittedRefs.has(sourceRef))) return undefined;
|
||||
return { entries: [...input.pendingEntries], evidence: fitted };
|
||||
}
|
||||
|
||||
/** Projects only stable user-authored text into Memory evidence. */
|
||||
export function projectMemoryExtractionEvidence(
|
||||
events: readonly RuntimeEvent[],
|
||||
options: {
|
||||
readonly snippetTerms?: readonly string[];
|
||||
} = {},
|
||||
): readonly MemoryExtractionEvidence[] {
|
||||
const stable = events.filter((event) => !event.partial);
|
||||
const projected: MemoryExtractionEvidence[] = [];
|
||||
for (const event of stable) {
|
||||
const content = event.content;
|
||||
if (!content) continue;
|
||||
if (content.kind === 'text' && event.role === 'user' && event.author === 'user') {
|
||||
const fullText = normalizeEvidenceText(content.text);
|
||||
if (!fullText) continue;
|
||||
projected.push({
|
||||
sourceRef: `event:${event.id}`,
|
||||
type: 'user_message',
|
||||
text: boundedEvidenceText(fullText, options.snippetTerms),
|
||||
events: [event],
|
||||
});
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every evidence record represented while shrinking supplemental text to
|
||||
* the actual serialized JSON budget. Returning undefined means even the record
|
||||
* identities cannot fit and the Cursor must not advance.
|
||||
*/
|
||||
export function fitMemoryExtractionEvidence(
|
||||
evidence: readonly MemoryExtractionEvidence[],
|
||||
maxJsonChars = MAX_MEMORY_EVIDENCE_JSON_CHARS,
|
||||
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
|
||||
): readonly MemoryExtractionEvidence[] | undefined {
|
||||
if (!Number.isSafeInteger(maxJsonChars) || maxJsonChars < 1) return undefined;
|
||||
if (memoryExtractionEvidenceJsonSize(evidence, sourceEventMessagePositions) <= maxJsonChars) {
|
||||
return evidence;
|
||||
}
|
||||
let low = MIN_EVIDENCE_TEXT_CHARS;
|
||||
let high = MAX_EVIDENCE_TEXT_CHARS;
|
||||
let best: readonly MemoryExtractionEvidence[] | undefined;
|
||||
while (low <= high) {
|
||||
const cap = Math.floor((low + high) / 2);
|
||||
const candidate = evidence.map((entry) => ({
|
||||
...entry,
|
||||
text: sliceCodePoints(entry.text, cap),
|
||||
}));
|
||||
if (memoryExtractionEvidenceJsonSize(candidate, sourceEventMessagePositions) <= maxJsonChars) {
|
||||
best = candidate;
|
||||
low = cap + 1;
|
||||
} else {
|
||||
high = cap - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function memoryExtractionEvidenceJsonSize(
|
||||
evidence: readonly MemoryExtractionEvidence[],
|
||||
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
|
||||
): number {
|
||||
return JSON.stringify(renderMemoryExtractionEvidence(evidence, sourceEventMessagePositions))
|
||||
.length;
|
||||
}
|
||||
|
||||
export function renderMemoryExtractionEvidence(
|
||||
evidence: readonly MemoryExtractionEvidence[],
|
||||
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
|
||||
) {
|
||||
return evidence.map(({ sourceRef, type, text, events, providerVisibleTexts }) => {
|
||||
const messagePositions = uniqueSorted(
|
||||
events.flatMap((event) => sourceEventMessagePositions?.[event.id] ?? []),
|
||||
);
|
||||
const everyEventIndexed = providerVisibleTexts !== undefined;
|
||||
return {
|
||||
sourceRef,
|
||||
type,
|
||||
observedAt: minuteTimestamp(Math.max(0, ...events.map((event) => event.ts))),
|
||||
...(everyEventIndexed ? { messagePositions } : { text }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function bindProviderVisibleEvidence(
|
||||
evidence: readonly MemoryExtractionEvidence[],
|
||||
sourceMessages: readonly ModelMessage[] | undefined,
|
||||
sourceEventMessagePositions: Readonly<Record<string, readonly number[]>> | undefined,
|
||||
): readonly MemoryExtractionEvidence[] {
|
||||
if (!sourceMessages || !sourceEventMessagePositions) return evidence;
|
||||
return evidence.map((entry) => {
|
||||
const positions = uniqueSorted(
|
||||
entry.events.flatMap((event) => sourceEventMessagePositions[event.id] ?? []),
|
||||
);
|
||||
if (
|
||||
positions.length === 0 ||
|
||||
entry.events.some((event) => (sourceEventMessagePositions[event.id]?.length ?? 0) === 0)
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
const visibleByPosition = positions.map((position) => {
|
||||
const message = sourceMessages[position];
|
||||
if (!message || message.role !== 'user') return [];
|
||||
if (typeof message.content === 'string') {
|
||||
const text = normalizeEvidenceText(message.content);
|
||||
return text ? [text] : [];
|
||||
}
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== 'text') return [];
|
||||
const text = normalizeEvidenceText(part.text);
|
||||
return text ? [text] : [];
|
||||
});
|
||||
});
|
||||
if (visibleByPosition.some((texts) => texts.length === 0)) {
|
||||
return entry;
|
||||
}
|
||||
return { ...entry, providerVisibleTexts: visibleByPosition.flat() };
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve requested evidence once fitted; all coverage records must fit. */
|
||||
function fitCoverageAroundPriority(
|
||||
priority: readonly MemoryExtractionEvidence[],
|
||||
coverage: readonly MemoryExtractionEvidence[],
|
||||
maxJsonChars: number,
|
||||
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
|
||||
): readonly MemoryExtractionEvidence[] | undefined {
|
||||
const priorityRefs = new Set(priority.map(({ sourceRef }) => sourceRef));
|
||||
const remaining = coverage.filter(({ sourceRef }) => !priorityRefs.has(sourceRef));
|
||||
const merged = [...priority, ...remaining];
|
||||
if (memoryExtractionEvidenceJsonSize(merged, sourceEventMessagePositions) <= maxJsonChars) {
|
||||
return merged;
|
||||
}
|
||||
if (remaining.length === 0) return undefined;
|
||||
|
||||
const fit = (entries: readonly MemoryExtractionEvidence[]) => {
|
||||
let low = MIN_EVIDENCE_TEXT_CHARS;
|
||||
let high = MAX_EVIDENCE_TEXT_CHARS;
|
||||
let best: readonly MemoryExtractionEvidence[] | undefined;
|
||||
while (low <= high) {
|
||||
const cap = Math.floor((low + high) / 2);
|
||||
const candidate = [
|
||||
...priority,
|
||||
...entries.map((entry) => ({ ...entry, text: sliceCodePoints(entry.text, cap) })),
|
||||
];
|
||||
if (
|
||||
memoryExtractionEvidenceJsonSize(candidate, sourceEventMessagePositions) <= maxJsonChars
|
||||
) {
|
||||
best = candidate;
|
||||
low = cap + 1;
|
||||
} else {
|
||||
high = cap - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
return fit(remaining);
|
||||
}
|
||||
|
||||
/** Rank matching Turns by term coverage and recency, then add a one-Turn neighborhood. */
|
||||
export function searchSameSessionMemoryHistory(
|
||||
entries: readonly MemoryExtractionEventEntry[],
|
||||
throughOrdinal: number,
|
||||
search: { readonly terms: readonly string[]; readonly roles?: readonly string[] },
|
||||
afterOrdinal = 0,
|
||||
): readonly MemoryExtractionEventEntry[] {
|
||||
const eligible = entries.filter(
|
||||
({ ordinal, event }) =>
|
||||
ordinal > afterOrdinal &&
|
||||
ordinal <= throughOrdinal &&
|
||||
!event.partial &&
|
||||
event.content?.kind === 'text' &&
|
||||
((event.role === 'user' && event.author === 'user') ||
|
||||
(event.role === 'model' && event.author === 'agent')),
|
||||
);
|
||||
const turns: Array<{ key: string; entries: MemoryExtractionEventEntry[] }> = [];
|
||||
for (const entry of eligible) {
|
||||
const key = `${entry.event.runId}\0${entry.event.turnId}`;
|
||||
const last = turns.at(-1);
|
||||
if (last?.key === key) last.entries.push(entry);
|
||||
else turns.push({ key, entries: [entry] });
|
||||
}
|
||||
|
||||
const terms = search.terms.map((term) => normalizeEvidenceText(term).toLowerCase());
|
||||
const allowedRoles = search.roles ? new Set(search.roles) : undefined;
|
||||
const hits = turns
|
||||
.map((turn, index) => ({
|
||||
index,
|
||||
score: terms.filter((term) =>
|
||||
turn.entries.some(({ event }) => {
|
||||
if (allowedRoles && !allowedRoles.has(historyRole(event))) return false;
|
||||
return normalizeEvidenceText(event.content?.kind === 'text' ? event.content.text : '')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
}),
|
||||
).length,
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((left, right) => right.score - left.score || right.index - left.index);
|
||||
|
||||
const selected = new Set<number>();
|
||||
for (const hit of hits) {
|
||||
for (const index of [hit.index, hit.index - 1, hit.index + 1]) {
|
||||
if (index < 0 || index >= turns.length || selected.has(index)) continue;
|
||||
if (selected.size >= MAX_LOCALIZED_TURNS) break;
|
||||
selected.add(index);
|
||||
}
|
||||
if (selected.size >= MAX_LOCALIZED_TURNS) break;
|
||||
}
|
||||
return [...selected]
|
||||
.sort((left, right) => left - right)
|
||||
.flatMap((index) => turns[index]!.entries);
|
||||
}
|
||||
|
||||
/** Bounded User/Assistant text used only to interpret elliptical user evidence. */
|
||||
export function renderMemoryLocalizationContext(
|
||||
entries: readonly MemoryExtractionEventEntry[],
|
||||
): string {
|
||||
let remaining = MAX_LOCALIZED_CONTEXT_CHARS;
|
||||
const rendered: string[] = [];
|
||||
for (const { event } of entries) {
|
||||
if (remaining <= 0 || event.partial || event.content?.kind !== 'text') continue;
|
||||
const role = historyRole(event);
|
||||
const text = sliceCodePoints(
|
||||
normalizeEvidenceText(event.content.text),
|
||||
MAX_LOCALIZED_EVENT_CHARS,
|
||||
);
|
||||
if (!text) continue;
|
||||
const line = `[${role} event:${event.id}] ${text}`;
|
||||
const bounded = sliceCodePoints(line, remaining);
|
||||
rendered.push(bounded);
|
||||
remaining -= Array.from(bounded).length;
|
||||
}
|
||||
return rendered.join('\n');
|
||||
}
|
||||
|
||||
export function isMemoryToolName(name: string): boolean {
|
||||
return name === 'memory_remember' || name === 'memory_extract';
|
||||
}
|
||||
|
||||
function boundedEvidenceText(value: string, terms: readonly string[] | undefined): string {
|
||||
const codePoints = Array.from(value);
|
||||
if (codePoints.length <= MAX_EVIDENCE_TEXT_CHARS) return value;
|
||||
const normalizedTerms = terms
|
||||
?.map((term) => normalizeEvidenceText(term).toLowerCase())
|
||||
.filter(Boolean);
|
||||
const lower = value.toLowerCase();
|
||||
const hit = normalizedTerms
|
||||
?.map((term) => lower.indexOf(term))
|
||||
.filter((index) => index >= 0)
|
||||
.sort((left, right) => left - right)[0];
|
||||
if (hit === undefined) return codePoints.slice(0, MAX_EVIDENCE_TEXT_CHARS).join('');
|
||||
const before = Math.floor(MAX_EVIDENCE_TEXT_CHARS / 3);
|
||||
const start = Math.max(0, Array.from(value.slice(0, hit)).length - before);
|
||||
return codePoints.slice(start, start + MAX_EVIDENCE_TEXT_CHARS).join('');
|
||||
}
|
||||
|
||||
function normalizeEvidenceText(value: string): string {
|
||||
return value.normalize('NFC').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function minuteTimestamp(value: number): number {
|
||||
return Math.floor(value / 60_000) * 60_000;
|
||||
}
|
||||
|
||||
function historyRole(event: RuntimeEvent): 'user' | 'assistant' {
|
||||
return event.role === 'model' ? 'assistant' : 'user';
|
||||
}
|
||||
|
||||
function sliceCodePoints(value: string, maximum: number): string {
|
||||
return Array.from(value).slice(0, maximum).join('');
|
||||
}
|
||||
|
||||
function uniqueSorted(values: readonly number[]): number[] {
|
||||
return [...new Set(values)].sort((left, right) => left - right);
|
||||
}
|
||||
Reference in New Issue
Block a user