@gmickel/gno 2.5.1 → 2.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/README.md +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +80 -4
- package/assets/skill/cli-reference.md +132 -2
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +54 -1
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +449 -35
- package/spec/mcp.md +234 -4
- package/spec/output-schemas/ask.schema.json +1 -1
- package/spec/output-schemas/capture-receipt.schema.json +4 -1
- package/spec/output-schemas/doctor.schema.json +88 -0
- package/spec/output-schemas/error.schema.json +11 -2
- package/spec/output-schemas/get.schema.json +1 -1
- package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
- package/spec/output-schemas/memory-remember.schema.json +10 -4
- package/spec/output-schemas/multi-get.schema.json +4 -1
- package/spec/output-schemas/peek.schema.json +2 -9
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/resident-status.schema.json +22 -0
- package/spec/output-schemas/search-result.schema.json +1 -1
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/spec/output-schemas/status.schema.json +98 -0
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/doctor.ts +54 -20
- package/src/cli/commands/embed.ts +41 -3
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/query.ts +5 -0
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/commands/status.ts +63 -5
- package/src/cli/commands/vec.ts +54 -0
- package/src/cli/detach.ts +29 -1
- package/src/cli/errors.ts +13 -9
- package/src/cli/program.ts +441 -2
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +12 -2
- package/src/core/host-paths.ts +31 -0
- package/src/core/memory-remember.ts +234 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/shutdown-budget.ts +6 -0
- package/src/core/vector-partition-status.ts +52 -0
- package/src/embed/backlog.ts +124 -18
- package/src/embed/fingerprint.ts +6 -3
- package/src/embed/retry.ts +66 -27
- package/src/embed/variant-backlog.ts +15 -10
- package/src/embed/variant-retry.ts +31 -22
- package/src/index.ts +30 -2
- package/src/llm/native-worker/dispatcher.ts +2 -0
- package/src/llm/native-worker/embedding-identity.ts +42 -0
- package/src/llm/native-worker/protocol.ts +1 -0
- package/src/llm/types.ts +3 -0
- package/src/mcp/context.ts +17 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/resources/index.ts +6 -5
- package/src/mcp/tool-descriptions-core.ts +1 -1
- package/src/mcp/tools/capture.ts +87 -85
- package/src/mcp/tools/index.ts +77 -4
- package/src/mcp/tools/memory-remember.ts +8 -1
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/status.ts +4 -0
- package/src/pipeline/hybrid.ts +37 -7
- package/src/pipeline/vsearch.ts +14 -2
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/embed-scheduler.ts +133 -19
- package/src/serve/host-path-redaction.ts +79 -0
- package/src/serve/public/app.tsx +4 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +26 -3
- package/src/serve/public/lib/request-intent.ts +77 -0
- package/src/serve/public/lib/snippet.tsx +52 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Dashboard.tsx +22 -9
- package/src/serve/public/pages/DocView.tsx +25 -6
- package/src/serve/public/pages/DocumentEditor.tsx +224 -104
- package/src/serve/public/pages/Search.tsx +1 -41
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +69 -4
- package/src/serve/resident-status.ts +13 -1
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +215 -10
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/status-model.ts +16 -0
- package/src/serve/status.ts +2 -0
- package/src/serve/watch-reconciliation-shared.ts +3 -0
- package/src/serve/watch-service-events.ts +3 -2
- package/src/serve/watch-service-run-flush.ts +35 -2
- package/src/serve/watch-service.ts +5 -0
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
- package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +76 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/src/store/types.ts +11 -1
- package/src/store/vector/lazy.ts +46 -43
- package/src/store/vector/runtime-compat.ts +651 -0
- package/src/store/vector/sqlite-vec.ts +20 -2
- package/src/store/vector/status.ts +276 -35
- package/src/store/vector/types.ts +2 -0
- package/src/store/vector/variant-search.ts +71 -23
- package/src/store/vector/variants.ts +49 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session archive: one sanitized JSONL file per thread.
|
|
3
|
+
*
|
|
4
|
+
* Archive files are ordinary JSONL records read back through the existing
|
|
5
|
+
* JSONL record adapter with {@link SESSION_ARCHIVE_FIELD_MAPPING}, so the
|
|
6
|
+
* archive needs no dedicated ingestion or ranking path. Each line is one
|
|
7
|
+
* dialogue turn; the body carries the sanitized text plus a bounded
|
|
8
|
+
* provenance block so `get` shows who spoke, when, and where it came from.
|
|
9
|
+
*
|
|
10
|
+
* @module src/sessions/archive
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// node:path: no Bun path utilities.
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { JsonlFieldMapping } from "../converters/adapters/jsonl/config";
|
|
17
|
+
import type { RedactionPolicy } from "./sanitize";
|
|
18
|
+
|
|
19
|
+
import { hashRecordValue } from "../converters/adapters/shared/record-utils";
|
|
20
|
+
import { SESSION_REDACTION_VERSION, ThreadSanitizer } from "./sanitize";
|
|
21
|
+
import {
|
|
22
|
+
type ParsedThread,
|
|
23
|
+
SESSION_ARCHIVE_FORMAT_VERSION,
|
|
24
|
+
SESSION_HARNESS_LABELS,
|
|
25
|
+
SESSION_LIMITS,
|
|
26
|
+
type SessionHarness,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
/** Record mapping registered on every archive collection. */
|
|
30
|
+
export const SESSION_ARCHIVE_FIELD_MAPPING: JsonlFieldMapping = {
|
|
31
|
+
id: "/id",
|
|
32
|
+
body: "/body",
|
|
33
|
+
title: "/title",
|
|
34
|
+
author: "/author",
|
|
35
|
+
categories: "/categories",
|
|
36
|
+
sessionId: "/sessionId",
|
|
37
|
+
threadId: "/threadId",
|
|
38
|
+
dateFields: { recorded: "/recordedAt" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Directory inside the archive root that holds import state. */
|
|
42
|
+
export const SESSION_STATE_DIRNAME = ".gno-sessions";
|
|
43
|
+
|
|
44
|
+
const PROVENANCE_FIELD_CHARS = 256;
|
|
45
|
+
|
|
46
|
+
export interface ArchiveLine {
|
|
47
|
+
id: string;
|
|
48
|
+
title: string;
|
|
49
|
+
body: string;
|
|
50
|
+
author: "human" | "assistant";
|
|
51
|
+
categories: string[];
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
threadId: string;
|
|
54
|
+
recordedAt?: string;
|
|
55
|
+
provenance: {
|
|
56
|
+
sourceId: string;
|
|
57
|
+
harness: SessionHarness;
|
|
58
|
+
unit: string;
|
|
59
|
+
locator: string;
|
|
60
|
+
turnId: string;
|
|
61
|
+
threadKind: string;
|
|
62
|
+
parentThreadId?: string;
|
|
63
|
+
parser: string;
|
|
64
|
+
redaction: number;
|
|
65
|
+
format: number;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RenderedThread {
|
|
70
|
+
/** Archive file path relative to the collection root. */
|
|
71
|
+
relPath: string;
|
|
72
|
+
content: string;
|
|
73
|
+
lines: number;
|
|
74
|
+
humanTurns: number;
|
|
75
|
+
assistantTurns: number;
|
|
76
|
+
redactions: number;
|
|
77
|
+
overLimit: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bound = (value: string): string =>
|
|
81
|
+
value.length > PROVENANCE_FIELD_CHARS
|
|
82
|
+
? `${value.slice(0, PROVENANCE_FIELD_CHARS - 1)}…`
|
|
83
|
+
: value;
|
|
84
|
+
|
|
85
|
+
/** Lowercase tag segment accepted by GNO's tag grammar. */
|
|
86
|
+
function tagSegment(value: string): string {
|
|
87
|
+
const slug = value
|
|
88
|
+
.normalize("NFC")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^\p{Ll}\p{Lo}\p{N}.-]+/gu, "-")
|
|
91
|
+
.replace(/^[^\p{Ll}\p{Lo}\p{N}]+/u, "")
|
|
92
|
+
.replace(/-+$/u, "")
|
|
93
|
+
.slice(0, 64);
|
|
94
|
+
return slug || "unknown";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Opaque identity of a working directory; the raw path is never stored. */
|
|
98
|
+
function projectIdentity(cwd: string | undefined): {
|
|
99
|
+
label: string;
|
|
100
|
+
id: string;
|
|
101
|
+
} | null {
|
|
102
|
+
if (!cwd) return null;
|
|
103
|
+
const normalized = cwd.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
104
|
+
return {
|
|
105
|
+
label: basename(normalized) || "root",
|
|
106
|
+
id: hashRecordValue("gno-session-project-v1", normalized).slice(0, 12),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stable, path-free archive file for one thread of one unit. The unit key
|
|
112
|
+
* namespaces the file, so two units that report the same thread ID never
|
|
113
|
+
* overwrite each other.
|
|
114
|
+
*/
|
|
115
|
+
export function threadRelPath(
|
|
116
|
+
sourceId: string,
|
|
117
|
+
harness: SessionHarness,
|
|
118
|
+
unitKey: string,
|
|
119
|
+
threadId: string
|
|
120
|
+
): string {
|
|
121
|
+
const fileKey = hashRecordValue(
|
|
122
|
+
"gno-session-thread-v2",
|
|
123
|
+
`${harness}\0${sourceId}\0${unitKey}\0${threadId}`
|
|
124
|
+
).slice(0, 32);
|
|
125
|
+
return `${harness}/${sourceId}/${fileKey}.jsonl`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function archiveFilePath(
|
|
129
|
+
archiveRoot: string,
|
|
130
|
+
collection: string,
|
|
131
|
+
relPath: string
|
|
132
|
+
): string {
|
|
133
|
+
return join(archiveRoot, collection, relPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const roleLabel = (role: "human" | "assistant"): string =>
|
|
137
|
+
role === "human" ? "Human" : "Assistant";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render a parsed thread into sanitized archive lines. Sanitization runs
|
|
141
|
+
* over every persisted field (text, titles, labels, locators) before
|
|
142
|
+
* anything is written, with a second pass propagating detected values
|
|
143
|
+
* across the whole thread.
|
|
144
|
+
*/
|
|
145
|
+
export function renderThread(options: {
|
|
146
|
+
thread: ParsedThread;
|
|
147
|
+
sourceId: string;
|
|
148
|
+
unitKey: string;
|
|
149
|
+
unitLocator: string;
|
|
150
|
+
parser: string;
|
|
151
|
+
redaction: RedactionPolicy;
|
|
152
|
+
}): RenderedThread {
|
|
153
|
+
const { thread, sourceId, parser } = options;
|
|
154
|
+
const sanitizer = new ThreadSanitizer(options.redaction);
|
|
155
|
+
const project = projectIdentity(thread.cwd);
|
|
156
|
+
// Pass 1 detects secrets in every persisted field; pass 2 (`clean`)
|
|
157
|
+
// propagates values found anywhere in the thread into every field, so a
|
|
158
|
+
// secret revealed in a later turn is also removed from titles, tags and IDs.
|
|
159
|
+
const raw = {
|
|
160
|
+
project: project ? sanitizer.sanitize(project.label) : null,
|
|
161
|
+
sessionId: sanitizer.sanitize(thread.sessionId),
|
|
162
|
+
threadId: sanitizer.sanitize(thread.threadId),
|
|
163
|
+
parentThreadId: thread.parentThreadId
|
|
164
|
+
? sanitizer.sanitize(thread.parentThreadId)
|
|
165
|
+
: null,
|
|
166
|
+
unit: sanitizer.sanitize(options.unitLocator),
|
|
167
|
+
};
|
|
168
|
+
const rawDrafts = thread.turns.map((turn) => ({
|
|
169
|
+
turn,
|
|
170
|
+
text: sanitizer.sanitize(turn.text),
|
|
171
|
+
turnId: sanitizer.sanitize(turn.turnId),
|
|
172
|
+
locator: sanitizer.sanitize(turn.locator),
|
|
173
|
+
}));
|
|
174
|
+
const clean = (value: string): string => sanitizer.propagate(value);
|
|
175
|
+
const projectLabel = raw.project === null ? null : clean(raw.project);
|
|
176
|
+
const harnessLabel = SESSION_HARNESS_LABELS[thread.harness];
|
|
177
|
+
const sessionId = `${thread.harness}/${sourceId}/${clean(raw.sessionId)}`;
|
|
178
|
+
const threadId = `${thread.harness}/${sourceId}/${clean(raw.threadId)}`;
|
|
179
|
+
const parentThreadId =
|
|
180
|
+
raw.parentThreadId === null
|
|
181
|
+
? null
|
|
182
|
+
: `${thread.harness}/${sourceId}/${clean(raw.parentThreadId)}`;
|
|
183
|
+
const unit = clean(raw.unit);
|
|
184
|
+
|
|
185
|
+
const categories = [
|
|
186
|
+
"session",
|
|
187
|
+
`harness/${tagSegment(thread.harness)}`,
|
|
188
|
+
`session-kind/${thread.kind}`,
|
|
189
|
+
];
|
|
190
|
+
if (projectLabel && project) {
|
|
191
|
+
categories.push(`project/${tagSegment(projectLabel)}`);
|
|
192
|
+
categories.push(`project-id/${project.id}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const drafts = rawDrafts.map((draft) => ({
|
|
196
|
+
turn: draft.turn,
|
|
197
|
+
text: clean(draft.text),
|
|
198
|
+
turnId: clean(draft.turnId),
|
|
199
|
+
locator: clean(draft.locator),
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
const lines: string[] = [];
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
let human = 0;
|
|
205
|
+
let assistant = 0;
|
|
206
|
+
let overLimit = false;
|
|
207
|
+
for (const draft of drafts) {
|
|
208
|
+
const { turn } = draft;
|
|
209
|
+
const { text } = draft;
|
|
210
|
+
const speaker = roleLabel(turn.role);
|
|
211
|
+
const titleParts = [speaker, harnessLabel, projectLabel ?? "no project"];
|
|
212
|
+
// One bounded line keeps each record small in bounded context delivery.
|
|
213
|
+
const provenance = [
|
|
214
|
+
turn.role === "assistant"
|
|
215
|
+
? "Assistant (assistant output, not a user decision)"
|
|
216
|
+
: "Human",
|
|
217
|
+
// A known time travels as record metadata (dateFields.recorded).
|
|
218
|
+
...(turn.timestamp ? [] : ["recorded unknown"]),
|
|
219
|
+
`source ${bound(sourceId)}`,
|
|
220
|
+
`locator ${bound(`${unit}#${draft.locator}`)}`,
|
|
221
|
+
`turn ${bound(draft.turnId)}`,
|
|
222
|
+
].join(" · ");
|
|
223
|
+
const line: ArchiveLine = {
|
|
224
|
+
id: hashRecordValue(
|
|
225
|
+
"gno-session-turn-v1",
|
|
226
|
+
`${threadId}\0${draft.turnId}`
|
|
227
|
+
).slice(0, 32),
|
|
228
|
+
title: titleParts.join(" · "),
|
|
229
|
+
body: `${speaker}: ${text}\n\nProvenance: ${provenance}`,
|
|
230
|
+
author: turn.role,
|
|
231
|
+
categories: [...categories, `role/${turn.role}`],
|
|
232
|
+
// A main thread is its own session; the duplicate ID is omitted.
|
|
233
|
+
...(sessionId === threadId ? {} : { sessionId }),
|
|
234
|
+
threadId,
|
|
235
|
+
...(turn.timestamp ? { recordedAt: turn.timestamp } : {}),
|
|
236
|
+
provenance: {
|
|
237
|
+
sourceId,
|
|
238
|
+
harness: thread.harness,
|
|
239
|
+
unit,
|
|
240
|
+
locator: draft.locator,
|
|
241
|
+
turnId: draft.turnId,
|
|
242
|
+
threadKind: thread.kind,
|
|
243
|
+
...(parentThreadId ? { parentThreadId } : {}),
|
|
244
|
+
parser,
|
|
245
|
+
redaction: SESSION_REDACTION_VERSION,
|
|
246
|
+
format: SESSION_ARCHIVE_FORMAT_VERSION,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const serialized = JSON.stringify(line);
|
|
250
|
+
bytes += serialized.length + 1;
|
|
251
|
+
if (bytes > SESSION_LIMITS.maxArchiveBytesPerThread) {
|
|
252
|
+
overLimit = true;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
lines.push(serialized);
|
|
256
|
+
if (turn.role === "human") human += 1;
|
|
257
|
+
else assistant += 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
relPath: threadRelPath(
|
|
262
|
+
sourceId,
|
|
263
|
+
thread.harness,
|
|
264
|
+
options.unitKey,
|
|
265
|
+
thread.threadId
|
|
266
|
+
),
|
|
267
|
+
content: lines.length > 0 ? `${lines.join("\n")}\n` : "",
|
|
268
|
+
lines: lines.length,
|
|
269
|
+
humanTurns: human,
|
|
270
|
+
assistantTurns: assistant,
|
|
271
|
+
redactions: sanitizer.redactions,
|
|
272
|
+
overLimit,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
type JsonValue =
|
|
277
|
+
| string
|
|
278
|
+
| number
|
|
279
|
+
| boolean
|
|
280
|
+
| null
|
|
281
|
+
| JsonValue[]
|
|
282
|
+
| { [key: string]: JsonValue };
|
|
283
|
+
|
|
284
|
+
/** Apply `fn` to every string leaf of a parsed archive line. */
|
|
285
|
+
function mapStrings(value: JsonValue, fn: (text: string) => string): JsonValue {
|
|
286
|
+
if (typeof value === "string") return fn(value);
|
|
287
|
+
if (Array.isArray(value)) return value.map((item) => mapStrings(item, fn));
|
|
288
|
+
if (value && typeof value === "object") {
|
|
289
|
+
return Object.fromEntries(
|
|
290
|
+
Object.entries(value).map(([key, item]) => [key, mapStrings(item, fn)])
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Re-sanitize an existing archive file with the current redaction rules
|
|
298
|
+
* (used when the original source is no longer available). Every persisted
|
|
299
|
+
* string field is rescanned, not only the text. Returns null when the file
|
|
300
|
+
* does not parse as an archive; the caller must then withhold it.
|
|
301
|
+
*/
|
|
302
|
+
export function rescanArchiveContent(
|
|
303
|
+
content: string,
|
|
304
|
+
redaction: RedactionPolicy
|
|
305
|
+
): { content: string; redactions: number } | null {
|
|
306
|
+
const sanitizer = new ThreadSanitizer(redaction);
|
|
307
|
+
const parsed: JsonValue[] = [];
|
|
308
|
+
for (const raw of content.split("\n")) {
|
|
309
|
+
if (!raw.trim()) continue;
|
|
310
|
+
let line: unknown;
|
|
311
|
+
try {
|
|
312
|
+
line = JSON.parse(raw);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const record = line as Partial<ArchiveLine> | null;
|
|
317
|
+
if (
|
|
318
|
+
!record ||
|
|
319
|
+
typeof record !== "object" ||
|
|
320
|
+
typeof record.body !== "string" ||
|
|
321
|
+
!record.provenance ||
|
|
322
|
+
typeof record.provenance !== "object"
|
|
323
|
+
) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
parsed.push(
|
|
327
|
+
mapStrings(line as JsonValue, (text) => sanitizer.sanitize(text))
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const output = parsed.map((line) => {
|
|
331
|
+
const propagated = mapStrings(line, (text) =>
|
|
332
|
+
sanitizer.propagate(text)
|
|
333
|
+
) as {
|
|
334
|
+
provenance: Record<string, JsonValue>;
|
|
335
|
+
};
|
|
336
|
+
return JSON.stringify({
|
|
337
|
+
...propagated,
|
|
338
|
+
provenance: {
|
|
339
|
+
...propagated.provenance,
|
|
340
|
+
redaction: SESSION_REDACTION_VERSION,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return {
|
|
345
|
+
content: output.length > 0 ? `${output.join("\n")}\n` : "",
|
|
346
|
+
redactions: sanitizer.redactions,
|
|
347
|
+
};
|
|
348
|
+
}
|