@datafog/fogclaw 0.2.0 → 0.3.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/backlog-tools.d.ts +57 -0
  3. package/dist/backlog-tools.d.ts.map +1 -0
  4. package/dist/backlog-tools.js +173 -0
  5. package/dist/backlog-tools.js.map +1 -0
  6. package/dist/backlog.d.ts +82 -0
  7. package/dist/backlog.d.ts.map +1 -0
  8. package/dist/backlog.js +169 -0
  9. package/dist/backlog.js.map +1 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +6 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +87 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/message-sending-handler.d.ts +2 -1
  18. package/dist/message-sending-handler.d.ts.map +1 -1
  19. package/dist/message-sending-handler.js +5 -1
  20. package/dist/message-sending-handler.js.map +1 -1
  21. package/dist/tool-result-handler.d.ts +2 -1
  22. package/dist/tool-result-handler.d.ts.map +1 -1
  23. package/dist/tool-result-handler.js +5 -1
  24. package/dist/tool-result-handler.js.map +1 -1
  25. package/dist/types.d.ts +15 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/openclaw.plugin.json +11 -1
  29. package/package.json +7 -1
  30. package/.github/workflows/harness-docs.yml +0 -30
  31. package/AGENTS.md +0 -28
  32. package/docs/DATA.md +0 -28
  33. package/docs/DESIGN.md +0 -17
  34. package/docs/DOMAIN_DOCS.md +0 -30
  35. package/docs/FRONTEND.md +0 -24
  36. package/docs/OBSERVABILITY.md +0 -32
  37. package/docs/PLANS.md +0 -171
  38. package/docs/PRODUCT_SENSE.md +0 -20
  39. package/docs/RELIABILITY.md +0 -60
  40. package/docs/SECURITY.md +0 -52
  41. package/docs/design-docs/core-beliefs.md +0 -17
  42. package/docs/design-docs/index.md +0 -8
  43. package/docs/generated/README.md +0 -36
  44. package/docs/generated/memory.md +0 -1
  45. package/docs/plans/2026-02-16-fogclaw-design.md +0 -172
  46. package/docs/plans/2026-02-16-fogclaw-implementation.md +0 -1606
  47. package/docs/plans/README.md +0 -15
  48. package/docs/plans/active/2026-02-16-feat-openclaw-official-submission-plan.md +0 -386
  49. package/docs/plans/active/2026-02-17-feat-release-fogclaw-via-datafog-package-plan.md +0 -328
  50. package/docs/plans/active/2026-02-17-feat-submit-fogclaw-to-openclaw-plan.md +0 -244
  51. package/docs/plans/active/2026-02-17-feat-tool-result-pii-scanning-plan.md +0 -293
  52. package/docs/plans/tech-debt-tracker.md +0 -42
  53. package/docs/plugins/fogclaw.md +0 -101
  54. package/docs/runbooks/address-review-findings.md +0 -30
  55. package/docs/runbooks/ci-failures.md +0 -46
  56. package/docs/runbooks/code-review.md +0 -34
  57. package/docs/runbooks/merge-change.md +0 -28
  58. package/docs/runbooks/pull-request.md +0 -45
  59. package/docs/runbooks/record-evidence.md +0 -43
  60. package/docs/runbooks/reproduce-bug.md +0 -42
  61. package/docs/runbooks/respond-to-feedback.md +0 -42
  62. package/docs/runbooks/review-findings.md +0 -31
  63. package/docs/runbooks/submit-openclaw-plugin.md +0 -68
  64. package/docs/runbooks/update-agents-md.md +0 -59
  65. package/docs/runbooks/update-domain-docs.md +0 -42
  66. package/docs/runbooks/validate-current-state.md +0 -41
  67. package/docs/runbooks/verify-release.md +0 -69
  68. package/docs/specs/2026-02-16-feat-openclaw-official-submission-spec.md +0 -115
  69. package/docs/specs/2026-02-17-feat-outbound-message-pii-scanning-spec.md +0 -93
  70. package/docs/specs/2026-02-17-feat-submit-fogclaw-to-openclaw.md +0 -125
  71. package/docs/specs/2026-02-17-feat-tool-result-pii-scanning-spec.md +0 -122
  72. package/docs/specs/README.md +0 -5
  73. package/docs/specs/index.md +0 -8
  74. package/docs/spikes/README.md +0 -8
  75. package/fogclaw.config.example.json +0 -33
  76. package/scripts/ci/he-docs-config.json +0 -123
  77. package/scripts/ci/he-docs-drift.sh +0 -112
  78. package/scripts/ci/he-docs-lint.sh +0 -234
  79. package/scripts/ci/he-plans-lint.sh +0 -354
  80. package/scripts/ci/he-runbooks-lint.sh +0 -445
  81. package/scripts/ci/he-specs-lint.sh +0 -258
  82. package/scripts/ci/he-spikes-lint.sh +0 -249
  83. package/scripts/runbooks/select-runbooks.sh +0 -154
  84. package/src/config.ts +0 -183
  85. package/src/engines/gliner.ts +0 -240
  86. package/src/engines/regex.ts +0 -71
  87. package/src/extract.ts +0 -98
  88. package/src/index.ts +0 -381
  89. package/src/message-sending-handler.ts +0 -87
  90. package/src/redactor.ts +0 -51
  91. package/src/scanner.ts +0 -196
  92. package/src/tool-result-handler.ts +0 -133
  93. package/src/types.ts +0 -75
  94. package/tests/config.test.ts +0 -78
  95. package/tests/extract.test.ts +0 -185
  96. package/tests/gliner.test.ts +0 -289
  97. package/tests/message-sending-handler.test.ts +0 -244
  98. package/tests/plugin-smoke.test.ts +0 -250
  99. package/tests/redactor.test.ts +0 -320
  100. package/tests/regex.test.ts +0 -345
  101. package/tests/scanner.test.ts +0 -348
  102. package/tests/tool-result-handler.test.ts +0 -329
  103. package/tsconfig.json +0 -20
package/src/scanner.ts DELETED
@@ -1,196 +0,0 @@
1
- import type { Entity, FogClawConfig } from "./types.js";
2
- import { canonicalType } from "./types.js";
3
- import { RegexEngine } from "./engines/regex.js";
4
- import { GlinerEngine } from "./engines/gliner.js";
5
-
6
- type AllowlistPatternCache = {
7
- values: Set<string>;
8
- patterns: RegExp[];
9
- entityValues: Map<string, Set<string>>;
10
- };
11
-
12
- function normalizeAllowlistValue(value: string): string {
13
- return value.trim().toLowerCase();
14
- }
15
-
16
- function buildPatternMaps(value: string[] | undefined): RegExp[] {
17
- if (!value || value.length === 0) {
18
- return [];
19
- }
20
-
21
- return value.map((pattern) => new RegExp(pattern, "i"));
22
- }
23
-
24
- export class Scanner {
25
- private regexEngine: RegexEngine;
26
- private glinerEngine: GlinerEngine;
27
- private glinerAvailable = false;
28
- private config: FogClawConfig;
29
- private allowlist: AllowlistPatternCache;
30
-
31
- constructor(config: FogClawConfig) {
32
- this.config = config;
33
- this.regexEngine = new RegexEngine();
34
-
35
- const glinerThreshold = this.computeGlinerThreshold(config);
36
- this.glinerEngine = new GlinerEngine(config.model, glinerThreshold);
37
- if (config.custom_entities.length > 0) {
38
- this.glinerEngine.setCustomLabels(config.custom_entities);
39
- }
40
-
41
- this.allowlist = this.buildAllowlistCache(config.allowlist);
42
- }
43
-
44
- async initialize(): Promise<void> {
45
- try {
46
- await this.glinerEngine.initialize();
47
- this.glinerAvailable = true;
48
- } catch (err) {
49
- console.warn(
50
- `[fogclaw] GLiNER failed to initialize, falling back to regex-only mode: ${err instanceof Error ? err.message : String(err)}`,
51
- );
52
- this.glinerAvailable = false;
53
- }
54
- }
55
-
56
- async scan(text: string, extraLabels?: string[]): Promise<{ entities: Entity[]; text: string }> {
57
- if (!text) return { entities: [], text };
58
-
59
- // Step 1: Regex pass (always runs, synchronous)
60
- const regexEntities = this.filterByPolicy(this.regexEngine.scan(text));
61
-
62
- // Step 2: GLiNER pass (if available)
63
- let glinerEntities: Entity[] = [];
64
- if (this.glinerAvailable) {
65
- try {
66
- glinerEntities = await this.glinerEngine.scan(text, extraLabels);
67
- glinerEntities = this.filterByConfidence(glinerEntities);
68
- glinerEntities = this.filterByPolicy(glinerEntities);
69
- } catch (err) {
70
- console.warn(
71
- `[fogclaw] GLiNER scan failed, using regex results only: ${
72
- err instanceof Error ? err.message : String(err)
73
- }`,
74
- );
75
- }
76
- }
77
-
78
- // Step 3: Merge and deduplicate
79
- const merged = deduplicateEntities([...regexEntities, ...glinerEntities]);
80
-
81
- return { entities: merged, text };
82
- }
83
-
84
- private filterByConfidence(entities: Entity[]): Entity[] {
85
- return entities.filter((entity) => {
86
- const threshold = this.getThresholdForLabel(entity.label);
87
- return entity.confidence >= threshold;
88
- });
89
- }
90
-
91
- private filterByPolicy(entities: Entity[]): Entity[] {
92
- if (
93
- this.allowlist.values.size === 0 &&
94
- this.allowlist.patterns.length === 0 &&
95
- this.allowlist.entityValues.size === 0
96
- ) {
97
- return entities;
98
- }
99
-
100
- return entities.filter((entity) => !this.shouldAllowlistEntity(entity));
101
- }
102
-
103
- private shouldAllowlistEntity(entity: Entity): boolean {
104
- const normalizedText = normalizeAllowlistValue(entity.text);
105
-
106
- if (this.allowlist.values.has(normalizedText)) {
107
- return true;
108
- }
109
-
110
- if (this.allowlist.patterns.some((pattern) => pattern.test(entity.text))) {
111
- return true;
112
- }
113
-
114
- const entityValues = this.allowlist.entityValues.get(entity.label);
115
- if (entityValues && entityValues.has(normalizedText)) {
116
- return true;
117
- }
118
-
119
- return false;
120
- }
121
-
122
- private getThresholdForLabel(label: string): number {
123
- const canonicalLabel = canonicalType(label);
124
- return this.config.entityConfidenceThresholds[canonicalLabel] ?? this.config.confidence_threshold;
125
- }
126
-
127
- private computeGlinerThreshold(config: FogClawConfig): number {
128
- const thresholds = Object.values(config.entityConfidenceThresholds);
129
- if (thresholds.length === 0) {
130
- return config.confidence_threshold;
131
- }
132
-
133
- return Math.min(config.confidence_threshold, ...thresholds);
134
- }
135
-
136
- private buildAllowlistCache(allowlist: FogClawConfig["allowlist"]): AllowlistPatternCache {
137
- const globalValues = new Set(
138
- allowlist.values.map((value) => normalizeAllowlistValue(value)),
139
- );
140
-
141
- const globalPatterns = buildPatternMaps(allowlist.patterns);
142
-
143
- const entityValues = new Map<string, Set<string>>();
144
- for (const [entityType, values] of Object.entries(allowlist.entities)) {
145
- const canonical = canonicalType(entityType);
146
- const uniqueValues = values
147
- .map((value) => normalizeAllowlistValue(value))
148
- .filter((value) => value.length > 0);
149
- entityValues.set(canonical, new Set(uniqueValues));
150
- }
151
-
152
- return {
153
- values: globalValues,
154
- patterns: globalPatterns,
155
- entityValues,
156
- };
157
- }
158
-
159
- get isGlinerAvailable(): boolean {
160
- return this.glinerAvailable;
161
- }
162
- }
163
-
164
- /**
165
- * Remove overlapping entity spans. When two entities overlap,
166
- * keep the one with higher confidence. If equal, prefer regex.
167
- */
168
- function deduplicateEntities(entities: Entity[]): Entity[] {
169
- if (entities.length <= 1) return entities;
170
-
171
- // Sort by start position, then by confidence descending
172
- const sorted = [...entities].sort((a, b) => {
173
- if (a.start !== b.start) return a.start - b.start;
174
- return b.confidence - a.confidence;
175
- });
176
-
177
- const result: Entity[] = [sorted[0]];
178
-
179
- for (let i = 1; i < sorted.length; i++) {
180
- const current = sorted[i];
181
- const last = result[result.length - 1];
182
-
183
- // Check for overlap
184
- if (current.start < last.end) {
185
- // Overlapping: keep higher confidence (already in result if first)
186
- if (current.confidence > last.confidence) {
187
- result[result.length - 1] = current;
188
- }
189
- // Otherwise keep what's already in result
190
- } else {
191
- result.push(current);
192
- }
193
- }
194
-
195
- return result;
196
- }
@@ -1,133 +0,0 @@
1
- /**
2
- * Synchronous tool_result_persist hook handler for FogClaw.
3
- *
4
- * Scans tool result text for PII using the regex engine (synchronous),
5
- * redacts detected entities, and returns the transformed message.
6
- * GLiNER is not used here because tool_result_persist is synchronous-only.
7
- */
8
-
9
- import { RegexEngine } from "./engines/regex.js";
10
- import { redact } from "./redactor.js";
11
- import { extractText, replaceText } from "./extract.js";
12
- import { canonicalType, resolveAction } from "./types.js";
13
- import type { Entity, FogClawConfig } from "./types.js";
14
-
15
- interface Logger {
16
- info(msg: string): void;
17
- warn(msg: string): void;
18
- }
19
-
20
- export interface ToolResultPersistEvent {
21
- toolName?: string;
22
- toolCallId?: string;
23
- message: unknown;
24
- isSynthetic?: boolean;
25
- }
26
-
27
- export interface ToolResultPersistContext {
28
- agentId?: string;
29
- sessionKey?: string;
30
- toolName?: string;
31
- toolCallId?: string;
32
- }
33
-
34
- /**
35
- * Build an allowlist filter from config. Replicates Scanner.filterByPolicy
36
- * and Scanner.shouldAllowlistEntity logic synchronously.
37
- */
38
- function buildAllowlistFilter(config: FogClawConfig): (entity: Entity) => boolean {
39
- const globalValues = new Set(
40
- config.allowlist.values.map((v) => v.trim().toLowerCase()),
41
- );
42
-
43
- const globalPatterns = config.allowlist.patterns
44
- .filter((p) => p.length > 0)
45
- .map((p) => new RegExp(p, "i"));
46
-
47
- const entityValues = new Map<string, Set<string>>();
48
- for (const [entityType, values] of Object.entries(config.allowlist.entities)) {
49
- const canonical = canonicalType(entityType);
50
- const set = new Set(
51
- values
52
- .map((v) => v.trim().toLowerCase())
53
- .filter((v) => v.length > 0),
54
- );
55
- entityValues.set(canonical, set);
56
- }
57
-
58
- // Short-circuit: if no allowlist entries, keep everything
59
- if (globalValues.size === 0 && globalPatterns.length === 0 && entityValues.size === 0) {
60
- return () => true;
61
- }
62
-
63
- // Return true if entity should be KEPT (not allowlisted)
64
- return (entity: Entity): boolean => {
65
- const normalizedText = entity.text.trim().toLowerCase();
66
-
67
- if (globalValues.has(normalizedText)) return false;
68
- if (globalPatterns.some((pattern) => pattern.test(entity.text))) return false;
69
-
70
- const perEntity = entityValues.get(entity.label);
71
- if (perEntity && perEntity.has(normalizedText)) return false;
72
-
73
- return true;
74
- };
75
- }
76
-
77
- /**
78
- * Create a synchronous tool_result_persist hook handler.
79
- *
80
- * The returned function must NOT return a Promise — OpenClaw rejects
81
- * async tool_result_persist handlers.
82
- */
83
- export function createToolResultHandler(
84
- config: FogClawConfig,
85
- regexEngine: RegexEngine,
86
- logger?: Logger,
87
- ): (event: ToolResultPersistEvent, ctx: ToolResultPersistContext) => { message: unknown } | void {
88
- const shouldKeep = buildAllowlistFilter(config);
89
-
90
- return (event: ToolResultPersistEvent, _ctx: ToolResultPersistContext): { message: unknown } | void => {
91
- const text = extractText(event.message);
92
- if (!text) return;
93
-
94
- // Scan with regex engine (synchronous)
95
- let entities = regexEngine.scan(text);
96
- if (entities.length === 0) return;
97
-
98
- // Apply allowlist filtering
99
- entities = entities.filter(shouldKeep);
100
- if (entities.length === 0) return;
101
-
102
- // All guardrail modes produce span-level redaction in tool results.
103
- // Determine which entities are actionable (all of them — block/warn/redact
104
- // all produce redaction at the tool result level).
105
- const actionableEntities = entities.filter((entity) => {
106
- const action = resolveAction(entity, config);
107
- return action === "redact" || action === "block" || action === "warn";
108
- });
109
-
110
- if (actionableEntities.length === 0) return;
111
-
112
- // Redact
113
- const result = redact(text, actionableEntities, config.redactStrategy);
114
-
115
- // Replace text in the message
116
- const modifiedMessage = replaceText(event.message, result.redacted_text);
117
-
118
- // Audit logging
119
- if (config.auditEnabled && logger) {
120
- const labels = [...new Set(actionableEntities.map((e) => e.label))];
121
- logger.info(
122
- `[FOGCLAW AUDIT] tool_result_scan ${JSON.stringify({
123
- totalEntities: actionableEntities.length,
124
- labels,
125
- toolName: event.toolName ?? null,
126
- source: "tool_result",
127
- })}`,
128
- );
129
- }
130
-
131
- return { message: modifiedMessage };
132
- };
133
- }
package/src/types.ts DELETED
@@ -1,75 +0,0 @@
1
- export interface Entity {
2
- text: string;
3
- label: string;
4
- start: number;
5
- end: number;
6
- confidence: number;
7
- source: "regex" | "gliner";
8
- }
9
-
10
- export type RedactStrategy = "token" | "mask" | "hash";
11
-
12
- export type GuardrailAction = "redact" | "block" | "warn";
13
-
14
- export interface EntityConfidenceThresholds {
15
- [entityType: string]: number;
16
- }
17
-
18
- export interface EntityAllowlist {
19
- values: string[];
20
- patterns: string[];
21
- entities: Record<string, string[]>;
22
- }
23
-
24
- export interface FogClawConfig {
25
- enabled: boolean;
26
- guardrail_mode: GuardrailAction;
27
- redactStrategy: RedactStrategy;
28
- model: string;
29
- confidence_threshold: number;
30
- custom_entities: string[];
31
- entityActions: Record<string, GuardrailAction>;
32
- entityConfidenceThresholds: EntityConfidenceThresholds;
33
- allowlist: EntityAllowlist;
34
- auditEnabled: boolean;
35
- }
36
-
37
- export interface ScanResult {
38
- entities: Entity[];
39
- text: string;
40
- }
41
-
42
- export interface RedactResult {
43
- redacted_text: string;
44
- mapping: Record<string, string>;
45
- entities: Entity[];
46
- }
47
-
48
- export interface GuardrailPlan {
49
- blocked: Entity[];
50
- warned: Entity[];
51
- redacted: Entity[];
52
- }
53
-
54
- export const CANONICAL_TYPE_MAP: Record<string, string> = {
55
- DOB: "DATE",
56
- ZIP: "ZIP_CODE",
57
- PER: "PERSON",
58
- ORG: "ORGANIZATION",
59
- GPE: "LOCATION",
60
- LOC: "LOCATION",
61
- FAC: "ADDRESS",
62
- PHONE_NUMBER: "PHONE",
63
- SOCIAL_SECURITY_NUMBER: "SSN",
64
- CREDIT_CARD_NUMBER: "CREDIT_CARD",
65
- DATE_OF_BIRTH: "DATE",
66
- };
67
-
68
- export function canonicalType(entityType: string): string {
69
- const normalized = entityType.toUpperCase().trim();
70
- return CANONICAL_TYPE_MAP[normalized] ?? normalized;
71
- }
72
-
73
- export function resolveAction(entity: Entity, config: FogClawConfig): GuardrailAction {
74
- return config.entityActions[entity.label] ?? config.guardrail_mode;
75
- }
@@ -1,78 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- import { loadConfig } from "../src/config.js";
4
-
5
- describe("FogClaw config", () => {
6
- it("loads defaults for new policy fields", () => {
7
- const config = loadConfig({});
8
-
9
- expect(config.entityConfidenceThresholds).toEqual({});
10
- expect(config.allowlist).toMatchObject({
11
- values: [],
12
- patterns: [],
13
- entities: {},
14
- });
15
- });
16
-
17
- it("canonicalizes per-entity confidence threshold keys", () => {
18
- const config = loadConfig({
19
- entityConfidenceThresholds: {
20
- person: 0.7,
21
- },
22
- });
23
-
24
- expect(config.entityConfidenceThresholds).toEqual({
25
- PERSON: 0.7,
26
- });
27
- });
28
-
29
- it("rejects invalid per-entity confidence thresholds", () => {
30
- expect(() =>
31
- loadConfig({
32
- entityConfidenceThresholds: {
33
- PERSON: 1.2,
34
- },
35
- }),
36
- ).toThrow('entityConfidenceThresholds["PERSON"] must be between 0 and 1, got 1.2');
37
- });
38
-
39
- it("validates allowlist regex patterns", () => {
40
- expect(() =>
41
- loadConfig({
42
- allowlist: {
43
- values: ["ok@example.com"],
44
- patterns: ["["],
45
- entities: {
46
- PERSON: ["John"],
47
- },
48
- },
49
- }),
50
- ).toThrow(/invalid regex pattern/);
51
- });
52
-
53
- it("canonicalizes allowlist entity keys", () => {
54
- const config = loadConfig({
55
- allowlist: {
56
- entities: {
57
- person: ["John"],
58
- },
59
- },
60
- });
61
-
62
- expect(config.allowlist.entities).toEqual({
63
- PERSON: ["John"],
64
- });
65
- });
66
-
67
- it("canonicalizes entity action labels", () => {
68
- const config = loadConfig({
69
- entityActions: {
70
- person: "block",
71
- },
72
- });
73
-
74
- expect(config.entityActions).toEqual({
75
- PERSON: "block",
76
- });
77
- });
78
- });
@@ -1,185 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { extractText, replaceText } from "../src/extract.js";
3
-
4
- describe("extractText", () => {
5
- it("extracts from a plain string", () => {
6
- expect(extractText("hello world")).toBe("hello world");
7
- });
8
-
9
- it("extracts from an object with content as string", () => {
10
- expect(extractText({ role: "toolResult", content: "file contents here" })).toBe(
11
- "file contents here",
12
- );
13
- });
14
-
15
- it("extracts from content block array with single text block", () => {
16
- const msg = {
17
- role: "toolResult",
18
- content: [{ type: "text", text: "block one" }],
19
- };
20
- expect(extractText(msg)).toBe("block one");
21
- });
22
-
23
- it("extracts from content block array with multiple text blocks", () => {
24
- const msg = {
25
- content: [
26
- { type: "text", text: "first" },
27
- { type: "text", text: "second" },
28
- ],
29
- };
30
- expect(extractText(msg)).toBe("first\0second");
31
- });
32
-
33
- it("skips non-text blocks in content array", () => {
34
- const msg = {
35
- content: [
36
- { type: "text", text: "visible" },
37
- { type: "image", source: { data: "base64..." } },
38
- { type: "text", text: "also visible" },
39
- ],
40
- };
41
- expect(extractText(msg)).toBe("visible\0also visible");
42
- });
43
-
44
- it("returns empty string for null", () => {
45
- expect(extractText(null)).toBe("");
46
- });
47
-
48
- it("returns empty string for undefined", () => {
49
- expect(extractText(undefined)).toBe("");
50
- });
51
-
52
- it("returns empty string for a number", () => {
53
- expect(extractText(42)).toBe("");
54
- });
55
-
56
- it("returns empty string for object with no content", () => {
57
- expect(extractText({ role: "toolResult" })).toBe("");
58
- });
59
-
60
- it("returns empty string for object with null content", () => {
61
- expect(extractText({ content: null })).toBe("");
62
- });
63
-
64
- it("returns empty string for empty content array", () => {
65
- expect(extractText({ content: [] })).toBe("");
66
- });
67
-
68
- it("returns empty string for content array with only image blocks", () => {
69
- const msg = {
70
- content: [
71
- { type: "image", source: { data: "..." } },
72
- { type: "image", source: { data: "..." } },
73
- ],
74
- };
75
- expect(extractText(msg)).toBe("");
76
- });
77
-
78
- it("handles empty string content", () => {
79
- expect(extractText({ content: "" })).toBe("");
80
- });
81
-
82
- it("handles text block with empty text", () => {
83
- const msg = { content: [{ type: "text", text: "" }] };
84
- expect(extractText(msg)).toBe("");
85
- });
86
-
87
- it("handles content array with mixed valid and invalid blocks", () => {
88
- const msg = {
89
- content: [
90
- { type: "text", text: "valid" },
91
- { type: "text" }, // missing text property
92
- null,
93
- { type: "text", text: "also valid" },
94
- ],
95
- };
96
- expect(extractText(msg)).toBe("valid\0also valid");
97
- });
98
- });
99
-
100
- describe("replaceText", () => {
101
- it("replaces plain string message", () => {
102
- expect(replaceText("original", "redacted")).toBe("redacted");
103
- });
104
-
105
- it("replaces content string in object", () => {
106
- const msg = { role: "toolResult", content: "original text" };
107
- const result = replaceText(msg, "redacted text") as Record<string, unknown>;
108
- expect(result.content).toBe("redacted text");
109
- expect(result.role).toBe("toolResult");
110
- });
111
-
112
- it("does not mutate the original message object", () => {
113
- const msg = { role: "toolResult", content: "original" };
114
- replaceText(msg, "redacted");
115
- expect(msg.content).toBe("original");
116
- });
117
-
118
- it("replaces single text block in content array", () => {
119
- const msg = {
120
- content: [{ type: "text", text: "original" }],
121
- };
122
- const result = replaceText(msg, "redacted") as Record<string, unknown>;
123
- const content = result.content as Array<Record<string, unknown>>;
124
- expect(content[0].text).toBe("redacted");
125
- expect(content[0].type).toBe("text");
126
- });
127
-
128
- it("replaces multiple text blocks using segment separator", () => {
129
- const msg = {
130
- content: [
131
- { type: "text", text: "first original" },
132
- { type: "text", text: "second original" },
133
- ],
134
- };
135
- const result = replaceText(msg, "first redacted\0second redacted") as Record<string, unknown>;
136
- const content = result.content as Array<Record<string, unknown>>;
137
- expect(content[0].text).toBe("first redacted");
138
- expect(content[1].text).toBe("second redacted");
139
- });
140
-
141
- it("preserves non-text blocks in content array", () => {
142
- const msg = {
143
- content: [
144
- { type: "text", text: "original" },
145
- { type: "image", source: { data: "base64" } },
146
- { type: "text", text: "also original" },
147
- ],
148
- };
149
- const result = replaceText(msg, "redacted\0also redacted") as Record<string, unknown>;
150
- const content = result.content as Array<Record<string, unknown>>;
151
- expect(content[0].text).toBe("redacted");
152
- expect((content[1] as any).type).toBe("image");
153
- expect((content[1] as any).source.data).toBe("base64");
154
- expect(content[2].text).toBe("also redacted");
155
- });
156
-
157
- it("returns null unchanged", () => {
158
- expect(replaceText(null, "x")).toBe(null);
159
- });
160
-
161
- it("returns undefined unchanged", () => {
162
- expect(replaceText(undefined, "x")).toBe(undefined);
163
- });
164
-
165
- it("returns number unchanged", () => {
166
- expect(replaceText(42, "x")).toBe(42);
167
- });
168
-
169
- it("returns message unchanged if content is null", () => {
170
- const msg = { content: null };
171
- expect(replaceText(msg, "x")).toBe(msg);
172
- });
173
-
174
- it("returns message unchanged if content is not string or array", () => {
175
- const msg = { content: 123 };
176
- expect(replaceText(msg, "x")).toBe(msg);
177
- });
178
-
179
- it("preserves extra properties on the message", () => {
180
- const msg = { role: "toolResult", content: "original", toolCallId: "abc123" };
181
- const result = replaceText(msg, "redacted") as Record<string, unknown>;
182
- expect(result.toolCallId).toBe("abc123");
183
- expect(result.role).toBe("toolResult");
184
- });
185
- });