pi-longhaul 1.0.2 → 1.0.4
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 +64 -0
- package/CONTEXT.md +6 -0
- package/README.md +13 -4
- package/docs/security.md +63 -0
- package/package.json +5 -5
- package/pi-compat.json +9 -0
- package/src/credential-scrub.ts +319 -0
- package/src/learned-secrets.ts +1 -1
- package/src/ledger/handoff-commands.ts +2 -0
- package/src/ledger/handoff-errors.ts +19 -5
- package/src/narrative/named-values.ts +14 -4
- package/src/narrative/prompt.ts +6 -4
- package/src/narrative/turns/conversation.ts +3 -2
- package/src/pass.ts +24 -6
- package/src/remember-tool.ts +17 -3
- package/src/span-credentials.ts +194 -0
- package/src/store/events.ts +4 -1
- package/src/store/log.ts +25 -5
- package/src/store/project.ts +87 -12
- package/src/store/recall.ts +28 -17
- package/src/store/verify.ts +3 -3
- package/src/tool-context.ts +16 -0
|
@@ -45,7 +45,7 @@ function textsIn(message: Rec): string[] {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** Everything a message carries as text, call arguments string by string (so a written file is read line by line). */
|
|
48
|
-
function everyTextIn(message: Rec): string[] {
|
|
48
|
+
export function everyTextIn(message: Rec): string[] {
|
|
49
49
|
const out: string[] = [];
|
|
50
50
|
if (typeof message.content === "string") out.push(message.content);
|
|
51
51
|
if (Array.isArray(message.content)) {
|
|
@@ -89,6 +89,18 @@ export interface SpanTexts {
|
|
|
89
89
|
holderOf?: (value: string) => string | undefined;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Every token that stood on a credential line, after a credential named in prose, in a `user:pass@` login or in a
|
|
94
|
+
* secret store's output anywhere in `messages`: what a value is refused for. Broad on purpose, since refusing a value
|
|
95
|
+
* costs little; the credential scrub uses the stricter span-credentials.ts, as it takes each one out everywhere.
|
|
96
|
+
*/
|
|
97
|
+
function credentialTokensIn(messages: readonly AgentMessage[]): Set<string> {
|
|
98
|
+
const tainted = new Set<string>();
|
|
99
|
+
for (const message of messages as unknown as Rec[]) for (const text of everyTextIn(message)) for (const token of credentialTokensOf(text)) tainted.add(token);
|
|
100
|
+
for (const output of secretStoreOutputs(messages as unknown as Rec[])) for (const token of tokensOf(output)) tainted.add(token);
|
|
101
|
+
return tainted;
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
/**
|
|
93
105
|
* Each turn's text, by the ordinal tag prepare.ts puts on its user message (thinking was stripped before), and the
|
|
94
106
|
* span's credential tokens. Those are taken from every message, sources or not: a password a web page showed is no
|
|
@@ -110,14 +122,12 @@ export function spanTextsOf(messages: AgentMessage[]): SpanTexts {
|
|
|
110
122
|
*/
|
|
111
123
|
export function turnTextsOf(messages: ReadonlyArray<{ ordinal?: string; message: AgentMessage; taintOnly?: boolean }>): SpanTexts {
|
|
112
124
|
const turns = new Map<string, string[]>();
|
|
113
|
-
const tainted = new Set<string>();
|
|
114
125
|
let current: string[] | undefined;
|
|
115
126
|
for (const { ordinal, message, taintOnly } of messages as unknown as ReadonlyArray<{ ordinal?: string; message: Rec; taintOnly?: boolean }>) {
|
|
116
|
-
for (const text of everyTextIn(message)) for (const token of credentialTokensOf(text)) tainted.add(token);
|
|
117
127
|
if (ordinal) turns.set(ordinal, (current = []));
|
|
118
128
|
if (!taintOnly) current?.push(...textsIn(message));
|
|
119
129
|
}
|
|
120
|
-
|
|
130
|
+
const tainted = credentialTokensIn(messages.map((entry) => entry.message));
|
|
121
131
|
const joined = new Map([...turns].map(([ordinal, texts]) => [ordinal, texts.join("\n")]));
|
|
122
132
|
// Each turn's clean text once, newest first: a value that misses is looked up again on every attempt.
|
|
123
133
|
let clean: Array<[string, string]> | undefined;
|
package/src/narrative/prompt.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
8
8
|
|
|
9
|
+
import { CREDENTIAL_RULE, scrubCredentials } from "../credential-scrub.ts";
|
|
9
10
|
import { normalizeWs } from "../intent.ts";
|
|
10
11
|
import { quoteText } from "../render.ts";
|
|
11
12
|
import { DEFAULT_CAPS, type Now, type Quote, type StateEvent } from "../store/events.ts";
|
|
@@ -35,7 +36,7 @@ import { LABEL_FORMS, LIFT_GUIDANCE, RULE_DEFINITION, RULE_WHOLE_CHARS } from ".
|
|
|
35
36
|
* state-16: outside a /goal, the session's opening request is the goal line once the Request has moved from it
|
|
36
37
|
* (store/project.ts goalLine), so the project's purpose survives every compaction; never quoted as the objective.
|
|
37
38
|
*/
|
|
38
|
-
export const NARRATIVE_PROMPT_REVISION = "state-
|
|
39
|
+
export const NARRATIVE_PROMPT_REVISION = "state-17";
|
|
39
40
|
|
|
40
41
|
export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. You are given the state so far, the user's messages with their ordinals (u1, u2, ...), and the conversation being compacted. Answer with ONE fenced json block and nothing else:
|
|
41
42
|
|
|
@@ -52,7 +53,7 @@ export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a codi
|
|
|
52
53
|
}
|
|
53
54
|
\`\`\`
|
|
54
55
|
|
|
55
|
-
Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. "not a rule" is a request, question, answer, approval or one-off. Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. Empty arrays are fine. No prose outside the fence.`;
|
|
56
|
+
Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. "not a rule" is a request, question, answer, approval or one-off. Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. ${CREDENTIAL_RULE} Empty arrays are fine. No prose outside the fence.`;
|
|
56
57
|
|
|
57
58
|
export interface PromptState {
|
|
58
59
|
/** The Goal line (store/project.ts goalLine), shown so the summarizer never re-picks it as the Request. */
|
|
@@ -110,8 +111,9 @@ const rememberedText = (event: StateEvent): { kind: string; text: string } | und
|
|
|
110
111
|
switch (event.kind) {
|
|
111
112
|
case "correction.add": return { kind: "correction", text: `${event.id} [${event.quote.ordinal}] ${event.quote.text}` };
|
|
112
113
|
case "correction.lift": return { kind: "lift", text: `${event.target} lifted by ${event.by.ordinal}` };
|
|
113
|
-
|
|
114
|
-
case "
|
|
114
|
+
// Written by a tool call this span: the projection has not seen it, so it is scrubbed here (credential-scrub.ts).
|
|
115
|
+
case "decision.add": return { kind: "decision", text: `${event.id} ${scrubCredentials(event.text).text}` };
|
|
116
|
+
case "deadEnd.add": return { kind: "dead-end", text: `${event.id} ${scrubCredentials(event.text).text}` };
|
|
115
117
|
case "objective.set": return { kind: "objective", text: `${event.quote.ordinal} ${event.quote.text}` };
|
|
116
118
|
case "objective.doneWhen": return { kind: "done-when", text: `${event.quote.ordinal} ${event.quote.text}` };
|
|
117
119
|
default: return undefined;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import type { AssistantMessage, Context, Message, Model, ModelThinkingLevel, Tool, ToolCall } from "@earendil-works/pi-ai";
|
|
11
11
|
|
|
12
|
+
import { CREDENTIAL_RULE } from "../../credential-scrub.ts";
|
|
12
13
|
import type { ModelCall } from "../call.ts";
|
|
13
14
|
import { timedTransport } from "../timings.ts";
|
|
14
15
|
import { completeWithTransportRetry, type TransportRetryPolicy } from "../transport-retry.ts";
|
|
@@ -16,9 +17,9 @@ import type { CallTrace } from "../types.ts";
|
|
|
16
17
|
import { callUsageOf } from "../usage.ts";
|
|
17
18
|
|
|
18
19
|
/** Recorded as the span's promptRevision in turns mode, as NARRATIVE_PROMPT_REVISION is for the single call. */
|
|
19
|
-
export const TURNS_PROMPT_REVISION = "turns-
|
|
20
|
+
export const TURNS_PROMPT_REVISION = "turns-9";
|
|
20
21
|
|
|
21
|
-
export const TURNS_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. The first message holds the digest of the span: the state so far, the user's messages, the commands and errors (with ids), the files, the workspace and a condensed transcript. Code asks for one section at a time. Answer each with ONE JSON object and nothing else. Use only ids that appear in the digest. Call show(ref) to read a user message (u<n>), command (C<n>), error (E<n>) or tool output (T<n>) in full when the digest cut it
|
|
22
|
+
export const TURNS_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. The first message holds the digest of the span: the state so far, the user's messages, the commands and errors (with ids), the files, the workspace and a condensed transcript. Code asks for one section at a time. Answer each with ONE JSON object and nothing else. Use only ids that appear in the digest. Call show(ref) to read a user message (u<n>), command (C<n>), error (E<n>) or tool output (T<n>) in full when the digest cut it. ${CREDENTIAL_RULE}`;
|
|
22
23
|
|
|
23
24
|
export const SHOW_TOOL: Tool = {
|
|
24
25
|
name: "show",
|
package/src/pass.ts
CHANGED
|
@@ -21,12 +21,14 @@ import { shouldAttemptRoute, type RouteHealthState } from "./narrative/route-hea
|
|
|
21
21
|
import { resolveSummarizationModel, type ModelLookup, type ResolvedSummarizationModel } from "./narrative/routing.ts";
|
|
22
22
|
import { routeProvidersFor, type ResolvedSummarizationRoute } from "./narrative/route-config.ts";
|
|
23
23
|
import { turnTextsOf, type SpanTexts } from "./narrative/named-values.ts";
|
|
24
|
+
import { spanCredentials } from "./span-credentials.ts";
|
|
24
25
|
import { runNarrativeCall, type NarrativeInput } from "./narrative/run.ts";
|
|
25
26
|
import { TURNS_PROMPT_REVISION } from "./narrative/turns/conversation.ts";
|
|
26
27
|
import { runSectionTurns, type TurnsContext } from "./narrative/turns/run.ts";
|
|
27
28
|
import type { NarrativeOutcome } from "./narrative/types.ts";
|
|
28
29
|
import type { WarmBase } from "./narrative/warm.ts";
|
|
29
30
|
import { render } from "./render.ts";
|
|
31
|
+
import { scrubModelWritten } from "./credential-scrub.ts";
|
|
30
32
|
import type { RunningTask } from "./tasks.ts";
|
|
31
33
|
import type { CapFitResult } from "./store/capfit.ts";
|
|
32
34
|
import { closeSpan, type ClosedSpan } from "./store/close.ts";
|
|
@@ -123,6 +125,8 @@ interface Closed {
|
|
|
123
125
|
ledger: { commands: CommandRecord[]; errors: ErrorRecord[] };
|
|
124
126
|
files: ReturnType<typeof carryFileOps>;
|
|
125
127
|
learned: LearnedValue[];
|
|
128
|
+
/** Credential values taken out of what the summarizer wrote (credential-scrub.ts); a count, never the values. */
|
|
129
|
+
redacted: number;
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
function resolveRoute(input: PassInput, config: StateConfig, deps: PassDeps): { route: ResolvedSummarizationRoute; resolved?: ResolvedSummarizationModel; routeAvailable: boolean } {
|
|
@@ -326,30 +330,44 @@ async function callNarrative(input: PassInput, fold: Fold, config: StateConfig,
|
|
|
326
330
|
return runSectionTurns({ ...narrativeInputOf(input, fold, config, deps, at, state), turns: config.narrativeTurns, context }, narrativeDeps);
|
|
327
331
|
}
|
|
328
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Every message the summarizer was shown, as far as credentials go: the span, the previous summary, a switchover's
|
|
335
|
+
* earlier user messages, and in warm mode the session's own last request.
|
|
336
|
+
*/
|
|
337
|
+
function summarizerSaw(input: PassInput, fold: Fold, config: StateConfig, spanMessages: AgentMessage[]): AgentMessage[] {
|
|
338
|
+
const texts = [input.preparation.previousSummary, ...(fold.seed.seedRules ?? []).map((rule) => rule.text), ...(fold.seed.seedContext ?? []).map((message) => message.text)];
|
|
339
|
+
const shown = texts.filter((text): text is string => Boolean(text)).map((text) => ({ role: "custom", content: text, timestamp: 0 }) as unknown as AgentMessage);
|
|
340
|
+
const warm = config.narrativeMode === "warm" ? (input.warmBase?.messages ?? []) as unknown as AgentMessage[] : [];
|
|
341
|
+
return [...spanMessages, ...shown, ...warm];
|
|
342
|
+
}
|
|
343
|
+
|
|
329
344
|
async function closeAndRender(input: PassInput, fold: Fold, outcome: NarrativeOutcome, config: StateConfig, at: string): Promise<Closed> {
|
|
330
345
|
const { preparation, branchEntries } = input;
|
|
331
346
|
const { capture, snapshot, ledger, objectiveEvents, world, files } = await fold.settled();
|
|
332
|
-
const
|
|
347
|
+
const spanMessages = [...preparation.messagesToSummarize, ...(preparation.turnPrefixMessages ?? [])];
|
|
348
|
+
// Before anything is recorded: a credential the summarizer was shown must not reach the span record, nor every later request.
|
|
349
|
+
const scrubbed = scrubModelWritten(outcome.events, outcome.narrative, spanCredentials(summarizerSaw(input, fold, config, spanMessages)));
|
|
350
|
+
const modelEvents = [...objectiveEvents.map((event) => ({ ...event, seq: 0 })), ...scrubbed.events];
|
|
333
351
|
const diffEvents = [...modelEvents, ...seedRuleEvents(fold, outcome, modelEvents, config, at)];
|
|
334
352
|
// Ids from the whole merged ledger, not the fitted slice: a value's source must not vanish with a low-ranked record.
|
|
335
353
|
// The values the summarizer named come first: each was checked against its turn, and its name says what it is.
|
|
336
|
-
const picked = learnedValues(
|
|
354
|
+
const picked = learnedValues(spanMessages, fold.handoff.commands);
|
|
337
355
|
const learned = carryLearnedValues(previousLearnedValues(branchEntries), [...(outcome.values ?? []), ...picked]);
|
|
338
356
|
const tasks = (await fold.tasks) ?? [];
|
|
339
357
|
const measure = (candidate: SessionState) => render({ state: candidate, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }).length;
|
|
340
|
-
const closed = closeSpan({ log: fold.log, caps: config.caps, diffEvents: renumber(diffEvents, fold.log.nextSeq), close: { fromRef: fold.spanInput.fromRef, toRef: fold.spanInput.toRef, narrative:
|
|
341
|
-
return { closed, summary: render({ state: closed.state, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }), capture, snapshot, ledger, files, learned };
|
|
358
|
+
const closed = closeSpan({ log: fold.log, caps: config.caps, diffEvents: renumber(diffEvents, fold.log.nextSeq), close: { fromRef: fold.spanInput.fromRef, toRef: fold.spanInput.toRef, narrative: scrubbed.narrative, narrativeLess: outcome.kind === "narrative-less", world, ...(outcome.missingChunks.length ? { missingChunks: outcome.missingChunks } : {}) }, at, measure });
|
|
359
|
+
return { closed, summary: render({ state: closed.state, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }), capture, snapshot, ledger, files, learned, redacted: scrubbed.redacted };
|
|
342
360
|
}
|
|
343
361
|
|
|
344
362
|
function assembleResult(input: PassInput, config: StateConfig, fold: Fold, outcome: NarrativeOutcome, built: Closed, deps: PassDeps, startedAt: Date, at: string): PassResult {
|
|
345
363
|
const { preparation } = input;
|
|
346
|
-
const { closed, summary, capture, snapshot, ledger, files, learned } = built;
|
|
364
|
+
const { closed, summary, capture, snapshot, ledger, files, learned, redacted } = built;
|
|
347
365
|
const usage = { input: outcome.usage.input, output: outcome.usage.output, calls: outcome.usage.calls };
|
|
348
366
|
const details: StateDetails = { compactor: "state", stateFormat: STATE_FORMAT, promptRevision: outcome.sections ? TURNS_PROMPT_REVISION : NARRATIVE_PROMPT_REVISION, state: closed.state, caps: config.caps, ledger, snapshot, narrative: narrativeTraceOf(outcome), readFiles: files.readFiles, modifiedFiles: files.modifiedFiles, learnedValues: learned };
|
|
349
367
|
const pending: PendingSpan = { span: fold.span, events: closed.log.events.filter((event) => event.span === fold.span), ledger, snapshot, usage, caps: config.coldSliceCaps };
|
|
350
368
|
const route = { spec: fold.route.spec, rule: fold.route.rule, model: fold.resolved ? `${fold.resolved.model.provider}/${fold.resolved.model.id}` : undefined, fallbackReason: fold.resolved?.fallbackReason, routeIgnored: fold.resolved?.routeIgnored, routeAvailable: fold.routeAvailable };
|
|
351
369
|
const logOptions = logOptionsOf(input.env);
|
|
352
|
-
const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: closed.capFit.retired, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
|
|
370
|
+
const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: closed.capFit.retired, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, redacted, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
|
|
353
371
|
return {
|
|
354
372
|
compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage: compactionUsageOf(outcome.usage), details },
|
|
355
373
|
pending, outcome, routeHealth: outcome.routeHealth, route, capFit: closed.capFit, anomalies: fold.log.anomalies, notice: noticeOf(fold.span, outcome, summary.length), log,
|
package/src/remember-tool.ts
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
2
2
|
* The `remember` tool: one verified write into the Session state, sequential so two calls in one
|
|
3
3
|
* turn never race the fold. Quotes only, resolved newest-first; the store's commitRemember is the
|
|
4
4
|
* whole policy. Argument repair: a quote passed as `text` (or the reverse) is swapped with a Note,
|
|
5
|
-
* because a model that got the field wrong got the intent right.
|
|
5
|
+
* because a model that got the field wrong got the intent right. A decision or dead end is the
|
|
6
|
+
* model's own words, so one that carries a credential value (credential-scrub.ts, with the
|
|
7
|
+
* credentials of the messages still in context, the kept window included) is refused, never stored (security.md T3).
|
|
6
8
|
*/
|
|
7
9
|
import { REMEMBER_TOOL } from "./tool-names.ts";
|
|
8
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
11
|
import { Type } from "typebox";
|
|
10
12
|
|
|
11
13
|
import { readConfigFile, resolveStateConfig, type ReadFileFn } from "./config.ts";
|
|
14
|
+
import { CREDENTIAL_RULE, scrubCredentials } from "./credential-scrub.ts";
|
|
15
|
+
import { spanCredentials } from "./span-credentials.ts";
|
|
12
16
|
import { commitRemember } from "./store/commit.ts";
|
|
13
17
|
import { EVENT_ENTRY_TYPE, type PersistedEvent } from "./store/events.ts";
|
|
14
18
|
import type { Proposal } from "./store/verify.ts";
|
|
15
|
-
import { buildToolContext } from "./tool-context.ts";
|
|
19
|
+
import { buildToolContext, messagesInContext } from "./tool-context.ts";
|
|
16
20
|
import { formatRemember } from "./tool-text.ts";
|
|
17
21
|
|
|
18
22
|
export const REMEMBER_KINDS = ["correction", "lift", "decision", "dead-end", "done-when"] as const;
|
|
@@ -42,6 +46,13 @@ export function proposalOf(params: RememberParams): { proposal: Proposal; note?:
|
|
|
42
46
|
return { proposal: { kind: "dead-end", text: words, evidence: params.evidence }, note };
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Why a decision or dead end is refused, or undefined: the model's own words may not hold a credential the span showed or one shaped as one. */
|
|
50
|
+
function credentialRefusal(proposal: Proposal, branch: Parameters<typeof messagesInContext>[0]): string | undefined {
|
|
51
|
+
if (proposal.kind !== "decision" && proposal.kind !== "dead-end") return undefined;
|
|
52
|
+
if (scrubCredentials(proposal.text, spanCredentials(messagesInContext(branch))).redacted === 0) return undefined;
|
|
53
|
+
return `the text carries a credential. ${CREDENTIAL_RULE}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
const PARAMETERS = Type.Object({
|
|
46
57
|
kind: Type.Union(REMEMBER_KINDS.map((kind) => Type.Literal(kind)), { description: "correction: a standing rule the user gave (quote). lift: the user withdrew a correction (target + quote). decision / dead-end: your own one sentence (text, evidence). done-when: the user's acceptance words (quote)." }),
|
|
47
58
|
quote: Type.Optional(Type.String({ description: "The user's exact words, a substring of one user message (12+ chars). Example: \"never use mocks in these tests\"." })),
|
|
@@ -64,8 +75,11 @@ export function registerRememberTool(pi: ExtensionAPI, deps: { readConfig: ReadF
|
|
|
64
75
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
65
76
|
const mapped = proposalOf(params as RememberParams);
|
|
66
77
|
if ("error" in mapped) return { content: [{ type: "text", text: `not remembered: ${mapped.error}` }], details: { ok: false, remaining: undefined, rejection: { code: "schema", detail: mapped.error } } };
|
|
78
|
+
const branch = ctx.sessionManager.getBranch();
|
|
79
|
+
const refusal = credentialRefusal(mapped.proposal, branch);
|
|
80
|
+
if (refusal) return { content: [{ type: "text", text: `not remembered: ${refusal}` }], details: { ok: false, remaining: undefined, rejection: { code: "credential", detail: refusal } } };
|
|
67
81
|
const config = resolveStateConfig(deps.readConfig);
|
|
68
|
-
const context = buildToolContext(
|
|
82
|
+
const context = buildToolContext(branch);
|
|
69
83
|
const outcome = commitRemember(mapped.proposal, {
|
|
70
84
|
log: context.log, resolver: context.index, caps: config.caps, nearDuplicateThreshold: config.nearDuplicateThreshold, rememberCap: config.rememberPerSpan,
|
|
71
85
|
now: () => deps.now().toISOString(), appendEntry: (customType: string, data: PersistedEvent) => pi.appendEntry(customType, data),
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The span's credentials (credential-scrub.ts SpanCredentials): the whole values the span itself showed as
|
|
3
|
+
* credentials, which the scrub then takes out of what the summarizer wrote wherever it put them (security.md T3).
|
|
4
|
+
* Strong evidence only, because each value is redacted everywhere it stands: the named-value taint
|
|
5
|
+
* (narrative/named-values.ts), right for refusing a value, counts the words after "pin", "card" or "login" and a
|
|
6
|
+
* secret store's whole output, and on real states it redacted versions, commit hashes and paths in about a third of
|
|
7
|
+
* the fields. A value counts when
|
|
8
|
+
* - the span's text names it as the scrub itself would (credential-scrub.ts namedCredentialValues), unless it is
|
|
9
|
+
* shaped as a name (below);
|
|
10
|
+
* - it is the value of a key named for one credential, ending in its name or a short part after it
|
|
11
|
+
* (`DB_PASSWORD`, `cluster_token`, `WIFI_PASSPHRASE_2400_BAUD`; not `tokens: 1200`, a test runner's `pass`, nor a key
|
|
12
|
+
* about one, `password_policy`, `token_env`), unless a token, secret or key key holds a value shaped as a name (`app-token`,
|
|
13
|
+
* `APP_TOKEN`, a path, a file name; a password key's value is one whatever its shape, `unmade-tremor-oxidize`);
|
|
14
|
+
* - a command gives it as a password argument (`mysql -pX`, `sshpass -p X`, `redis-cli -a X`, `curl -u user:X`,
|
|
15
|
+
* `htpasswd -b FILE USER X`, `--db-password X`, `openssl -pass pass:X`, `keytool -storepass X`);
|
|
16
|
+
* - it is a random-looking word on another credential line (a YAML block under such a key);
|
|
17
|
+
* - a secret store printed it alone (`vault kv get -field=...`), or random-looking among other output;
|
|
18
|
+
* - but never a random-looking word its label names as an id or a hash (`Evaluation ID: <uuid>`, `plan_hash <hex>`,
|
|
19
|
+
* `commit <sha>`), nor the words after a key whose value is computed (`TOKEN=$(vault kv get ... | python3 ...)`), nor
|
|
20
|
+
* a test runner's `--- PASS: TestName`;
|
|
21
|
+
* - or prose gives it: `the password is ...`, `the db pass is ...`.
|
|
22
|
+
* Never a number (except six digits and more given as a password), version, date or path, nor one plain lowercase
|
|
23
|
+
* word ("token: missing"). Measured on real states, these took no version, hash or path out of a field.
|
|
24
|
+
*/
|
|
25
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
26
|
+
|
|
27
|
+
import { credentialCore, isNumberish, isNumericPasswordKey, isRandomLooking, isValueLike, namedCredentialValues, WORD } from "./credential-scrub.ts";
|
|
28
|
+
import { CREDENTIAL_NAME, keyedLine, secretStoreOutputs, splitCredentialLines } from "./learned-secrets.ts";
|
|
29
|
+
import type { Rec } from "./message-text.ts";
|
|
30
|
+
import { everyTextIn } from "./narrative/named-values.ts";
|
|
31
|
+
|
|
32
|
+
/** `the password is X`: the one value prose gives a password outright. */
|
|
33
|
+
const PROSE_PASSWORD = /(?<![A-Za-z])(?:passw(?:or)?d|passwd|pwd|pass|passphrase|passcode)\s+(?:is|was)\s+(\S+)/gi;
|
|
34
|
+
/**
|
|
35
|
+
* A command's password argument, group 1 (as ledger/handoff-commands.ts SECRET_PATTERNS, which marks the line).
|
|
36
|
+
* Every quantifier is bounded, each lazy run stops at a newline.
|
|
37
|
+
*/
|
|
38
|
+
const COMMAND_PASSWORDS = [
|
|
39
|
+
/\b(?:mysql|mysqldump|mariadb)\b[^\n]{0,2000}?\s-p(?!\s)(\S+)/g,
|
|
40
|
+
/\bsshpass\s+(?:(?:-P\s+\S+|-[a-oq-zA-Z]\w*)\s+){0,3}-p\s*(\S+)/g,
|
|
41
|
+
/\bredis-cli\b[^\n]{0,200}?\s-a\s+(\S+)/g,
|
|
42
|
+
/(?:^|\s)(?:-u|--user)\s+[^\s:]+:(\S+)/g,
|
|
43
|
+
/\s-pass(?:in|out)?\s+pass:(\S+)/g,
|
|
44
|
+
/\s-(?:src|dest)?(?:store|key)?pass\s+(?!(?:env|file|fd):|stdin\b)(\S+)/g,
|
|
45
|
+
/(?:^|\s)--?(?:[a-z0-9]{1,32}[-_.]){0,3}(?:password|passwd|passphrase|pass|secret|token|api[-_]?key)(?:=|\s+)(?!-)(\S+)/gi,
|
|
46
|
+
];
|
|
47
|
+
/** `htpasswd -b FILE USER PASS`, or `-nb USER PASS` (no file). */
|
|
48
|
+
const HTPASSWD = /\bhtpasswd\s+(?:-[a-zA-Z]{1,8}\s+){0,3}?-([a-zA-Z]{0,8}b[a-zA-Z]{0,8})\s+(\S+)\s+(\S+)(?:\s+(\S+))?/g;
|
|
49
|
+
/** A test runner's status line, `--- PASS: TestName (0.01s)` or `PASS: name`, upper case: no credential (`pass: x` is one). */
|
|
50
|
+
const TEST_STATUS = /^\s*(?:[-=]{3}\s*)?PASS\s*:/;
|
|
51
|
+
/** A value pointed at rather than held (a variable, a placeholder), once a quote or bracket before it is dropped. */
|
|
52
|
+
const REFERENCE = /^["'`([]*(?:\$|%|\*|\{|<|\.\.\.)/;
|
|
53
|
+
const NOT_A_SECRET = [
|
|
54
|
+
/^v?\d+(?:[.:_-]\d+)*$/i, // a number, a version, a time
|
|
55
|
+
/^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?$/, // a date
|
|
56
|
+
/^(?:\/|~\/|\.\.?\/)/, // a path
|
|
57
|
+
/^[a-z]+$/, // one plain lowercase word: a status
|
|
58
|
+
];
|
|
59
|
+
const isSpanValue = (value: string) => value.length >= 4 && (/^\d{13,}$/.test(value) || !NOT_A_SECRET.some((shape) => shape.test(value)));
|
|
60
|
+
/** A password given as one: a span value, or six digits and more. */
|
|
61
|
+
const isPasswordValue = (value: string) => isSpanValue(value) || /^\d{6,}$/.test(value);
|
|
62
|
+
/**
|
|
63
|
+
* What may follow a credential's name at the end of its key: up to two short parts (`WIFI_PASSPHRASE_2400_BAUD`,
|
|
64
|
+
* `ADMIN_TOKEN_2`), then quotes. A part that says the key is ABOUT the credential (KEY_ABOUT) makes it no key for one.
|
|
65
|
+
*/
|
|
66
|
+
const KEY_TAIL = String.raw`((?:[_.-][A-Za-z0-9]{1,12}){0,2})["'\\]{0,4}$`;
|
|
67
|
+
const KEY_ABOUT = /[_.-](?:policy|disabled|enabled|required|length|len|min|max|algorithm|algo|hash|hashed|salt|rounds|file|path|dir|env|var|name|id|type|kind|mode|set|present|missing|rules|reset|expiry|expires|ttl|age|count|ref|url|header|field|prompt|hint|strength|changed|updated)(?![A-Za-z0-9])/i;
|
|
68
|
+
const endsInCredential = (pattern: RegExp, key: string) => {
|
|
69
|
+
const match = pattern.exec(key.trim());
|
|
70
|
+
return match !== null && !KEY_ABOUT.test(match[1]);
|
|
71
|
+
};
|
|
72
|
+
/** A key that ends in a password word (`DB_PASS`, `"password"`), not one about passwords (`password_policy`). */
|
|
73
|
+
const PASSWORD_KEY = new RegExp(String.raw`(?:passw(?:or)?d|passwd|pwd|pass(?:phrase|code)?)s?` + KEY_TAIL, "i");
|
|
74
|
+
const isPasswordKey = (key: string) => endsInCredential(PASSWORD_KEY, key);
|
|
75
|
+
/** Push without spreading: a spread passes every element as an argument and overflows the stack on a huge line. */
|
|
76
|
+
function pushAll(into: string[], items: Iterable<string>): void {
|
|
77
|
+
for (const item of items) into.push(item);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A key named for one credential, in at most three words, ending in its name (quotes aside): singular, so
|
|
81
|
+
* `tokens: 1200` and a list of `secrets` are not one, no bare `pass` (a test runner's count), and no key about one
|
|
82
|
+
* (`password_policy`, `token_env`, `PASSWORD_FILE`). Letters may precede the name (`PGPASSWORD`).
|
|
83
|
+
*/
|
|
84
|
+
const CREDENTIAL_KEY = new RegExp(String.raw`(?:passw(?:or)?d|passwd|passphrase|passcode|secret|token|api[_ -]?key|access[_ -]?key|private[_ -]?key|credential)` + KEY_TAIL, "i");
|
|
85
|
+
/** A bare lower-case `pass` key (nodemailer's `auth.pass`, YAML), which CREDENTIAL_KEY leaves out as a test runner's count. */
|
|
86
|
+
const BARE_PASS_KEY = /^["']?pass["']?$/;
|
|
87
|
+
const isCredentialKey = (key: string) => BARE_PASS_KEY.test(key.trim()) || key.length <= 40 && key.trim().split(/\s+/).length <= 3 && endsInCredential(CREDENTIAL_KEY, key);
|
|
88
|
+
/**
|
|
89
|
+
* A value shaped as a name of something rather than a secret: an identifier, an environment variable, a path, a file
|
|
90
|
+
* or host name, or a call that computes it (`secret = json.load(open('app.json'))`).
|
|
91
|
+
*/
|
|
92
|
+
const NAME_SHAPES = [/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)+$/, /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, /\//, /^[\w-]+(?:\.[\w-]+)+$/, /^[A-Za-z_][\w.]{0,64}\(/];
|
|
93
|
+
const isNameShaped = (value: string) => !isRandomLooking(value) && NAME_SHAPES.some((shape) => shape.test(value));
|
|
94
|
+
/** A credential key's value: value-like, or eight characters and more; under a token, secret or key key, not shaped as a name. */
|
|
95
|
+
function isKeyedValue(value: string, key: string): boolean {
|
|
96
|
+
if (/^\d{6,}$/.test(value)) return isNumericPasswordKey(key);
|
|
97
|
+
if (isPasswordKey(key)) return isSpanValue(value) && (isValueLike(value) || value.length >= 6);
|
|
98
|
+
return (isValueLike(value) || value.length >= 8) && !isNameShaped(value) && !isNumberish(value);
|
|
99
|
+
}
|
|
100
|
+
/** A command's password argument: a password, value-like or six characters and more. */
|
|
101
|
+
const isCommandValue = (value: string) => isPasswordValue(value) && (isValueLike(value) || value.length >= 6);
|
|
102
|
+
/** A value prose gives: value-like, or a long run with a non-letter in it (a passphrase), not a long English word. */
|
|
103
|
+
const isProseValue = (value: string) => isValueLike(value) || (value.length >= 12 && /[^A-Za-z]/.test(value));
|
|
104
|
+
|
|
105
|
+
/** The words of `text` that hold a value, each with what wraps it trimmed. */
|
|
106
|
+
function coresOf(text: string): string[] {
|
|
107
|
+
const cores: string[] = [];
|
|
108
|
+
for (const [word] of text.matchAll(WORD)) {
|
|
109
|
+
if (REFERENCE.test(word)) continue;
|
|
110
|
+
const core = credentialCore(word);
|
|
111
|
+
if (core.end > core.start) cores.push(word.slice(core.start, core.end));
|
|
112
|
+
}
|
|
113
|
+
return cores;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A word that names the value after it as an id or a hash (`Evaluation ID:`, `plan_hash`, `commit`), not a secret. */
|
|
117
|
+
const ID_LABEL = /(?:^|[_.-])(?:id|ids|uuid|hash|sha|sha1|sha256|digest|commit|rev|revision|etag)$/i;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The random-looking words of `text`, except one the word before it on its line labels as an id or a hash (ID_LABEL),
|
|
121
|
+
* unless that label or the word before it names a credential (`secret_id`, `Secret ID`).
|
|
122
|
+
*/
|
|
123
|
+
function randomWordsOf(text: string): string[] {
|
|
124
|
+
const values: string[] = [];
|
|
125
|
+
let label = "";
|
|
126
|
+
let previous = "";
|
|
127
|
+
let end = 0;
|
|
128
|
+
for (const match of text.matchAll(WORD)) {
|
|
129
|
+
if (text.slice(end, match.index).includes("\n")) label = previous = "";
|
|
130
|
+
const core = credentialCore(match[0]);
|
|
131
|
+
const bare = match[0].slice(core.start, core.end);
|
|
132
|
+
const idLabelled = ID_LABEL.test(label) && !CREDENTIAL_NAME.test(`${previous} ${label}`);
|
|
133
|
+
if (!REFERENCE.test(match[0]) && isRandomLooking(bare) && !idLabelled) values.push(bare);
|
|
134
|
+
previous = label;
|
|
135
|
+
label = bare;
|
|
136
|
+
end = match.index + match[0].length;
|
|
137
|
+
}
|
|
138
|
+
return values;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The password arguments of a command line. */
|
|
142
|
+
function commandValuesOf(line: string): string[] {
|
|
143
|
+
const values: string[] = [];
|
|
144
|
+
for (const pattern of COMMAND_PASSWORDS) for (const match of line.matchAll(pattern)) pushAll(values, coresOf(match[1]));
|
|
145
|
+
for (const match of line.matchAll(HTPASSWD)) {
|
|
146
|
+
const value = match[1].includes("n") ? match[3] : match[4];
|
|
147
|
+
if (value) pushAll(values, coresOf(value));
|
|
148
|
+
}
|
|
149
|
+
return values.filter(isCommandValue);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A password key written as YAML (`password: my Secret#Pass 99`), whose plain value runs to the line's end or a comment. */
|
|
153
|
+
const isYamlPassword = (line: string, key: string) => isPasswordKey(key) && /^\s*:\s/.test(line.trimStart().slice(key.length));
|
|
154
|
+
/** What is left of an assignment once the key is cut at its first colon: Go's `:=` or a type (`: str = `, `: &str = `). */
|
|
155
|
+
const ASSIGNED = /^(?:[^\s=]{1,40}[ \t]{1,8})?:?=(?!=)[ \t]{0,8}/;
|
|
156
|
+
/**
|
|
157
|
+
* What a key holds: its quoted string, or else its first word; the rest of the line is the command or prose around it
|
|
158
|
+
* (`TOKEN=$(vault kv get ... | python3 -c "import sys,json"`). Nothing when the value is computed or pointed at.
|
|
159
|
+
*/
|
|
160
|
+
function heldBy(raw: string, toLineEnd: boolean): string {
|
|
161
|
+
const value = raw.replace(ASSIGNED, "");
|
|
162
|
+
const quoted = /^(\\{0,3}["'])((?:\\.|[^\n\\]){0,512}?)\1/.exec(value);
|
|
163
|
+
const comment = toLineEnd ? value.search(/\s#/) : -1;
|
|
164
|
+
const held = quoted ? quoted[2] : toLineEnd ? (comment < 0 ? value : value.slice(0, comment)) : (/^\S+/.exec(value)?.[0] ?? "");
|
|
165
|
+
return REFERENCE.test(held) || /^["'([]*`/.test(held) ? "" : held;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function valuesOf(text: string): string[] {
|
|
169
|
+
const values = namedCredentialValues(text).filter((value) => !isNameShaped(value));
|
|
170
|
+
for (const line of splitCredentialLines(text).credential) {
|
|
171
|
+
if (TEST_STATUS.test(line)) continue;
|
|
172
|
+
const keyed = keyedLine(line);
|
|
173
|
+
if (keyed && isCredentialKey(keyed.key)) pushAll(values, coresOf(heldBy(keyed.value, isYamlPassword(line, keyed.key))).filter((value) => isKeyedValue(value, keyed.key)));
|
|
174
|
+
else pushAll(values, randomWordsOf(line));
|
|
175
|
+
pushAll(values, commandValuesOf(line));
|
|
176
|
+
}
|
|
177
|
+
for (const match of text.matchAll(PROSE_PASSWORD)) pushAll(values, coresOf(match[1]).filter((value) => isProseValue(value) || /^\d{6,}$/.test(value)));
|
|
178
|
+
return values;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What a secret store printed: a value printed alone is one whatever it looks like; among other output, a random-looking one. */
|
|
182
|
+
function storeValuesOf(output: string): string[] {
|
|
183
|
+
const words = coresOf(output.trim());
|
|
184
|
+
return words.length === 1 ? words : randomWordsOf(output);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The span's credentials in `messages`: every whole value one of them showed as a credential. */
|
|
188
|
+
export function spanCredentials(messages: readonly AgentMessage[]): Set<string> {
|
|
189
|
+
const values = new Set<string>();
|
|
190
|
+
const add = (value: string) => { if (isPasswordValue(value)) values.add(value); };
|
|
191
|
+
for (const message of messages as unknown as Rec[]) for (const text of everyTextIn(message)) valuesOf(text).forEach(add);
|
|
192
|
+
for (const output of secretStoreOutputs(messages as unknown as Rec[])) storeValuesOf(output).forEach(add);
|
|
193
|
+
return values;
|
|
194
|
+
}
|
package/src/store/events.ts
CHANGED
|
@@ -229,10 +229,13 @@ function isEventBody(event: Rec): boolean {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/** A span or seq: a whole number from 1, so a replayed `1e999` (Infinity) cannot stall the numbering after it. */
|
|
233
|
+
const isCount = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 1;
|
|
234
|
+
|
|
232
235
|
/** Shape check for replayed entries: a malformed one is skipped and counted by the fold, never thrown. */
|
|
233
236
|
export function isStateEvent(value: unknown): value is StateEvent {
|
|
234
237
|
if (!isRec(value)) return false;
|
|
235
|
-
const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") &&
|
|
238
|
+
const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && isCount(value.span) && typeof value.at === "string" && isCount(value.seq);
|
|
236
239
|
return provenanceOk && isEventBody(value);
|
|
237
240
|
}
|
|
238
241
|
|
package/src/store/log.ts
CHANGED
|
@@ -39,6 +39,7 @@ interface Fold {
|
|
|
39
39
|
events: StateEvent[];
|
|
40
40
|
spans: SpanSummary[];
|
|
41
41
|
anomalies: FoldAnomaly[];
|
|
42
|
+
/** `span:seq` of every admitted event: a seq is unique within its span (see seqKey). */
|
|
42
43
|
seqs: Set<string>;
|
|
43
44
|
/** Loose events collected since the last compaction entry, keyed by nothing: in branch order. */
|
|
44
45
|
loose: StateEvent[];
|
|
@@ -54,6 +55,15 @@ function quoteOf(event: StateEvent): Quote[] {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The duplicate key of an event: its seq within its span. A pass numbers from the fold it reads, and a fold that
|
|
60
|
+
* lost span records (another extension's entry ahead of one) numbered later spans from too low a seq, so two
|
|
61
|
+
* spans' records can share seqs; each span's events are still its own.
|
|
62
|
+
*/
|
|
63
|
+
function seqKey(event: { span: number; seq: number }): string {
|
|
64
|
+
return `${event.span}:${event.seq}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
/** Admit one event into the fold, or record why not. */
|
|
58
68
|
function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker: QuoteChecker, into: StateEvent[]): void {
|
|
59
69
|
const newer = newerStateFormat(raw);
|
|
@@ -73,7 +83,7 @@ function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker:
|
|
|
73
83
|
fold.anomalies.push({ span, entryId, reason: `event names span ${raw.span} while folding span ${span}: ${entryId}` });
|
|
74
84
|
return;
|
|
75
85
|
}
|
|
76
|
-
const key =
|
|
86
|
+
const key = seqKey(raw);
|
|
77
87
|
if (fold.seqs.has(key)) {
|
|
78
88
|
fold.anomalies.push({ span, entryId, reason: `duplicate seq: ${raw.seq} at ${entryId}` });
|
|
79
89
|
return;
|
|
@@ -131,7 +141,7 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
|
|
|
131
141
|
}
|
|
132
142
|
// The record SUPERSEDES the span's loose events, so release their seqs before admitting its own
|
|
133
143
|
// (otherwise the record's authoritative copy of a loose event is dropped as a duplicate seq).
|
|
134
|
-
for (const loose of fold.loose) fold.seqs.delete(
|
|
144
|
+
for (const loose of fold.loose) fold.seqs.delete(seqKey(loose));
|
|
135
145
|
const events: StateEvent[] = [];
|
|
136
146
|
for (const raw of record.events) admit(fold, raw, pending.span, entry.id, checker, events);
|
|
137
147
|
for (const event of events) fold.events.push(event);
|
|
@@ -140,13 +150,23 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
|
|
|
140
150
|
fold.pending = undefined;
|
|
141
151
|
}
|
|
142
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Whether `entry` proves a pending compaction's span record never landed: a message, another compaction, or one
|
|
155
|
+
* of our own event entries. Anything else another extension wrote at session_compact (pi-goal's goal-state and
|
|
156
|
+
* goal-contract land there, ahead of the record) is passed over, so the record that follows still closes the span.
|
|
157
|
+
*/
|
|
158
|
+
function endsPendingSpan(entry: SessionEntry): boolean {
|
|
159
|
+
if (entry.type === "message" || entry.type === "compaction") return true;
|
|
160
|
+
return entry.type === "custom" && entry.customType === EVENT_ENTRY_TYPE;
|
|
161
|
+
}
|
|
162
|
+
|
|
143
163
|
export function readLog(branchEntries: SessionEntry[], checker: QuoteChecker): StateLog {
|
|
144
164
|
const fold: Fold = { events: [], spans: [], anomalies: [], seqs: new Set(), loose: [] };
|
|
145
165
|
let compactions = 0;
|
|
146
166
|
for (const entry of branchEntries) {
|
|
147
|
-
// A pending state compaction is closed by its span record,
|
|
148
|
-
//
|
|
149
|
-
if (fold.pending &&
|
|
167
|
+
// A pending state compaction is closed by its span record, the next entry of OURS after it; a message,
|
|
168
|
+
// compaction or event arriving first means the record never landed.
|
|
169
|
+
if (fold.pending && endsPendingSpan(entry)) synthesizeClose(fold, undefined, entry.timestamp);
|
|
150
170
|
if (entry.type === "compaction") {
|
|
151
171
|
compactions++;
|
|
152
172
|
const compactor = compactorOfEntry(entry);
|