claude-memory-layer 1.0.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 (127) hide show
  1. package/.claude-plugin/commands/memory-forget.md +42 -0
  2. package/.claude-plugin/commands/memory-history.md +34 -0
  3. package/.claude-plugin/commands/memory-import.md +56 -0
  4. package/.claude-plugin/commands/memory-list.md +37 -0
  5. package/.claude-plugin/commands/memory-search.md +36 -0
  6. package/.claude-plugin/commands/memory-stats.md +34 -0
  7. package/.claude-plugin/hooks.json +59 -0
  8. package/.claude-plugin/plugin.json +24 -0
  9. package/.history/package_20260201112328.json +45 -0
  10. package/.history/package_20260201113602.json +45 -0
  11. package/.history/package_20260201113713.json +45 -0
  12. package/.history/package_20260201114110.json +45 -0
  13. package/Memo.txt +558 -0
  14. package/README.md +520 -0
  15. package/context.md +636 -0
  16. package/dist/.claude-plugin/commands/memory-forget.md +42 -0
  17. package/dist/.claude-plugin/commands/memory-history.md +34 -0
  18. package/dist/.claude-plugin/commands/memory-import.md +56 -0
  19. package/dist/.claude-plugin/commands/memory-list.md +37 -0
  20. package/dist/.claude-plugin/commands/memory-search.md +36 -0
  21. package/dist/.claude-plugin/commands/memory-stats.md +34 -0
  22. package/dist/.claude-plugin/hooks.json +59 -0
  23. package/dist/.claude-plugin/plugin.json +24 -0
  24. package/dist/cli/index.js +3539 -0
  25. package/dist/cli/index.js.map +7 -0
  26. package/dist/core/index.js +4408 -0
  27. package/dist/core/index.js.map +7 -0
  28. package/dist/hooks/session-end.js +2971 -0
  29. package/dist/hooks/session-end.js.map +7 -0
  30. package/dist/hooks/session-start.js +2969 -0
  31. package/dist/hooks/session-start.js.map +7 -0
  32. package/dist/hooks/stop.js +3123 -0
  33. package/dist/hooks/stop.js.map +7 -0
  34. package/dist/hooks/user-prompt-submit.js +2960 -0
  35. package/dist/hooks/user-prompt-submit.js.map +7 -0
  36. package/dist/services/memory-service.js +2931 -0
  37. package/dist/services/memory-service.js.map +7 -0
  38. package/package.json +45 -0
  39. package/plan.md +1642 -0
  40. package/scripts/build.ts +102 -0
  41. package/spec.md +624 -0
  42. package/specs/citations-system/context.md +243 -0
  43. package/specs/citations-system/plan.md +495 -0
  44. package/specs/citations-system/spec.md +371 -0
  45. package/specs/endless-mode/context.md +305 -0
  46. package/specs/endless-mode/plan.md +620 -0
  47. package/specs/endless-mode/spec.md +455 -0
  48. package/specs/entity-edge-model/context.md +401 -0
  49. package/specs/entity-edge-model/plan.md +459 -0
  50. package/specs/entity-edge-model/spec.md +391 -0
  51. package/specs/evidence-aligner-v2/context.md +401 -0
  52. package/specs/evidence-aligner-v2/plan.md +303 -0
  53. package/specs/evidence-aligner-v2/spec.md +312 -0
  54. package/specs/mcp-desktop-integration/context.md +278 -0
  55. package/specs/mcp-desktop-integration/plan.md +550 -0
  56. package/specs/mcp-desktop-integration/spec.md +494 -0
  57. package/specs/post-tool-use-hook/context.md +319 -0
  58. package/specs/post-tool-use-hook/plan.md +469 -0
  59. package/specs/post-tool-use-hook/spec.md +364 -0
  60. package/specs/private-tags/context.md +288 -0
  61. package/specs/private-tags/plan.md +412 -0
  62. package/specs/private-tags/spec.md +345 -0
  63. package/specs/progressive-disclosure/context.md +346 -0
  64. package/specs/progressive-disclosure/plan.md +663 -0
  65. package/specs/progressive-disclosure/spec.md +415 -0
  66. package/specs/task-entity-system/context.md +297 -0
  67. package/specs/task-entity-system/plan.md +301 -0
  68. package/specs/task-entity-system/spec.md +314 -0
  69. package/specs/vector-outbox-v2/context.md +470 -0
  70. package/specs/vector-outbox-v2/plan.md +562 -0
  71. package/specs/vector-outbox-v2/spec.md +466 -0
  72. package/specs/web-viewer-ui/context.md +384 -0
  73. package/specs/web-viewer-ui/plan.md +797 -0
  74. package/specs/web-viewer-ui/spec.md +516 -0
  75. package/src/cli/index.ts +570 -0
  76. package/src/core/canonical-key.ts +186 -0
  77. package/src/core/citation-generator.ts +63 -0
  78. package/src/core/consolidated-store.ts +279 -0
  79. package/src/core/consolidation-worker.ts +384 -0
  80. package/src/core/context-formatter.ts +276 -0
  81. package/src/core/continuity-manager.ts +336 -0
  82. package/src/core/edge-repo.ts +324 -0
  83. package/src/core/embedder.ts +124 -0
  84. package/src/core/entity-repo.ts +342 -0
  85. package/src/core/event-store.ts +672 -0
  86. package/src/core/evidence-aligner.ts +635 -0
  87. package/src/core/graduation.ts +365 -0
  88. package/src/core/index.ts +32 -0
  89. package/src/core/matcher.ts +210 -0
  90. package/src/core/metadata-extractor.ts +203 -0
  91. package/src/core/privacy/filter.ts +179 -0
  92. package/src/core/privacy/index.ts +20 -0
  93. package/src/core/privacy/tag-parser.ts +145 -0
  94. package/src/core/progressive-retriever.ts +415 -0
  95. package/src/core/retriever.ts +235 -0
  96. package/src/core/task/blocker-resolver.ts +325 -0
  97. package/src/core/task/index.ts +9 -0
  98. package/src/core/task/task-matcher.ts +238 -0
  99. package/src/core/task/task-projector.ts +345 -0
  100. package/src/core/task/task-resolver.ts +414 -0
  101. package/src/core/types.ts +841 -0
  102. package/src/core/vector-outbox.ts +295 -0
  103. package/src/core/vector-store.ts +182 -0
  104. package/src/core/vector-worker.ts +488 -0
  105. package/src/core/working-set-store.ts +244 -0
  106. package/src/hooks/post-tool-use.ts +127 -0
  107. package/src/hooks/session-end.ts +78 -0
  108. package/src/hooks/session-start.ts +57 -0
  109. package/src/hooks/stop.ts +78 -0
  110. package/src/hooks/user-prompt-submit.ts +54 -0
  111. package/src/mcp/handlers.ts +212 -0
  112. package/src/mcp/index.ts +47 -0
  113. package/src/mcp/tools.ts +78 -0
  114. package/src/server/api/citations.ts +101 -0
  115. package/src/server/api/events.ts +101 -0
  116. package/src/server/api/index.ts +18 -0
  117. package/src/server/api/search.ts +98 -0
  118. package/src/server/api/sessions.ts +111 -0
  119. package/src/server/api/stats.ts +97 -0
  120. package/src/server/index.ts +91 -0
  121. package/src/services/memory-service.ts +626 -0
  122. package/src/services/session-history-importer.ts +367 -0
  123. package/tests/canonical-key.test.ts +101 -0
  124. package/tests/evidence-aligner.test.ts +152 -0
  125. package/tests/matcher.test.ts +112 -0
  126. package/tsconfig.json +24 -0
  127. package/vitest.config.ts +15 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Metadata Extractor
3
+ * Extracts tool-specific metadata from tool inputs and outputs
4
+ */
5
+
6
+ import type { ToolMetadata } from './types.js';
7
+
8
+ /**
9
+ * Get file type from path
10
+ */
11
+ function getFileType(filePath: string): string | undefined {
12
+ const ext = filePath.split('.').pop()?.toLowerCase();
13
+ if (!ext) return undefined;
14
+
15
+ const typeMap: Record<string, string> = {
16
+ ts: 'typescript',
17
+ tsx: 'typescript',
18
+ js: 'javascript',
19
+ jsx: 'javascript',
20
+ py: 'python',
21
+ rb: 'ruby',
22
+ go: 'go',
23
+ rs: 'rust',
24
+ java: 'java',
25
+ kt: 'kotlin',
26
+ swift: 'swift',
27
+ c: 'c',
28
+ cpp: 'cpp',
29
+ h: 'header',
30
+ hpp: 'header',
31
+ cs: 'csharp',
32
+ php: 'php',
33
+ html: 'html',
34
+ css: 'css',
35
+ scss: 'scss',
36
+ json: 'json',
37
+ yaml: 'yaml',
38
+ yml: 'yaml',
39
+ xml: 'xml',
40
+ md: 'markdown',
41
+ sql: 'sql',
42
+ sh: 'shell',
43
+ bash: 'shell',
44
+ zsh: 'shell'
45
+ };
46
+
47
+ return typeMap[ext];
48
+ }
49
+
50
+ /**
51
+ * Count lines in content
52
+ */
53
+ function countLines(content: string): number {
54
+ return content.split('\n').length;
55
+ }
56
+
57
+ /**
58
+ * Extract bash command (without arguments that might contain secrets)
59
+ */
60
+ function extractCommand(fullCommand: string): string {
61
+ // Get first word (command name)
62
+ const parts = fullCommand.trim().split(/\s+/);
63
+ const command = parts[0];
64
+
65
+ // For common commands, include safe arguments
66
+ const safeCommands = ['git', 'npm', 'yarn', 'pnpm', 'node', 'python', 'go', 'cargo', 'make'];
67
+ if (safeCommands.includes(command) && parts.length > 1) {
68
+ // Include subcommand for these
69
+ return `${command} ${parts[1]}`;
70
+ }
71
+
72
+ return command;
73
+ }
74
+
75
+ /**
76
+ * Extract metadata from tool usage
77
+ */
78
+ export function extractMetadata(
79
+ toolName: string,
80
+ input: Record<string, unknown>,
81
+ output: string,
82
+ success: boolean
83
+ ): ToolMetadata {
84
+ switch (toolName) {
85
+ case 'Read': {
86
+ const filePath = input.file_path as string | undefined;
87
+ return {
88
+ filePath,
89
+ fileType: filePath ? getFileType(filePath) : undefined,
90
+ lineCount: success ? countLines(output) : undefined
91
+ };
92
+ }
93
+
94
+ case 'Write': {
95
+ const filePath = input.file_path as string | undefined;
96
+ const content = input.content as string | undefined;
97
+ return {
98
+ filePath,
99
+ fileType: filePath ? getFileType(filePath) : undefined,
100
+ lineCount: content ? countLines(content) : undefined
101
+ };
102
+ }
103
+
104
+ case 'Edit': {
105
+ const filePath = input.file_path as string | undefined;
106
+ return {
107
+ filePath,
108
+ fileType: filePath ? getFileType(filePath) : undefined
109
+ };
110
+ }
111
+
112
+ case 'Bash': {
113
+ const fullCommand = input.command as string | undefined;
114
+ return {
115
+ command: fullCommand ? extractCommand(fullCommand) : undefined
116
+ };
117
+ }
118
+
119
+ case 'Grep': {
120
+ const pattern = input.pattern as string | undefined;
121
+ // Count matches from output
122
+ const matchCount = success
123
+ ? (output.match(/\n/g) || []).length + (output.trim() ? 1 : 0)
124
+ : undefined;
125
+ return {
126
+ pattern,
127
+ matchCount
128
+ };
129
+ }
130
+
131
+ case 'Glob': {
132
+ const pattern = input.pattern as string | undefined;
133
+ const matchCount = success
134
+ ? (output.match(/\n/g) || []).length + (output.trim() ? 1 : 0)
135
+ : undefined;
136
+ return {
137
+ pattern,
138
+ matchCount
139
+ };
140
+ }
141
+
142
+ case 'WebFetch': {
143
+ const url = input.url as string | undefined;
144
+ // Try to extract status code from output
145
+ const statusMatch = output.match(/status:\s*(\d{3})/i);
146
+ return {
147
+ url,
148
+ statusCode: statusMatch ? parseInt(statusMatch[1], 10) : undefined
149
+ };
150
+ }
151
+
152
+ case 'WebSearch': {
153
+ return {};
154
+ }
155
+
156
+ case 'NotebookEdit': {
157
+ const notebookPath = input.notebook_path as string | undefined;
158
+ return {
159
+ filePath: notebookPath,
160
+ fileType: 'jupyter'
161
+ };
162
+ }
163
+
164
+ default:
165
+ return {};
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create embedding content for tool observation
171
+ */
172
+ export function createToolObservationEmbedding(
173
+ toolName: string,
174
+ metadata: ToolMetadata,
175
+ success: boolean
176
+ ): string {
177
+ const parts: string[] = [];
178
+
179
+ parts.push(`Tool: ${toolName}`);
180
+
181
+ if (metadata.filePath) {
182
+ parts.push(`File: ${metadata.filePath}`);
183
+ }
184
+ if (metadata.command) {
185
+ parts.push(`Command: ${metadata.command}`);
186
+ }
187
+ if (metadata.pattern) {
188
+ parts.push(`Pattern: ${metadata.pattern}`);
189
+ }
190
+ if (metadata.url) {
191
+ // Only include domain for privacy
192
+ try {
193
+ const url = new URL(metadata.url);
194
+ parts.push(`URL: ${url.hostname}`);
195
+ } catch {
196
+ // Invalid URL, skip
197
+ }
198
+ }
199
+
200
+ parts.push(`Result: ${success ? 'Success' : 'Failed'}`);
201
+
202
+ return parts.join('\n');
203
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Privacy Filter
3
+ * Combines pattern-based filtering with private tag parsing
4
+ */
5
+
6
+ import { parsePrivateTagsSafe, hasUnmatchedOpenTag } from './tag-parser.js';
7
+ import type { Config } from '../types.js';
8
+
9
+ export interface FilterResult {
10
+ content: string;
11
+ metadata: {
12
+ hasPrivateTags: boolean;
13
+ privateTagCount: number;
14
+ patternMatchCount: number;
15
+ originalLength: number;
16
+ filteredLength: number;
17
+ hasUnmatchedTags: boolean;
18
+ };
19
+ }
20
+
21
+ // Sensitive data patterns
22
+ const SENSITIVE_PATTERNS = [
23
+ /password\s*[:=]\s*['"]?[^\s'"]+/gi,
24
+ /api[_-]?key\s*[:=]\s*['"]?[^\s'"]+/gi,
25
+ /secret\s*[:=]\s*['"]?[^\s'"]+/gi,
26
+ /token\s*[:=]\s*['"]?[^\s'"]+/gi,
27
+ /bearer\s+[a-zA-Z0-9\-_.]+/gi,
28
+ /AWS[_-]?ACCESS[_-]?KEY[_-]?ID\s*[:=]\s*['"]?[A-Z0-9]+/gi,
29
+ /AWS[_-]?SECRET[_-]?ACCESS[_-]?KEY\s*[:=]\s*['"]?[^\s'"]+/gi,
30
+ /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/g,
31
+ /ghp_[a-zA-Z0-9]{36}/g, // GitHub Personal Access Token
32
+ /sk-[a-zA-Z0-9]{48}/g, // OpenAI API Key
33
+ ];
34
+
35
+ /**
36
+ * Apply privacy filter to content
37
+ */
38
+ export function applyPrivacyFilter(
39
+ content: string,
40
+ config: Config['privacy']
41
+ ): FilterResult {
42
+ let filtered = content;
43
+ let privateTagCount = 0;
44
+ let patternMatchCount = 0;
45
+ const hasUnmatchedTags = hasUnmatchedOpenTag(content);
46
+
47
+ // 1. Private tag filtering
48
+ if (config.privateTags?.enabled !== false) {
49
+ const tagResult = parsePrivateTagsSafe(filtered, {
50
+ formats: config.privateTags?.supportedFormats || ['xml'],
51
+ marker: config.privateTags?.marker || '[PRIVATE]'
52
+ });
53
+ filtered = tagResult.filtered;
54
+ privateTagCount = tagResult.stats.count;
55
+ }
56
+
57
+ // 2. Built-in sensitive pattern filtering
58
+ for (const pattern of SENSITIVE_PATTERNS) {
59
+ // Reset lastIndex for global regex
60
+ pattern.lastIndex = 0;
61
+ const matches = filtered.match(pattern);
62
+ if (matches) {
63
+ patternMatchCount += matches.length;
64
+ filtered = filtered.replace(pattern, '[REDACTED]');
65
+ }
66
+ }
67
+
68
+ // 3. Custom pattern filtering from config
69
+ for (const patternStr of config.excludePatterns || []) {
70
+ try {
71
+ const regex = new RegExp(
72
+ `(${patternStr})\\s*[:=]\\s*['"]?[^\\s'"]+`,
73
+ 'gi'
74
+ );
75
+ const matches = filtered.match(regex);
76
+ if (matches) {
77
+ patternMatchCount += matches.length;
78
+ filtered = filtered.replace(regex, '[REDACTED]');
79
+ }
80
+ } catch {
81
+ // Invalid regex pattern, skip
82
+ }
83
+ }
84
+
85
+ // 4. Clean up consecutive markers
86
+ filtered = filtered.replace(/(\[PRIVATE\]\s*)+/g, '[PRIVATE]\n');
87
+ filtered = filtered.replace(/(\[REDACTED\]\s*)+/g, '[REDACTED] ');
88
+
89
+ return {
90
+ content: filtered,
91
+ metadata: {
92
+ hasPrivateTags: privateTagCount > 0,
93
+ privateTagCount,
94
+ patternMatchCount,
95
+ originalLength: content.length,
96
+ filteredLength: filtered.length,
97
+ hasUnmatchedTags
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Mask sensitive data in tool input (recursively)
104
+ */
105
+ export function maskSensitiveInput(
106
+ input: Record<string, unknown>
107
+ ): Record<string, unknown> {
108
+ const result: Record<string, unknown> = {};
109
+
110
+ for (const [key, value] of Object.entries(input)) {
111
+ // Check if key suggests sensitive data
112
+ const sensitiveKeys = ['password', 'secret', 'key', 'token', 'auth', 'credential'];
113
+ const isSensitiveKey = sensitiveKeys.some(k =>
114
+ key.toLowerCase().includes(k)
115
+ );
116
+
117
+ if (isSensitiveKey && typeof value === 'string') {
118
+ result[key] = '[REDACTED]';
119
+ } else if (typeof value === 'string') {
120
+ // Apply pattern filtering to string values
121
+ let filtered = value;
122
+ for (const pattern of SENSITIVE_PATTERNS) {
123
+ pattern.lastIndex = 0;
124
+ filtered = filtered.replace(pattern, '[REDACTED]');
125
+ }
126
+ result[key] = filtered;
127
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
128
+ result[key] = maskSensitiveInput(value as Record<string, unknown>);
129
+ } else if (Array.isArray(value)) {
130
+ result[key] = value.map(item =>
131
+ typeof item === 'object' && item !== null
132
+ ? maskSensitiveInput(item as Record<string, unknown>)
133
+ : item
134
+ );
135
+ } else {
136
+ result[key] = value;
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * Truncate output with head + tail strategy
145
+ */
146
+ export function truncateOutput(
147
+ output: string,
148
+ options: { maxLength?: number; maxLines?: number }
149
+ ): string {
150
+ const { maxLength = 10000, maxLines = 100 } = options;
151
+
152
+ // Split into lines
153
+ const lines = output.split('\n');
154
+
155
+ // Apply line limit first
156
+ if (lines.length > maxLines) {
157
+ const headLines = Math.ceil(maxLines / 2);
158
+ const tailLines = Math.floor(maxLines / 2);
159
+ const head = lines.slice(0, headLines);
160
+ const tail = lines.slice(-tailLines);
161
+ const truncatedLines = [
162
+ ...head,
163
+ `\n... [${lines.length - maxLines} lines truncated] ...\n`,
164
+ ...tail
165
+ ];
166
+ output = truncatedLines.join('\n');
167
+ }
168
+
169
+ // Apply character limit
170
+ if (output.length > maxLength) {
171
+ const headChars = Math.ceil(maxLength / 2);
172
+ const tailChars = Math.floor(maxLength / 2);
173
+ output = output.slice(0, headChars) +
174
+ `\n... [${output.length - maxLength} characters truncated] ...\n` +
175
+ output.slice(-tailChars);
176
+ }
177
+
178
+ return output;
179
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Privacy Module
3
+ * Exports privacy-related utilities
4
+ */
5
+
6
+ export {
7
+ parsePrivateTags,
8
+ parsePrivateTagsSafe,
9
+ hasUnmatchedOpenTag,
10
+ type PrivateSection,
11
+ type ParseResult,
12
+ type ParseOptions
13
+ } from './tag-parser.js';
14
+
15
+ export {
16
+ applyPrivacyFilter,
17
+ maskSensitiveInput,
18
+ truncateOutput,
19
+ type FilterResult
20
+ } from './filter.js';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Private Tag Parser
3
+ * Parses and removes <private> tags from content
4
+ */
5
+
6
+ export interface PrivateSection {
7
+ start: number;
8
+ end: number;
9
+ content: string;
10
+ format: 'xml' | 'bracket' | 'comment';
11
+ }
12
+
13
+ export interface ParseResult {
14
+ filtered: string;
15
+ sections: PrivateSection[];
16
+ stats: {
17
+ count: number;
18
+ totalLength: number;
19
+ };
20
+ }
21
+
22
+ export interface ParseOptions {
23
+ formats: Array<'xml' | 'bracket' | 'comment'>;
24
+ marker: string;
25
+ }
26
+
27
+ // Tag patterns for different formats
28
+ const TAG_PATTERNS: Record<string, RegExp> = {
29
+ xml: /<private>([\s\S]*?)<\/private>/gi,
30
+ bracket: /\[private\]([\s\S]*?)\[\/private\]/gi,
31
+ comment: /<!--\s*private\s*-->([\s\S]*?)<!--\s*\/private\s*-->/gi
32
+ };
33
+
34
+ /**
35
+ * Parse and remove private tags from text
36
+ */
37
+ export function parsePrivateTags(
38
+ text: string,
39
+ options: ParseOptions
40
+ ): ParseResult {
41
+ const sections: PrivateSection[] = [];
42
+ let filtered = text;
43
+
44
+ // Find all private sections for each format
45
+ for (const format of options.formats) {
46
+ const pattern = TAG_PATTERNS[format];
47
+ if (!pattern) continue;
48
+
49
+ // Reset lastIndex for global regex
50
+ pattern.lastIndex = 0;
51
+
52
+ let match;
53
+ while ((match = pattern.exec(text)) !== null) {
54
+ sections.push({
55
+ start: match.index,
56
+ end: match.index + match[0].length,
57
+ content: match[1],
58
+ format: format as PrivateSection['format']
59
+ });
60
+ }
61
+ }
62
+
63
+ // Remove all tags and replace with marker
64
+ for (const format of options.formats) {
65
+ const pattern = TAG_PATTERNS[format];
66
+ if (!pattern) continue;
67
+
68
+ // Need to create new regex for replacement (global flag issues)
69
+ const replacePattern = new RegExp(pattern.source, 'gi');
70
+
71
+ filtered = filtered.replace(replacePattern, (_match, content: string) => {
72
+ // Empty tags are completely removed
73
+ if (!content.trim()) return '';
74
+ return options.marker;
75
+ });
76
+ }
77
+
78
+ return {
79
+ filtered,
80
+ sections,
81
+ stats: {
82
+ count: sections.length,
83
+ totalLength: sections.reduce((sum, s) => sum + s.content.length, 0)
84
+ }
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Parse private tags safely, protecting code blocks
90
+ */
91
+ export function parsePrivateTagsSafe(
92
+ text: string,
93
+ options: ParseOptions
94
+ ): ParseResult {
95
+ // 1. Extract and protect code blocks
96
+ const codeBlocks: string[] = [];
97
+ const textWithPlaceholders = text.replace(
98
+ /```[\s\S]*?```/g,
99
+ (match) => {
100
+ codeBlocks.push(match);
101
+ return `__CODE_BLOCK_${codeBlocks.length - 1}__`;
102
+ }
103
+ );
104
+
105
+ // Also protect inline code
106
+ const inlineCode: string[] = [];
107
+ const textWithAllPlaceholders = textWithPlaceholders.replace(
108
+ /`[^`]+`/g,
109
+ (match) => {
110
+ inlineCode.push(match);
111
+ return `__INLINE_CODE_${inlineCode.length - 1}__`;
112
+ }
113
+ );
114
+
115
+ // 2. Parse private tags
116
+ const result = parsePrivateTags(textWithAllPlaceholders, options);
117
+
118
+ // 3. Restore inline code
119
+ result.filtered = result.filtered.replace(
120
+ /__INLINE_CODE_(\d+)__/g,
121
+ (_, idx) => inlineCode[Number(idx)]
122
+ );
123
+
124
+ // 4. Restore code blocks
125
+ result.filtered = result.filtered.replace(
126
+ /__CODE_BLOCK_(\d+)__/g,
127
+ (_, idx) => codeBlocks[Number(idx)]
128
+ );
129
+
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Check if text has unclosed private tags
135
+ */
136
+ export function hasUnmatchedOpenTag(text: string): boolean {
137
+ // Check for opening tags without closing
138
+ const openXml = (text.match(/<private>/gi) || []).length;
139
+ const closeXml = (text.match(/<\/private>/gi) || []).length;
140
+
141
+ const openBracket = (text.match(/\[private\]/gi) || []).length;
142
+ const closeBracket = (text.match(/\[\/private\]/gi) || []).length;
143
+
144
+ return openXml !== closeXml || openBracket !== closeBracket;
145
+ }