@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.1

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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +1 -0
  3. package/README.md +124 -2
  4. package/THIRD_PARTY_NOTICES.md +66 -0
  5. package/ZCode-LICENSE +202 -0
  6. package/dist/index.mjs +4871 -0
  7. package/dist/native-command-plan.LICENSE.txt +221 -0
  8. package/dist/native-command-plan.json +16 -0
  9. package/dist/native-command-plan.mjs +8 -0
  10. package/dist/rpc-entry.mjs +5682 -0
  11. package/package.json +169 -4
  12. package/src/native/a2a-receipt-source.mjs +19 -0
  13. package/src/native/a2a-tools.mjs +517 -0
  14. package/src/native/activity-status.mjs +30 -0
  15. package/src/native/artifact-codec.mjs +481 -0
  16. package/src/native/artifact-store.mjs +1162 -0
  17. package/src/native/assistant-stream.mjs +76 -0
  18. package/src/native/background-command-port.mjs +95 -0
  19. package/src/native/browser-action-observation.mjs +43 -0
  20. package/src/native/browser-availability.mjs +22 -0
  21. package/src/native/browser-canvas-metrics.mjs +71 -0
  22. package/src/native/browser-dom.mjs +193 -0
  23. package/src/native/browser-playwright.mjs +1249 -0
  24. package/src/native/browser-vision.mjs +369 -0
  25. package/src/native/browser-workspace-policy.mjs +22 -0
  26. package/src/native/browser-workspace-protocol.mjs +95 -0
  27. package/src/native/browser-workspace-server.mjs +164 -0
  28. package/src/native/browser-workspace.mjs +355 -0
  29. package/src/native/captured-source.mjs +3 -0
  30. package/src/native/child-budget-allocation.mjs +87 -0
  31. package/src/native/child-capabilities.mjs +35 -0
  32. package/src/native/child-command-resource.mjs +20 -0
  33. package/src/native/child-history.mjs +188 -0
  34. package/src/native/child-merge.mjs +98 -0
  35. package/src/native/child-model-surface.mjs +19 -0
  36. package/src/native/child-observation.mjs +100 -0
  37. package/src/native/child-owner.mjs +620 -0
  38. package/src/native/child-publication.mjs +76 -0
  39. package/src/native/child-readers.mjs +366 -0
  40. package/src/native/child-tools.mjs +46 -0
  41. package/src/native/child-workspace-binding.mjs +29 -0
  42. package/src/native/child-workspace-tools.mjs +1 -0
  43. package/src/native/child-workspaces.mjs +655 -0
  44. package/src/native/children.mjs +365 -0
  45. package/src/native/command-plan-worker.mjs +14 -0
  46. package/src/native/command-plan.mjs +31 -0
  47. package/src/native/command-progress.mjs +64 -0
  48. package/src/native/command-shell.mjs +9 -0
  49. package/src/native/command-workspace-observation.mjs +36 -0
  50. package/src/native/component-tools.mjs +280 -0
  51. package/src/native/context-counter.mjs +133 -0
  52. package/src/native/context-markers.mjs +51 -0
  53. package/src/native/context-profile.mjs +72 -0
  54. package/src/native/context-status.mjs +27 -0
  55. package/src/native/conversation-command-control.mjs +133 -0
  56. package/src/native/delivery-timeline.mjs +39 -0
  57. package/src/native/dsh-background-ownership.mjs +102 -0
  58. package/src/native/dsh-managed-ownership.mjs +23 -0
  59. package/src/native/dsh-turn-contract.mjs +3 -0
  60. package/src/native/dsh-turn-ownership.mjs +54 -0
  61. package/src/native/dsh-turn-runtime.mjs +498 -0
  62. package/src/native/e2b-background-supervisor.mjs +347 -0
  63. package/src/native/e2b-background.mjs +368 -0
  64. package/src/native/e2b-command-state.mjs +40 -0
  65. package/src/native/e2b-command-tools.mjs +486 -0
  66. package/src/native/e2b-sandbox-owner.mjs +227 -0
  67. package/src/native/hook-executor.mjs +249 -0
  68. package/src/native/instruction-assembly.mjs +60 -0
  69. package/src/native/instruction-context.mjs +54 -0
  70. package/src/native/local-background-supervisor.mjs +108 -0
  71. package/src/native/local-background.mjs +207 -0
  72. package/src/native/local-command-evidence.mjs +57 -0
  73. package/src/native/local-command-policy-worker.mjs +197 -0
  74. package/src/native/local-command-policy.mjs +46 -0
  75. package/src/native/local-command-state.mjs +177 -0
  76. package/src/native/local-command-tools.mjs +302 -0
  77. package/src/native/local-execution-profile.mjs +36 -0
  78. package/src/native/local-output-policy.mjs +92 -0
  79. package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
  80. package/src/native/local-process-bin/darwin-x64/launcher +0 -0
  81. package/src/native/local-process-bin/linux-arm64/launcher +0 -0
  82. package/src/native/local-process-bin/linux-x64/launcher +0 -0
  83. package/src/native/local-process-launcher.c +183 -0
  84. package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
  85. package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
  86. package/src/native/local-process-licenses/NOTICE +21 -0
  87. package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
  88. package/src/native/local-process-manifest.json +32 -0
  89. package/src/native/local-process.mjs +246 -0
  90. package/src/native/local-resource-profile.mjs +32 -0
  91. package/src/native/memory-candidate.mjs +121 -0
  92. package/src/native/memory-extraction-run.mjs +60 -0
  93. package/src/native/memory-extraction-tools.mjs +116 -0
  94. package/src/native/memory-recall.mjs +65 -0
  95. package/src/native/memory-store.mjs +334 -0
  96. package/src/native/memory-tools.mjs +329 -0
  97. package/src/native/model-anthropic-cache.mjs +49 -0
  98. package/src/native/model-anthropic-tool-search.mjs +85 -0
  99. package/src/native/model-anthropic.mjs +753 -0
  100. package/src/native/model-chat-usage.mjs +24 -0
  101. package/src/native/model-inline-reasoning.mjs +111 -0
  102. package/src/native/model-media.mjs +174 -0
  103. package/src/native/model-normalize.mjs +228 -0
  104. package/src/native/model-openai-chat.mjs +561 -0
  105. package/src/native/model-port.mjs +361 -0
  106. package/src/native/model-provider-compat.mjs +129 -0
  107. package/src/native/model-replay.mjs +153 -0
  108. package/src/native/model-request.mjs +15 -0
  109. package/src/native/model-responses.mjs +1205 -0
  110. package/src/native/model-sse.mjs +171 -0
  111. package/src/native/model-surface-host.mjs +27 -0
  112. package/src/native/model-tool-search-policy.mjs +16 -0
  113. package/src/native/model-transport-observation.mjs +27 -0
  114. package/src/native/owned-child.mjs +114 -0
  115. package/src/native/owned-command.mjs +106 -0
  116. package/src/native/python-syntax-worker.mjs +60 -0
  117. package/src/native/python-syntax.mjs +50 -0
  118. package/src/native/read-interruption.mjs +16 -0
  119. package/src/native/result-stream.mjs +63 -0
  120. package/src/native/ruff-ast.mjs +95 -0
  121. package/src/native/run-observation.mjs +128 -0
  122. package/src/native/session-delivery-journal.mjs +96 -0
  123. package/src/native/session-delivery.mjs +434 -0
  124. package/src/native/shell-analysis.mjs +50 -0
  125. package/src/native/skill-disclosure.mjs +404 -0
  126. package/src/native/skill-events.mjs +164 -0
  127. package/src/native/skill-tools.mjs +813 -0
  128. package/src/native/source-page.mjs +73 -0
  129. package/src/native/sqlite-session-inbox.mjs +42 -0
  130. package/src/native/sqlite-store.mjs +617 -0
  131. package/src/native/tool-authority.mjs +99 -0
  132. package/src/native/tool-guidance.mjs +12 -0
  133. package/src/native/tool-observation.mjs +75 -0
  134. package/src/native/tool-states.mjs +14 -0
  135. package/src/native/verification-authority.mjs +37 -0
  136. package/src/native/web-tools.mjs +643 -0
  137. package/src/native/workspace-checkpoints.mjs +312 -0
  138. package/src/native/workspace-context.mjs +61 -0
  139. package/src/native/workspace-display.mjs +39 -0
  140. package/src/native/workspace-edit-match.mjs +19 -0
  141. package/src/native/workspace-edit-worker.mjs +15 -0
  142. package/src/native/workspace-editing.mjs +447 -0
  143. package/src/native/workspace-image.mjs +72 -0
  144. package/src/native/workspace-instructions.mjs +107 -0
  145. package/src/native/workspace-mutation-lock.mjs +74 -0
  146. package/src/native/workspace-patch-worker.mjs +17 -0
  147. package/src/native/workspace-patch.mjs +17 -0
  148. package/src/native/workspace-paths.mjs +225 -0
  149. package/src/native/workspace-state.mjs +272 -0
  150. package/src/native/workspace-subject-observer.mjs +84 -0
  151. package/src/native/workspace-tools.mjs +1740 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Read-state reconciliation, edit matching and bounded diff evidence for the
3
+ * Native Node workspace tools. Filesystem authorization and tool entry points
4
+ * live in workspace-tools.mjs; this module holds the pure editing logic that
5
+ * mirrors ksadk.toolsets.workspace_editing.
6
+ */
7
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
8
+ import { sha256, TWO_MB } from './workspace-paths.mjs';
9
+ import { findEditMatch, normalizeLineEndings, normalizeReplacementForMatch, preserveQuoteStyle } from '@kingsoftcloud/agentkit-harness-core/edit-matchers';
10
+ /**
11
+ * Python str.splitlines() boundaries, with \r\n consumed as a single split.
12
+ * The character class is built from code points at runtime because \u2028 and
13
+ * \u2029 are line terminators that Node's regex-literal grammar rejects even
14
+ * as escapes, so a regex literal cannot express the full splitlines boundary
15
+ * set.
16
+ */
17
+ const SEPARATOR_CHARS = [0x0a, 0x0d, 0x0b, 0x0c, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029];
18
+ const CHAR_CLASS = '[' + SEPARATOR_CHARS.map((c) => String.fromCharCode(c)).join('') + ']';
19
+ const LINE_SPLIT = new RegExp('\\r\\n|' + CHAR_CLASS);
20
+ const LINE_SPLIT_KEEP = new RegExp('(' + '\\r\\n' + '|' + CHAR_CLASS + ')');
21
+ const LINE_SPLIT_END = new RegExp('(?:\\r\\n|' + CHAR_CLASS + ')$');
22
+
23
+ /** splitlines(keepends=False): drop the trailing empty that follows a final separator. */
24
+ export function splitlines(text) {
25
+ if (text === '') return [];
26
+ return splitKeepends(text).map(stripLineEnd);
27
+ }
28
+
29
+ /** splitlines(keepends=True): keep the separator with the preceding segment. */
30
+ export function splitKeepends(text) {
31
+ if (text === '') return [];
32
+ const parts = text.split(LINE_SPLIT_KEEP);
33
+ const lines = [];
34
+ for (let i = 0; i < parts.length; i += 2) {
35
+ const seg = parts[i];
36
+ const sep = parts[i + 1] ?? '';
37
+ if (i === parts.length - 1 && seg === '' && sep === '') continue;
38
+ lines.push(seg + sep);
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ function stripLineEnd(line) {
44
+ return line.replace(LINE_SPLIT_END, '');
45
+ }
46
+
47
+ /**
48
+ * Read a UTF-8 workspace file as raw text, bounded to 2MB. Returns
49
+ * {text, error}; error is a result-shaped object on failure. Mirrors
50
+ * _read_workspace_text, including the strict utf-8 rejection.
51
+ */
52
+ export function readWorkspaceText(target) {
53
+ let size;
54
+ try {
55
+ const info = statSync(target, { bigint: true });
56
+ if (!info.isFile()) return { text: null, error: { ok: false, error_message: 'workspace file not found' } };
57
+ size = Number(info.size);
58
+ } catch (error) {
59
+ if (error.code === 'ENOENT') return { text: null, error: { ok: false, error_message: 'workspace file not found' } };
60
+ return { text: null, error: { ok: false, error_message: 'workspace file not found' } };
61
+ }
62
+ if (size > TWO_MB) return { text: null, error: { ok: false, error_message: 'file is larger than 2MB' } };
63
+ let payload;
64
+ try {
65
+ const fd = openSync(target, 'r');
66
+ try {
67
+ const buf = Buffer.alloc(TWO_MB + 1);
68
+ const n = readSync(fd, buf, 0, TWO_MB + 1, null);
69
+ payload = buf.subarray(0, n);
70
+ } finally {
71
+ closeSync(fd);
72
+ }
73
+ } catch {
74
+ return { text: null, error: { ok: false, error_message: 'workspace file read failed' } };
75
+ }
76
+ if (payload.length > TWO_MB) return { text: null, error: { ok: false, error_message: 'file is larger than 2MB' } };
77
+ let text;
78
+ try {
79
+ // Python's payload.decode('utf-8') is strict and keeps a BOM; reproduce
80
+ // that with a fatal TextDecoder that does not strip the BOM.
81
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(payload);
82
+ } catch {
83
+ return { text: null, error: { ok: false, error_message: 'file is not utf-8 text' } };
84
+ }
85
+ return { text, error: null };
86
+ }
87
+
88
+ /**
89
+ * Validate that an observed read state still matches the file on disk. Returns
90
+ * null when valid, or a result-shaped error. Mirrors _validate_read_state:
91
+ * a missing/partial-omitted state is file_not_read; a changed mtime, size or
92
+ * content digest is file_modified_since_read.
93
+ */
94
+ export function validateReadState(target, relative, { state, content = null }) {
95
+ if (!state || state.canonicalPath !== target || !state.contentSha256) {
96
+ return {
97
+ ok: false,
98
+ path: relative,
99
+ error_type: 'file_not_read',
100
+ error_message: 'file must be read before editing',
101
+ suggested_action: `Call read_workspace_file(path=${quote(relative)}) before editing.`,
102
+ };
103
+ }
104
+ let stat = null;
105
+ try {
106
+ stat = statSync(target, { bigint: true });
107
+ } catch { /* missing below */ }
108
+ if (
109
+ stat === null
110
+ || stat.mtimeNs !== state.mtimeNs
111
+ || Number(stat.size) !== state.sizeBytes
112
+ || (content !== null && sha256(Buffer.from(content, 'utf-8')) !== state.contentSha256)
113
+ ) {
114
+ return {
115
+ ok: false,
116
+ path: relative,
117
+ error_type: 'file_modified_since_read',
118
+ error_message: 'workspace file changed since last read',
119
+ suggested_action: `Re-read the file with read_workspace_file(path=${quote(relative)}) and retry.`,
120
+ };
121
+ }
122
+ return null;
123
+ }
124
+
125
+ export function validateSingleEditRequest(oldText, relative) {
126
+ if (!String(oldText ?? '')) {
127
+ return {
128
+ ok: false,
129
+ path: relative,
130
+ error_type: 'missing_old_text',
131
+ error_message: 'old_text is required',
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+
137
+ const QUOTE_MAP = { '“': '"', '”': '"', '„': '"', '‘': "'", '’': "'", '‚': "'" };
138
+ const SMART_TO_ASCII = { '"': '“', "'": '‘' };
139
+
140
+ export function normalizeQuotes(value) {
141
+ return value.replace(/[“”„‘’‚]/g, (ch) => QUOTE_MAP[ch]);
142
+ }
143
+
144
+ /**
145
+ * Find the actual occurrence of old in text, tolerating smart/straight quote
146
+ * substitution. Returns {actual, usedQuoteNormalization}. Mirrors
147
+ * _find_actual_string: exact match first, then normalized, then sliced.
148
+ */
149
+ export function findActualString(text, old) {
150
+ if (text.includes(old)) return { actual: old, usedQuoteNormalization: false };
151
+ const normalizedOld = normalizeQuotes(old);
152
+ const candidates = new Set([normalizedOld, old.replace(/["']/g, (ch) => SMART_TO_ASCII[ch] ?? ch)]);
153
+ for (const candidate of candidates) {
154
+ if (candidate && text.includes(candidate)) return { actual: candidate, usedQuoteNormalization: true };
155
+ }
156
+ const normalizedText = normalizeQuotes(text);
157
+ const index = normalizedText.indexOf(normalizedOld);
158
+ if (index < 0) return { actual: null, usedQuoteNormalization: false };
159
+ return { actual: text.slice(index, index + old.length), usedQuoteNormalization: true };
160
+ }
161
+
162
+ export function matchPreviews(text, needle, limit = 20) {
163
+ if (!needle) return [];
164
+ const matches = [];
165
+ const lines = splitlines(text);
166
+ for (let i = 0; i < lines.length; i++) {
167
+ if (lines[i].includes(needle)) {
168
+ matches.push({ line: i + 1, text: lines[i].trim(), snippet: lines[i] });
169
+ if (matches.length >= limit) break;
170
+ }
171
+ }
172
+ return matches;
173
+ }
174
+
175
+ export function nearbyCandidates(text, old, limit = 5) {
176
+ const oldNorm = normalizeQuotes(old).trim();
177
+ const candidates = [];
178
+ const lines = splitlines(text);
179
+ for (let i = 0; i < lines.length; i++) {
180
+ const line = lines[i];
181
+ const lineNorm = normalizeQuotes(line).trim();
182
+ const ratio = similarityRatio(oldNorm, lineNorm);
183
+ const prefix = oldNorm.slice(0, Math.max(1, Math.min(4, oldNorm.length)));
184
+ if (oldNorm && (lineNorm.includes(prefix) || ratio > 0.45)) {
185
+ candidates.push({ line: i + 1, text: line.trim(), snippet: line, score: Math.round(ratio * 1000) / 1000 });
186
+ }
187
+ }
188
+ candidates.sort((a, b) => (b.score - a.score) || (a.line - b.line));
189
+ return candidates.slice(0, limit);
190
+ }
191
+
192
+ /** difflib.SequenceMatcher.ratio() approximation: 2*M/(len(a)+len(b)). */
193
+ function similarityRatio(a, b) {
194
+ if (!a && !b) return 1;
195
+ if (!a || !b) return 0;
196
+ const matches = lcsLength(a, b);
197
+ return (2 * matches) / (a.length + b.length);
198
+ }
199
+
200
+ function lcsLength(a, b) {
201
+ if (a.length < b.length) [a, b] = [b, a];
202
+ const m = a.length, n = b.length;
203
+ let prev = new Array(n + 1).fill(0);
204
+ let curr = new Array(n + 1).fill(0);
205
+ for (let i = 1; i <= m; i++) {
206
+ for (let j = 1; j <= n; j++) {
207
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]);
208
+ }
209
+ [prev, curr] = [curr, prev];
210
+ curr.fill(0);
211
+ }
212
+ return prev[n];
213
+ }
214
+
215
+ /**
216
+ * Apply one exact-text edit. Matches LF/CRLF snippets against the file's
217
+ * prevailing line ending without converting unrelated bytes. Mirrors
218
+ * _apply_single_edit, including expected_replacements/replace_all semantics.
219
+ */
220
+ export function applySingleEdit(text, oldText, newText, { expectedReplacements = 1, replaceAll = false, relative }) {
221
+ const requestError = validateSingleEditRequest(oldText, relative);
222
+ if (requestError) return requestError;
223
+ const ending = text.includes('\r\n') ? '\r\n' : '\n';
224
+ const old = String(oldText).replace(/\r\n/g, '\n').replace(/\n/g, ending);
225
+ const replacement = String(newText ?? '').replace(/\r\n/g, '\n').replace(/\n/g, ending);
226
+ const expected = Math.max(1, Number(expectedReplacements || 1));
227
+ const { actual, usedQuoteNormalization } = findActualString(text, old);
228
+ const matches = actual ? matchPreviews(text, actual) : [];
229
+ const occurrences = actual ? countOccurrences(text, actual) : 0;
230
+ if (occurrences === 0) {
231
+ return {
232
+ ok: false,
233
+ path: relative,
234
+ error_type: 'snippet_not_found',
235
+ error_message: 'old_text was not found in the workspace file',
236
+ occurrences: 0,
237
+ nearby_candidates: nearbyCandidates(text, old),
238
+ suggested_action: 'Use one nearby candidate as old_text, or call read_workspace_file with line numbers and retry.',
239
+ };
240
+ }
241
+ if (actual === null) {
242
+ return { ok: false, path: relative, error_type: 'snippet_not_found', error_message: 'old_text was not found in the workspace file' };
243
+ }
244
+ if (!replaceAll && occurrences !== expected) {
245
+ return {
246
+ ok: false,
247
+ path: relative,
248
+ error_type: 'ambiguous_edit',
249
+ error_message: `old_text matched ${occurrences} occurrences, expected ${expected}`,
250
+ occurrences,
251
+ expected_replacements: expected,
252
+ matches,
253
+ suggested_action: 'Provide a larger unique old_text snippet, set expected_replacements, or use replace_all=true.',
254
+ };
255
+ }
256
+ const replacements = replaceAll ? occurrences : expected;
257
+ return {
258
+ ok: true,
259
+ updated: replaceFirst(text, actual, replacement, replacements),
260
+ replacements,
261
+ used_quote_normalization: usedQuoteNormalization,
262
+ matches,
263
+ };
264
+ }
265
+
266
+ function countOccurrences(text, needle) {
267
+ if (!needle) return 0;
268
+ let count = 0;
269
+ let from = 0;
270
+ while ((from = text.indexOf(needle, from)) !== -1) { count++; from += needle.length; }
271
+ return count;
272
+ }
273
+
274
+ /** Pure reference matching; called in the bounded worker, never with file authority. */
275
+ export function applyZcodeEdit(text, oldText, newText, { expectedReplacements = 1, replaceAll = false, relative }) {
276
+ const invalid = validateSingleEditRequest(oldText, relative);
277
+ if (invalid) return invalid;
278
+ if (oldText === newText) return { ok: false, path: relative, error_type: 'no_change', error_message: 'old_text and new_text are identical' };
279
+ const content = normalizeLineEndings(text), search = normalizeLineEndings(oldText);
280
+ const match = findEditMatch({ content, search, replaceAll });
281
+ if (match.status === 'not_found') return { ok: false, path: relative, error_type: 'snippet_not_found',
282
+ error_message: 'old_text was not found in the workspace file', occurrences: 0,
283
+ nearby_candidates: nearbyCandidates(content, search), suggested_action: 'Read the current file and provide a unique old_text snippet.' };
284
+ if (match.status === 'ambiguous') return { ok: false, path: relative, error_type: 'ambiguous_edit',
285
+ error_message: 'Normalized old_text matches different file fragments. Provide a larger unique snippet.',
286
+ occurrences: match.candidateCount, match_strategy: match.strategy, suggested_action: 'Read the current file and use a unique exact snippet.' };
287
+ const occurrences = countOccurrences(content, match.actualString), expected = Math.max(1, Number(expectedReplacements || 1));
288
+ if (!replaceAll && occurrences !== expected) return { ok: false, path: relative, error_type: 'ambiguous_edit',
289
+ error_message: `old_text matched ${occurrences} occurrences, expected ${expected}`, occurrences,
290
+ expected_replacements: expected, matches: matchPreviews(content, match.actualString), match_strategy: match.strategy,
291
+ suggested_action: 'Provide a larger unique snippet or use replace_all=true.' };
292
+ const replacement = preserveQuoteStyle(search, match.actualString,
293
+ normalizeReplacementForMatch(match.strategy, normalizeLineEndings(newText)));
294
+ const ending = text.includes('\r\n') ? '\r\n' : '\n';
295
+ // Match normalized LF across the original bytes, preserving all untouched
296
+ // line endings. A callback keeps replacement $ sequences literal.
297
+ const actualSearch = replacement === '' && !match.actualString.endsWith('\n') && content.includes(`${match.actualString}\n`)
298
+ ? `${match.actualString}\n` : match.actualString;
299
+ const pattern = actualSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').split('\n').join('\\r?\\n');
300
+ let remaining = replaceAll ? occurrences : expected, replacements = 0;
301
+ const updated = text.replace(new RegExp(pattern, 'g'), actual => {
302
+ if (remaining-- <= 0) return actual;
303
+ replacements++; return replacement.replaceAll('\n', ending);
304
+ });
305
+ return { ok: true, updated, replacements,
306
+ used_quote_normalization: match.strategy === 'quote_normalized', match_strategy: match.strategy,
307
+ match_candidate_count: match.candidateCount, matches: matchPreviews(content, match.actualString) };
308
+ }
309
+
310
+ function replaceFirst(text, needle, replacement, count) {
311
+ let result = '';
312
+ let from = 0;
313
+ let replaced = 0;
314
+ while (replaced < count) {
315
+ const idx = text.indexOf(needle, from);
316
+ if (idx === -1) break;
317
+ result += text.slice(from, idx) + replacement;
318
+ from = idx + needle.length;
319
+ replaced++;
320
+ }
321
+ result += text.slice(from);
322
+ return result;
323
+ }
324
+
325
+ /** Locate the changed middle: shared prefix and suffix line runs. */
326
+ export function changedMiddle(before, after) {
327
+ const oldLines = splitKeepends(before);
328
+ const newLines = splitKeepends(after);
329
+ let first = 0;
330
+ while (first < Math.min(oldLines.length, newLines.length) && oldLines[first] === newLines[first]) first++;
331
+ let endOld = oldLines.length, endNew = newLines.length;
332
+ while (endOld > first && endNew > first && oldLines[endOld - 1] === newLines[endNew - 1]) { endOld--; endNew--; }
333
+ return { oldLines, newLines, first, endOld, endNew };
334
+ }
335
+
336
+ /** LCS-based opcodes over two line arrays: equal/replace/insert/delete. */
337
+ export function lineDiffOpcodes(a, b) {
338
+ const n = a.length, m = b.length;
339
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
340
+ for (let i = n - 1; i >= 0; i--) {
341
+ for (let j = m - 1; j >= 0; j--) {
342
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
343
+ }
344
+ }
345
+ const ops = [];
346
+ let i = 0, j = 0;
347
+ while (i < n && j < m) {
348
+ if (a[i] === b[j]) {
349
+ const start = i;
350
+ while (i < n && j < m && a[i] === b[j]) { i++; j++; }
351
+ ops.push({ tag: 'equal', aStart: start, aEnd: i, bStart: start, bEnd: j });
352
+ } else {
353
+ const aS = i, bS = j;
354
+ while (i < n && j < m && a[i] !== b[j]) {
355
+ if (dp[i + 1][j] >= dp[i][j + 1]) i++; else j++;
356
+ }
357
+ if (i > aS && j > bS) ops.push({ tag: 'replace', aStart: aS, aEnd: i, bStart: bS, bEnd: j });
358
+ else if (i > aS) ops.push({ tag: 'delete', aStart: aS, aEnd: i, bStart: bS, bEnd: bS });
359
+ else ops.push({ tag: 'insert', aStart: aS, aEnd: aS, bStart: bS, bEnd: j });
360
+ }
361
+ }
362
+ if (i < n) ops.push({ tag: 'delete', aStart: i, aEnd: n, bStart: m, bEnd: m });
363
+ if (j < m) ops.push({ tag: 'insert', aStart: n, aEnd: n, bStart: j, bEnd: m });
364
+ return ops;
365
+ }
366
+
367
+ export function changeCounts(before, after) {
368
+ const { oldLines, newLines, first, endOld, endNew } = changedMiddle(before, after);
369
+ if ((endOld - first) * (endNew - first) > 1_000_000) {
370
+ return { diff_additions: endNew - first, diff_deletions: endOld - first };
371
+ }
372
+ const ops = lineDiffOpcodes(oldLines.slice(first, endOld), newLines.slice(first, endNew));
373
+ let additions = 0, deletions = 0;
374
+ for (const op of ops) {
375
+ if (op.tag === 'equal') continue;
376
+ additions += op.bEnd - op.bStart;
377
+ deletions += op.aEnd - op.aStart;
378
+ }
379
+ return { diff_additions: additions, diff_deletions: deletions };
380
+ }
381
+
382
+ /** Unified-diff hunk header range, matching difflib._format_range_unified. */
383
+ function formatRange(start, length) {
384
+ const beginning = start + 1;
385
+ if (length === 1) return `${beginning}`;
386
+ if (length === 0) return `${beginning - 1},0`;
387
+ return `${beginning},${length}`;
388
+ }
389
+
390
+ export function buildDiff(relative, before, after) {
391
+ const { oldLines, newLines, first, endOld, endNew } = changedMiddle(before, after);
392
+ if (first === endOld && first === endNew) return '';
393
+ if ((endOld - first) * (endNew - first) > 1_000_000) {
394
+ return bulkMiddleHunk(relative, oldLines, newLines, first, endOld, endNew);
395
+ }
396
+ return singleHunk(relative, oldLines, newLines, first, endOld, endNew, 3);
397
+ }
398
+
399
+ /** Fast path for a huge changed middle: emit the whole span as one hunk. */
400
+ function bulkMiddleHunk(relative, oldLines, newLines, first, endOld, endNew) {
401
+ let diff = `--- ${relative} before\n+++ ${relative} after\n`;
402
+ diff += `@@ -${formatRange(first, endOld - first)} +${formatRange(first, endNew - first)} @@\n`;
403
+ for (let k = first; k < endOld; k++) diff += '-' + oldLines[k];
404
+ for (let k = first; k < endNew; k++) diff += '+' + newLines[k];
405
+ return diff;
406
+ }
407
+
408
+ /** One unified-diff hunk over the changed middle with optional context lines. */
409
+ function singleHunk(relative, oldLines, newLines, first, endOld, endNew, context) {
410
+ const ops = lineDiffOpcodes(oldLines.slice(first, endOld), newLines.slice(first, endNew));
411
+ const beforeStart = Math.max(0, first - context);
412
+ const afterEnd = Math.min(oldLines.length, endOld + context);
413
+ const body = [];
414
+ for (let k = beforeStart; k < first; k++) body.push(' ' + oldLines[k]);
415
+ for (const op of ops) {
416
+ if (op.tag === 'equal') {
417
+ for (let k = op.aStart; k < op.aEnd; k++) body.push(' ' + oldLines[first + k]);
418
+ } else {
419
+ if (op.tag === 'replace' || op.tag === 'delete') {
420
+ for (let k = op.aStart; k < op.aEnd; k++) body.push('-' + oldLines[first + k]);
421
+ }
422
+ if (op.tag === 'replace' || op.tag === 'insert') {
423
+ for (let k = op.bStart; k < op.bEnd; k++) body.push('+' + newLines[first + k]);
424
+ }
425
+ }
426
+ }
427
+ for (let k = endOld; k < afterEnd; k++) body.push(' ' + oldLines[k]);
428
+ const aStart = beforeStart;
429
+ const aLen = afterEnd - beforeStart;
430
+ const bStart = beforeStart; // the context prefix is equal in both files
431
+ const bAfterEnd = Math.min(newLines.length, endNew + context);
432
+ const bLen = bAfterEnd - beforeStart;
433
+ let diff = `--- ${relative} before\n+++ ${relative} after\n`;
434
+ diff += `@@ -${formatRange(aStart, aLen)} +${formatRange(bStart, bLen)} @@\n`;
435
+ diff += body.join('');
436
+ return diff;
437
+ }
438
+
439
+ export function diffLineCount(diff) {
440
+ let count = 0;
441
+ for (let i = 0; i < diff.length; i++) if (diff[i] === '\n') count++;
442
+ return count;
443
+ }
444
+
445
+ function quote(value) {
446
+ return `'${value}'`;
447
+ }
@@ -0,0 +1,72 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync, statSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
5
+ const MAX_PIXELS = 40_000_000;
6
+ const fail = code => { throw Object.assign(Error(code), { code }); };
7
+ function imageInfo(data) {
8
+ if (data.length >= 24 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
9
+ return { mime_type: 'image/png', width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
10
+ if (data.length >= 10 && ['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString()))
11
+ return { mime_type: 'image/gif', width: data.readUInt16LE(6), height: data.readUInt16LE(8) };
12
+ if (data.length >= 30 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') {
13
+ const kind = data.toString('ascii', 12, 16);
14
+ if (kind === 'VP8X') return { mime_type: 'image/webp', width: 1 + data.readUIntLE(24, 3), height: 1 + data.readUIntLE(27, 3) };
15
+ if (kind === 'VP8 ' && data.subarray(23, 26).equals(Buffer.from([157, 1, 42])))
16
+ return { mime_type: 'image/webp', width: data.readUInt16LE(26) & 0x3fff, height: data.readUInt16LE(28) & 0x3fff };
17
+ if (kind === 'VP8L' && data[20] === 0x2f) return { mime_type: 'image/webp',
18
+ width: 1 + ((data[21] | data[22] << 8) & 0x3fff), height: 1 + ((data[22] >> 6 | data[23] << 2 | data[24] << 10) & 0x3fff) };
19
+ }
20
+ if (data[0] === 255 && data[1] === 216) {
21
+ for (let i = 2; i + 4 <= data.length;) {
22
+ if (data[i++] !== 255) return null;
23
+ while (data[i] === 255) i++;
24
+ const marker = data[i++];
25
+ if (marker === 0xda || marker === 0xd9) break;
26
+ if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue;
27
+ if (i + 2 > data.length) break;
28
+ const length = data.readUInt16BE(i);
29
+ if (length < 2 || i + length > data.length) break;
30
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker) && length >= 7)
31
+ return { mime_type: 'image/jpeg', height: data.readUInt16BE(i + 3), width: data.readUInt16BE(i + 5) };
32
+ i += length;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Read a bounded, stable image observation through the existing workspace authorization. No URL fetching or execution. */
39
+ export function readBoundedWorkspaceImage(target, path, checkCurrent) {
40
+ const fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
41
+ try {
42
+ checkCurrent();
43
+ const before = fstatSync(fd, { bigint: true });
44
+ if (!before.isFile()) fail('workspace_image_not_regular');
45
+ const header = Buffer.alloc(32), headerSize = readSync(fd, header, 0, 32, 0), bytes = header.subarray(0, headerSize);
46
+ const candidate = /\.(png|jpe?g|gif|webp)$/i.test(path)
47
+ || bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
48
+ || bytes[0] === 255 && bytes[1] === 216
49
+ || ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString())
50
+ || bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
51
+ if (!candidate) return null;
52
+ if (before.size > BigInt(MAX_IMAGE_BYTES)) fail('workspace_image_too_large');
53
+ const buffer = Buffer.alloc(Number(before.size) + 1);
54
+ let length = 0;
55
+ while (length < buffer.length) {
56
+ const count = readSync(fd, buffer, length, buffer.length - length, length);
57
+ if (!count) break;
58
+ length += count;
59
+ }
60
+ checkCurrent();
61
+ const after = fstatSync(fd, { bigint: true }), current = statSync(target, { bigint: true });
62
+ if (before.dev !== current.dev || before.ino !== current.ino || before.size !== after.size
63
+ || before.mtimeNs !== after.mtimeNs || BigInt(length) !== before.size) fail('workspace_image_changed');
64
+ const data = buffer.subarray(0, length), info = imageInfo(data);
65
+ if (!info || info.width < 1 || info.height < 1) fail('workspace_image_invalid');
66
+ if (info.width * info.height > MAX_PIXELS) fail('workspace_image_dimensions_exceeded');
67
+ const content = { ok: true, path, observation_kind: 'image', ...info, size_bytes: length,
68
+ sha256: createHash('sha256').update(data).digest('hex') };
69
+ return { content, context: { content: JSON.stringify(content), media: [{ type: 'image_url',
70
+ image_url: { url: `data:${info.mime_type};base64,${data.toString('base64')}` } }] } };
71
+ } finally { closeSync(fd); }
72
+ }
@@ -0,0 +1,107 @@
1
+ import { constants, realpathSync } from 'node:fs';
2
+ import { open, realpath, stat } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { canonical, invariant, INSTRUCTION_SCOPES, validateInstructionScopes } from '@kingsoftcloud/agentkit-harness-core';
6
+ import { validateNativeWorkspaceContext } from './workspace-context.mjs';
7
+ import { isInside, realpathExisting } from './workspace-paths.mjs';
8
+
9
+ const CONTRACT = 'agentkit.native-instruction-files/v1';
10
+ const CANDIDATES = ['AGENTS.md', 'CLAUDE.md', 'AGENTS.local.md', 'CLAUDE.local.md'];
11
+ const MAX_FILE_BYTES = 65536, MAX_TOTAL_BYTES = 98304;
12
+ const hash = value => createHash('sha256').update(value).digest('hex');
13
+ const missing = error => ['ENOENT', 'ENOTDIR'].includes(error.code);
14
+ const sameFile = (a, b) => ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every(key => a[key] === b[key]);
15
+
16
+ /** Stable bounded reads, using the physical file identity both before and after
17
+ * open. A concurrent replacement causes a retryable source failure, not stale
18
+ * guidance or a partially read instruction file. */
19
+ async function readFile(root, path, signal) {
20
+ signal.throwIfAborted();
21
+ let physical, before, handle;
22
+ try {
23
+ physical = await realpath(path);
24
+ invariant(isInside(physical, root), 'workspace_instruction_path_escape');
25
+ before = await stat(physical, { bigint: true });
26
+ if (!before.isFile()) return null;
27
+ invariant(before.size <= BigInt(MAX_FILE_BYTES), 'workspace_instruction_file_too_large');
28
+ handle = await open(physical, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
29
+ invariant(sameFile(before, await handle.stat({ bigint: true })) && await realpath(path) === physical,
30
+ 'workspace_instruction_file_changed');
31
+ const parts = []; let bytes = 0;
32
+ for await (const part of handle.createReadStream({ autoClose: false, signal, end: MAX_FILE_BYTES })) {
33
+ bytes += part.length; invariant(bytes <= MAX_FILE_BYTES, 'workspace_instruction_file_too_large'); parts.push(part);
34
+ }
35
+ signal.throwIfAborted();
36
+ invariant(sameFile(before, await handle.stat({ bigint: true })) && await realpath(path) === physical,
37
+ 'workspace_instruction_file_changed');
38
+ return Buffer.concat(parts).toString('utf8');
39
+ } catch (error) {
40
+ // Absence is current state. Once opened, disappearance is a changed read.
41
+ if (!handle && missing(error)) return null;
42
+ throw error;
43
+ } finally { await handle?.close(); }
44
+ }
45
+
46
+ /** Node-owned live source. Only discovery directories are durable; file bodies
47
+ * and deletion state never depend on a summary or a process-local cache. */
48
+ export function createNativeWorkspaceInstructions(binding) {
49
+ validateNativeWorkspaceContext(binding);
50
+ invariant(binding.instructionSource?.contract === CONTRACT, 'workspace_instruction_source_unbound');
51
+ const root = binding.workspaceRoot, roots = structuredClone(binding.instructionSource.globalRoots);
52
+ const source = Object.freeze({ contract: 'agentkit.native-context-source/v1', sourceId: 'native.workspace-instructions',
53
+ bindingDigest: hash(canonical({ contract: CONTRACT, root, roots, candidates: CANDIDATES, MAX_FILE_BYTES, MAX_TOTAL_BYTES })),
54
+ instructionPolicy: 'current-files/v1', priority: 1000, required: true, timeoutMs: 5000, maxTokens: 32768, maxBytes: 131072 });
55
+ const initialScopes = () => ({ contract: INSTRUCTION_SCOPES, sourceId: source.sourceId,
56
+ bindingDigest: source.bindingDigest, directories: [''] });
57
+ const scopesOf = state => state === undefined ? initialScopes() : validateInstructionScopes(state, { contextSources: [source] });
58
+ return {
59
+ source,
60
+ workspaceRoot: root,
61
+ async scopes(previous, touches, signal) {
62
+ const result = scopesOf(previous), directories = new Set(result.directories);
63
+ directories.add('');
64
+ invariant(realpathSync(root) === root, 'workspace_instruction_root_changed');
65
+ for (const touch of touches) {
66
+ signal.throwIfAborted();
67
+ const target = realpathExisting(resolve(root, touch));
68
+ invariant(isInside(target, root), 'workspace_instruction_path_escape');
69
+ let directory = dirname(target);
70
+ try { if ((await stat(target)).isDirectory()) directory = target; }
71
+ catch (error) { if (!missing(error)) throw error; }
72
+ while (isInside(directory, root)) {
73
+ directories.add(relative(root, directory).split(sep).join('/'));
74
+ if (directory === root) break;
75
+ directory = dirname(directory);
76
+ }
77
+ }
78
+ result.directories = [...directories].sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
79
+ return validateInstructionScopes(result, { contextSources: [source] });
80
+ },
81
+ async readSource({ source: requested, snapshot, signal }) {
82
+ invariant(canonical(requested) === canonical(source), 'workspace_instruction_source_changed');
83
+ invariant(await realpath(root) === root, 'workspace_instruction_root_changed');
84
+ const scopes = scopesOf(snapshot.context.instructionScopes), files = [], dedup = new Set(); let bytes = 0;
85
+ async function add(directory, path, display, scope, group) {
86
+ const content = await readFile(directory, path, signal);
87
+ if (content === null || !content.trim()) return;
88
+ const key = group + ':' + hash(content.trim());
89
+ if (dedup.has(key)) return;
90
+ dedup.add(key); bytes += Buffer.byteLength(content);
91
+ invariant(bytes <= MAX_TOTAL_BYTES && files.length < 256, 'workspace_instruction_source_too_large');
92
+ files.push({ path: display, scope, sha256: hash(content), content });
93
+ }
94
+ for (const [index, globalRoot] of roots.entries()) {
95
+ // A newly introduced symlink cannot widen a frozen global root.
96
+ invariant(realpathExisting(globalRoot) === globalRoot, 'workspace_instruction_root_changed');
97
+ await add(globalRoot, join(globalRoot, 'AGENTS.md'), `global:${index}/AGENTS.md`, 'user-global', 'user-global');
98
+ }
99
+ for (const directory of scopes.directories) for (const name of CANDIDATES) {
100
+ const path = directory ? `${directory}/${name}` : name;
101
+ await add(root, join(root, path), path, directory || '.', directory);
102
+ }
103
+ // Empty files is significant: it retires all previously active versions.
104
+ return { items: [{ contract: CONTRACT, workspaceRoot: root, directories: scopes.directories, files }] };
105
+ },
106
+ };
107
+ }