dsh-ptc-plus 0.2.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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/README.zh.md +137 -0
  4. package/assets/dsh-ptc-plus-banner.webp +0 -0
  5. package/assets/ptc-plus-repair.png +0 -0
  6. package/assets/ptc-plus-settings.png +0 -0
  7. package/client.js +366 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/README.md +21 -0
  10. package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
  11. package/docs/adr/0002-limit-work-map-scope.md +3 -0
  12. package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
  13. package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
  14. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
  15. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
  16. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
  17. package/docs/adr/0008-define-agent-operating-contract.md +25 -0
  18. package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
  19. package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
  20. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
  21. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
  22. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
  23. package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
  24. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
  25. package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
  26. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
  27. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
  28. package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
  29. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
  30. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
  31. package/docs/adr/README.md +18 -0
  32. package/docs/architecture.md +134 -0
  33. package/docs/capability-projection.md +48 -0
  34. package/docs/client-ui.md +47 -0
  35. package/docs/durability-design.md +265 -0
  36. package/docs/evaluation.md +62 -0
  37. package/docs/installation.md +82 -0
  38. package/docs/program-data-plane.md +61 -0
  39. package/docs/publishing.md +37 -0
  40. package/docs/runtime-reference.md +119 -0
  41. package/docs/value-wire.md +61 -0
  42. package/index.js +532 -0
  43. package/internal/ast-traversal.js +20 -0
  44. package/internal/binding-descriptors.js +95 -0
  45. package/internal/binding-pattern.js +54 -0
  46. package/internal/builtin-esm-sync.js +9 -0
  47. package/internal/cell-analysis.js +467 -0
  48. package/internal/cell-rewriter.js +520 -0
  49. package/internal/config-spec.js +179 -0
  50. package/internal/cordis-tools-owner.js +363 -0
  51. package/internal/diagnostic.js +133 -0
  52. package/internal/direct-surface-owner.js +326 -0
  53. package/internal/edit-transport-owner.js +287 -0
  54. package/internal/failure-reporting.js +139 -0
  55. package/internal/kernel-worker.js +484 -0
  56. package/internal/module-policy.js +46 -0
  57. package/internal/program-bindings.js +187 -0
  58. package/internal/record-utils.js +53 -0
  59. package/internal/recovery-tips.js +81 -0
  60. package/internal/rejected-cell-editor.js +328 -0
  61. package/internal/repl-convenience.js +201 -0
  62. package/internal/runtime-bridge-owner.js +417 -0
  63. package/internal/runtime-config.js +77 -0
  64. package/internal/runtime-contexts.js +31 -0
  65. package/internal/sdk-projection.js +88 -0
  66. package/internal/session-cell-executor.js +612 -0
  67. package/internal/session-journal.js +889 -0
  68. package/internal/session-log-view.js +80 -0
  69. package/internal/session-runtime.js +536 -0
  70. package/internal/session-state.js +45 -0
  71. package/internal/source-position-map.js +224 -0
  72. package/internal/tool-call-canonicalizer.js +339 -0
  73. package/internal/value-wire.js +421 -0
  74. package/internal/worker-client.js +218 -0
  75. package/internal/worker-cwd-virtualization.js +155 -0
  76. package/package.json +100 -0
  77. package/src/client.js +165 -0
@@ -0,0 +1,187 @@
1
+ /** Deterministic capability metadata derived from the current DSH tool view. */
2
+ import { record, text } from './record-utils.js'
3
+
4
+ const COMPLETENESS = new Set(['complete', 'bounded', 'incremental', 'open-world', 'unknown'])
5
+ const SOURCE_KINDS = new Set(['authored', 'runtime', 'tests', 'docs'])
6
+ const REPLAY = new Set(['recorded-value', 'owner-replay', 'volatile', 'unknown'])
7
+
8
+
9
+ function metadataSnapshot(value, label) {
10
+ let clone
11
+ try {
12
+ clone = structuredClone(value)
13
+ } catch (error) {
14
+ throw new TypeError(`ptc-plus: ${label} must be structured data: ${error.message}`)
15
+ }
16
+ if (clone === null || typeof clone !== 'object') return clone
17
+ const pending = [clone]
18
+ const seen = new Set()
19
+ while (pending.length > 0) {
20
+ const current = pending.pop()
21
+ if (seen.has(current)) continue
22
+ seen.add(current)
23
+ for (const child of Object.values(current)) {
24
+ if (child !== null && typeof child === 'object') pending.push(child)
25
+ }
26
+ Object.freeze(current)
27
+ }
28
+ return clone
29
+ }
30
+
31
+ function sourceRef(value, label) {
32
+ if (value === undefined) return undefined
33
+ const source = record(value, label)
34
+ const kind = text(source.kind, `${label}.kind`)
35
+ if (!SOURCE_KINDS.has(kind)) throw new TypeError(`ptc-plus: ${label}.kind is invalid`)
36
+ const path = text(source.path, `${label}.path`)
37
+ const result = { kind, path }
38
+ if (source.symbol !== undefined) result.symbol = text(source.symbol, `${label}.symbol`)
39
+ if (source.revision !== undefined) result.revision = text(source.revision, `${label}.revision`)
40
+ return Object.freeze(result)
41
+ }
42
+
43
+ /** Convert DSH's live tool schemas into descriptive metadata without wrapping invocation. */
44
+ export function toolCapabilityMetadata(schemas, annotations = {}) {
45
+ if (!Array.isArray(schemas)) throw new TypeError('ptc-plus: tool schemas must be an array')
46
+ const overrides = record(annotations, 'tool annotations')
47
+ const members = schemas
48
+ .filter(schema => schema?.name !== 'run_code')
49
+ .map((schema) => {
50
+ const value = record(schema, 'tool schema')
51
+ const name = text(value.name, 'tool schema name')
52
+ const annotation = overrides[name] === undefined ? {} : record(overrides[name], `${name} annotation`)
53
+ return Object.freeze({
54
+ name,
55
+ ...(value.description === undefined ? {} : { description: text(value.description, `${name} description`) }),
56
+ parameters: metadataSnapshot(value.parameters ?? {}, `${name} parameters`),
57
+ returns: metadataSnapshot(value.output ?? {}, `${name} output`),
58
+ effect: annotation.effect ?? 'unknown',
59
+ authority: annotation.authority ?? 'dsh-tool-dispatch',
60
+ completeness: annotation.completeness ?? 'unknown',
61
+ replay: annotation.replay ?? 'unknown',
62
+ ...(annotation.sourceRef === undefined ? {} : { sourceRef: sourceRef(annotation.sourceRef, `${name} sourceRef`) }),
63
+ ...(annotation.revision === undefined ? {} : { revision: text(annotation.revision, `${name} revision`) }),
64
+ ...(annotation.fingerprint === undefined ? {} : { fingerprint: text(annotation.fingerprint, `${name} fingerprint`) }),
65
+ })
66
+ })
67
+ .sort((left, right) => left.name.localeCompare(right.name))
68
+ for (const member of members) {
69
+ if (!COMPLETENESS.has(member.completeness)) {
70
+ throw new TypeError(`ptc-plus: ${member.name}.completeness is invalid`)
71
+ }
72
+ if (!REPLAY.has(member.replay)) {
73
+ throw new TypeError(`ptc-plus: ${member.name}.replay is invalid`)
74
+ }
75
+ }
76
+ return Object.freeze([{ namespace: 'tools', members: Object.freeze(members) }])
77
+ }
78
+
79
+ function metadataEntries(metadata) {
80
+ if (!Array.isArray(metadata)) throw new TypeError('ptc-plus: capability metadata must be an array')
81
+ return metadata.map((entry) => {
82
+ const value = record(entry, 'capability metadata entry')
83
+ const namespace = text(value.namespace, 'capability namespace')
84
+ const members = Array.isArray(value.members)
85
+ ? value.members.map((member) => {
86
+ const item = record(member, 'capability member')
87
+ return { ...item, name: text(item.name, `${namespace} capability member name`) }
88
+ })
89
+ : []
90
+ return { ...value, namespace, members }
91
+ })
92
+ }
93
+
94
+ function searchTokens(value) {
95
+ const separated = value
96
+ .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, '$1 $2')
97
+ .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
98
+ return separated.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []
99
+ }
100
+
101
+ function includesTokenSequence(tokens, queryTokens) {
102
+ if (queryTokens.length === 0 || queryTokens.length > tokens.length) return false
103
+ return tokens.some((_, start) => queryTokens.every(
104
+ (token, offset) => tokens[start + offset] === token,
105
+ ))
106
+ }
107
+
108
+ function capabilityMatchRank(namespace, member, query, queryTokens) {
109
+ const normalizedNamespace = namespace.toLowerCase()
110
+ const normalizedMember = member.name.toLowerCase()
111
+ const symbol = `${normalizedNamespace}.${normalizedMember}`
112
+ if (symbol === query) return 0
113
+ if (normalizedNamespace === query) return 1
114
+ if (normalizedMember === query) return 2
115
+ if (includesTokenSequence(searchTokens(`${namespace}.${member.name}`), queryTokens)) return 3
116
+ if (typeof member.description === 'string'
117
+ && includesTokenSequence(searchTokens(member.description), queryTokens)) return 4
118
+ return undefined
119
+ }
120
+
121
+ /** Deterministic hierarchy only. Details remain behind inspect. */
122
+ export function capabilityTree(metadata) {
123
+ return metadataEntries(metadata).map((value) => {
124
+ return {
125
+ namespace: value.namespace,
126
+ members: value.members.map(member => member.name),
127
+ ...(value.revision === undefined ? {} : { revision: value.revision }),
128
+ ...(value.fingerprint === undefined ? {} : { fingerprint: value.fingerprint }),
129
+ }
130
+ })
131
+ }
132
+
133
+ /** Find lightweight candidates without returning full schemas or source. */
134
+ export function capabilityFind(metadata, query) {
135
+ const trimmedQuery = text(query, 'capability query').trim()
136
+ const normalizedQuery = trimmedQuery.toLowerCase()
137
+ const queryTokens = searchTokens(trimmedQuery)
138
+ const candidates = []
139
+ let order = 0
140
+ for (const entry of metadataEntries(metadata)) {
141
+ for (const member of entry.members) {
142
+ const rank = capabilityMatchRank(entry.namespace, member, normalizedQuery, queryTokens)
143
+ if (rank !== undefined) {
144
+ candidates.push({
145
+ rank,
146
+ order,
147
+ value: {
148
+ symbol: `${entry.namespace}.${member.name}`,
149
+ ...(member.description === undefined ? {} : { description: member.description }),
150
+ completeness: member.completeness ?? 'unknown',
151
+ effect: member.effect ?? 'unknown',
152
+ replay: member.replay ?? 'unknown',
153
+ },
154
+ })
155
+ }
156
+ order += 1
157
+ }
158
+ }
159
+ return candidates
160
+ .sort((left, right) => left.rank - right.rank || left.order - right.order)
161
+ .map(candidate => candidate.value)
162
+ }
163
+
164
+ /** Batch, budgeted inspection; omitted items remain explicit to the caller. */
165
+ export function capabilityInspect(metadata, symbols = undefined, budget = 50) {
166
+ if (!Number.isSafeInteger(budget) || budget < 1) throw new TypeError('ptc-plus: capability inspection budget must be a positive integer')
167
+ const entries = metadataEntries(metadata)
168
+ const requested = symbols === undefined
169
+ ? entries.flatMap(entry => entry.members.map(member => `${entry.namespace}.${member.name}`))
170
+ : symbols
171
+ if (!Array.isArray(requested)) throw new TypeError('ptc-plus: capability symbols must be an array')
172
+ const bySymbol = new Map(entries.flatMap(entry => entry.members.map(member => [
173
+ `${entry.namespace}.${member.name}`,
174
+ { namespace: entry.namespace, ...member },
175
+ ])))
176
+ const selected = requested.slice(0, budget).flatMap((symbol) => {
177
+ const key = text(symbol, 'capability symbol')
178
+ const found = bySymbol.get(key)
179
+ return found === undefined ? [] : [found]
180
+ })
181
+ return {
182
+ symbols: selected,
183
+ omitted: Math.max(0, requested.length - budget),
184
+ unknown: requested.slice(0, budget).filter(symbol => !bySymbol.has(symbol)),
185
+ budget,
186
+ }
187
+ }
@@ -0,0 +1,53 @@
1
+ /** Shared structural guards for closed PTC metadata boundaries. */
2
+
3
+ export function isRecord(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
5
+ }
6
+
7
+ export function assertOwnFields(value, allowed, label) {
8
+ for (const key of Reflect.ownKeys(value)) {
9
+ if (typeof key !== 'string' || !allowed.has(key)
10
+ || !Object.prototype.propertyIsEnumerable.call(value, key)) {
11
+ throw new TypeError(`invalid ${label} field ${String(key)}`)
12
+ }
13
+ }
14
+ }
15
+
16
+ export function assertFields(value, allowed, label) {
17
+ if (!isRecord(value)) throw new TypeError(`invalid ${label}`)
18
+ assertOwnFields(value, allowed, label)
19
+ if (Reflect.ownKeys(value).length !== allowed.size) throw new TypeError(`invalid ${label} fields`)
20
+ }
21
+
22
+ export function record(value, label) {
23
+ if (!isRecord(value)) throw new TypeError(`ptc-plus: ${label} must be an object`)
24
+ return value
25
+ }
26
+
27
+ export function text(value, label) {
28
+ if (typeof value !== 'string' || value.trim().length === 0) {
29
+ throw new TypeError(`ptc-plus: ${label} must be a non-empty string`)
30
+ }
31
+ return value
32
+ }
33
+
34
+ /** Freeze one static object graph without invoking accessors or recursing. */
35
+ export function deepFreeze(value) {
36
+ if (value === null || typeof value !== 'object') return value
37
+ const pending = [value]
38
+ const seen = new Set()
39
+ while (pending.length > 0) {
40
+ const current = pending.pop()
41
+ if (seen.has(current)) continue
42
+ seen.add(current)
43
+ for (const key of Reflect.ownKeys(current)) {
44
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)
45
+ if (descriptor !== undefined && Object.hasOwn(descriptor, 'value')) {
46
+ const child = descriptor.value
47
+ if (child !== null && typeof child === 'object') pending.push(child)
48
+ }
49
+ }
50
+ Object.freeze(current)
51
+ }
52
+ return value
53
+ }
@@ -0,0 +1,81 @@
1
+ const PLATFORM_CAUSE_CODES = new Set(['EACCES', 'EINVAL', 'ENOTDIR', 'ENOENT', 'UNKNOWN'])
2
+ const TIP_CONTEXT_PREFIX = 'tools:ptc-plus-tip/'
3
+ export const LONG_FAILED_CELL_TIP_CODE_UNITS = 2_000
4
+ const TIP_PREFIXES = Object.freeze({
5
+ 'repeated-binding-failure': 'The same binding failure has recurred.',
6
+ 'platform-command-failure': 'An executable, shell, or path failed in the current execution world.',
7
+ })
8
+
9
+ // TODO(dsh-tips-api): replace this local provider with an adapter after dsh-tips publishes a stable facts and decision interface.
10
+ function tipHistory(view) {
11
+ const active = new Map()
12
+ const seen = new Set()
13
+ const history = []
14
+ for (const snapshot of view.systemPromptSnapshots) {
15
+ const next = new Map(snapshot.sections.map(section => [section.name, section.text]))
16
+ for (const [name] of next) {
17
+ if (active.has(name) || seen.has(name)) continue
18
+ const match = /^tools:ptc-plus-tip\/(repeated-binding-failure|platform-command-failure)\/([1-9][0-9]*)$/.exec(name)
19
+ if (match === null) continue
20
+ const ordinal = Number(match[2])
21
+ if (!Number.isSafeInteger(ordinal)) continue
22
+ seen.add(name)
23
+ history.push({ id: match[1], ordinal, index: snapshot.index, contextStep: snapshot.contextStep })
24
+ }
25
+ active.clear()
26
+ for (const entry of next) active.set(...entry)
27
+ }
28
+ return history
29
+ }
30
+
31
+ function hasPlatformCommandDiagnostic(diagnostics) {
32
+ return diagnostics.some(diagnostic => {
33
+ if (diagnostic.code !== 'PTC-X001') return false
34
+ const parts = [diagnostic.message, diagnostic.cause?.code, diagnostic.cause?.message]
35
+ .filter(value => typeof value === 'string')
36
+ const text = parts.join('\n')
37
+ const causeCode = typeof diagnostic.cause?.code === 'string' ? diagnostic.cause.code.toUpperCase() : undefined
38
+ if (causeCode !== undefined && PLATFORM_CAUSE_CODES.has(causeCode)
39
+ && /\b(?:spawn|exec(?:ute)?|command|executable|shell|path|file)\b/i.test(text)) return true
40
+ return /(?:spawn|exec(?:ute)?|child process)\b.*(?:failed|error|not found|cannot find)/i.test(text)
41
+ || /command not found|no such file|not recognized as (?:an )?internal|cannot find (?:the )?(?:path|file)/i.test(text)
42
+ })
43
+ }
44
+
45
+ function tipCandidate(view) {
46
+ const { args, journal } = view.latestRun ?? {}
47
+ if (args === undefined || journal === undefined) return undefined
48
+ const codes = new Set(journal.diagnostics.map(diagnostic => diagnostic.code))
49
+ if (codes.has('PTC-W001')) return { id: 'repeated-binding-failure' }
50
+ if (hasPlatformCommandDiagnostic(journal.diagnostics)) return { id: 'platform-command-failure' }
51
+ return undefined
52
+ }
53
+
54
+ function renderTip(id, detailed, candidate = {}) {
55
+ if (id === 'platform-command-failure') {
56
+ return detailed
57
+ ? `${TIP_PREFIXES[id]} Re-check the active execution world and the actual executable before retrying. Use direct argv for a normal executable; use a shell only when its syntax or resolution is required. Windows, WSL, POSIX, and package shims have different paths and launch rules.`
58
+ : `${TIP_PREFIXES[id]} Inspect the executable or path in the current execution world and choose direct argv or a shell only when required; do not assume Windows, WSL, POSIX, or one shell.`
59
+ }
60
+ return detailed
61
+ ? `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`. Do not invent hidden bindings or repeat the same failing expression.`
62
+ : `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`.`
63
+ }
64
+
65
+ export function latestRecoveryTip(view, config) {
66
+ if (!config.enabled) return undefined
67
+ const candidate = tipCandidate(view)
68
+ if (candidate === undefined) return undefined
69
+ const history = tipHistory(view)
70
+ const lastTip = history.at(-1)
71
+ if (lastTip !== undefined && view.contextStep - lastTip.contextStep < config.cooldownMessages) return undefined
72
+ const unresolved = history.filter(item => item.id === candidate.id && item.index > (view.lastSuccessfulRunIndex ?? -1))
73
+ const ordinal = history.reduce(
74
+ (highest, item) => item.id === candidate.id ? Math.max(highest, item.ordinal) : highest,
75
+ 0,
76
+ ) + 1
77
+ return {
78
+ name: `${TIP_CONTEXT_PREFIX}${candidate.id}/${ordinal}`,
79
+ text: renderTip(candidate.id, unresolved.length >= config.escalationFailures, candidate),
80
+ }
81
+ }
@@ -0,0 +1,328 @@
1
+ import { runInNewContext } from 'node:vm'
2
+
3
+ const DEFAULT_REGEX_TIMEOUT_MS = 100
4
+ export const EDIT_LIMITS = Object.freeze({
5
+ exactEdits: 16,
6
+ exactSearchCodeUnits: 16 * 1024 * 1024,
7
+ regexEdits: 16,
8
+ regexMatches: 4_096,
9
+ regexTemplateCodeUnits: 16 * 1024,
10
+ regexExpansionSteps: 64 * 1024,
11
+ regexCaptureSlots: 64 * 1024,
12
+ generatedCodeUnits: 1024 * 1024,
13
+ })
14
+
15
+ const EDIT_RUN_CODE_EXECUTION_DESCRIPTION = 'Edit and run TypeScript cell'
16
+ const REGEX_EDIT_RUN_CODE_EXECUTION_DESCRIPTION = 'Regex-edit and run TypeScript cell'
17
+
18
+ const REGEX_MATCH_SCRIPT = `(() => {
19
+ const replacements = []
20
+ let captureSlots = 0
21
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
22
+ const rule = rules[ruleIndex]
23
+ let expression
24
+ try {
25
+ expression = new RegExp(rule.pattern, (rule.flags.includes('g') ? rule.flags : rule.flags + 'g') + 'd')
26
+ } catch (error) {
27
+ return { error: 'regex_edits[' + ruleIndex + '].pattern is invalid: ' + error.message }
28
+ }
29
+ let count = 0
30
+ for (const match of source.matchAll(expression)) {
31
+ if (match[0].length === 0) {
32
+ return { error: 'regex_edits[' + ruleIndex + '] must not produce zero-length matches' }
33
+ }
34
+ count += 1
35
+ if (count > rule.expected_matches) break
36
+ captureSlots += match.indices.length - 1
37
+ if (captureSlots > maxCaptureSlots) {
38
+ return { error: 'regex_edits exceed the ' + maxCaptureSlots + ' capture-slot budget' }
39
+ }
40
+ const named = match.indices.groups
41
+ replacements.push({
42
+ start: match.index,
43
+ end: match.index + match[0].length,
44
+ captures: Array.from(match.indices).slice(1),
45
+ groups: named === undefined
46
+ ? undefined
47
+ : Object.fromEntries(Object.entries(named)),
48
+ ruleIndex,
49
+ })
50
+ }
51
+ if (count !== rule.expected_matches) {
52
+ return {
53
+ error: 'regex_edits[' + ruleIndex + '] expected ' + rule.expected_matches
54
+ + (count > rule.expected_matches
55
+ ? ' matches but found more than ' + rule.expected_matches
56
+ : ' matches but found ' + count),
57
+ }
58
+ }
59
+ }
60
+ return { replacements }
61
+ })()`
62
+
63
+ function rejection(reason) {
64
+ return Object.freeze({ edited: false, reason })
65
+ }
66
+
67
+ function exactReplacements(edits, source) {
68
+ if (edits.length === 0) return { error: 'edits must contain at least one replacement' }
69
+ if (edits.length > EDIT_LIMITS.exactEdits) {
70
+ return { error: `edits must contain at most ${EDIT_LIMITS.exactEdits} replacements` }
71
+ }
72
+ for (const [index, edit] of edits.entries()) {
73
+ if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) {
74
+ return { error: `edits[${index}] must be an object` }
75
+ }
76
+ const keys = Object.keys(edit)
77
+ if (keys.length !== 2 || !keys.includes('old_string') || !keys.includes('new_string')
78
+ || typeof edit.old_string !== 'string' || typeof edit.new_string !== 'string') {
79
+ return { error: `edits[${index}] expects exactly old_string and new_string strings` }
80
+ }
81
+ if (edit.old_string.length === 0) return { error: `edits[${index}].old_string must be non-empty` }
82
+ if (edit.old_string === edit.new_string) {
83
+ return { error: `edits[${index}].old_string and new_string must differ` }
84
+ }
85
+ }
86
+ const searchWork = source.length * edits.length * 2
87
+ if (!Number.isSafeInteger(searchWork) || searchWork > EDIT_LIMITS.exactSearchCodeUnits) {
88
+ return { error: `edits exceed the ${EDIT_LIMITS.exactSearchCodeUnits} source-code-unit search budget` }
89
+ }
90
+ const replacements = []
91
+ for (const [index, edit] of edits.entries()) {
92
+ const start = source.indexOf(edit.old_string)
93
+ if (start < 0) return { error: `edits[${index}].old_string was not found in the target cell` }
94
+ if (source.indexOf(edit.old_string, start + 1) >= 0) {
95
+ return { error: `edits[${index}].old_string occurs more than once in the target cell` }
96
+ }
97
+ replacements.push({ start, end: start + edit.old_string.length, text: edit.new_string, index })
98
+ }
99
+ return { replacements }
100
+ }
101
+
102
+ function replacementParts(template) {
103
+ const parts = []
104
+ let cursor = 0
105
+ for (const token of template.matchAll(/\$(\$|&|`|'|\d{1,2}|<([^>]*)>)/g)) {
106
+ if (token.index > cursor) parts.push(template.slice(cursor, token.index))
107
+ parts.push({ token: token[0], marker: token[1], groupName: token[2] })
108
+ cursor = token.index + token[0].length
109
+ }
110
+ if (cursor < template.length) parts.push(template.slice(cursor))
111
+ return parts
112
+ }
113
+
114
+ function sourcePart(range, suffix = '') {
115
+ return range === undefined
116
+ ? { text: suffix }
117
+ : { start: range[0], end: range[1], suffix }
118
+ }
119
+
120
+ function resolvePart(part, match, sourceLength) {
121
+ if (typeof part === 'string') return { text: part }
122
+ const { token, marker, groupName } = part
123
+ if (marker === '$') return { text: '$' }
124
+ if (marker === '&') return sourcePart([match.start, match.end])
125
+ if (marker === '`') return sourcePart([0, match.start])
126
+ if (marker === "'") return sourcePart([match.end, sourceLength])
127
+ if (marker.startsWith('<')) {
128
+ if (match.groups === undefined) return { text: token }
129
+ return sourcePart(match.groups[groupName])
130
+ }
131
+ let captureIndex = Number(marker)
132
+ let suffix = ''
133
+ if (captureIndex > match.captures.length && marker.length === 2) {
134
+ captureIndex = Number(marker[0])
135
+ suffix = marker[1]
136
+ }
137
+ if (captureIndex === 0 || captureIndex > match.captures.length) return { text: token }
138
+ return sourcePart(match.captures[captureIndex - 1], suffix)
139
+ }
140
+
141
+ function regexReplacements(edits, source, timeoutMs) {
142
+ if (edits.length === 0) return { error: 'regex_edits must contain at least one replacement' }
143
+ if (edits.length > EDIT_LIMITS.regexEdits) {
144
+ return { error: `regex_edits must contain at most ${EDIT_LIMITS.regexEdits} replacements` }
145
+ }
146
+ let matches = 0
147
+ let templateLength = 0
148
+ let expansionSteps = 0
149
+ const parsed = []
150
+ for (const [index, edit] of edits.entries()) {
151
+ if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) {
152
+ return { error: `regex_edits[${index}] must be an object` }
153
+ }
154
+ const keys = Object.keys(edit)
155
+ if (keys.length !== 4 || !keys.includes('pattern') || !keys.includes('flags')
156
+ || !keys.includes('replacement') || !keys.includes('expected_matches')
157
+ || typeof edit.pattern !== 'string' || typeof edit.flags !== 'string'
158
+ || typeof edit.replacement !== 'string' || !Number.isSafeInteger(edit.expected_matches)) {
159
+ return { error: `regex_edits[${index}] expects exactly pattern, flags, replacement, and integer expected_matches` }
160
+ }
161
+ if (edit.pattern.length === 0) return { error: `regex_edits[${index}].pattern must be non-empty` }
162
+ if (!/^[gimsu]*$/.test(edit.flags) || new Set(edit.flags).size !== edit.flags.length) {
163
+ return { error: `regex_edits[${index}].flags must contain unique JavaScript g, i, m, s, or u flags` }
164
+ }
165
+ if (edit.expected_matches < 1 || edit.expected_matches > EDIT_LIMITS.regexMatches) {
166
+ return { error: `regex_edits[${index}].expected_matches must be between 1 and ${EDIT_LIMITS.regexMatches}` }
167
+ }
168
+ const parts = replacementParts(edit.replacement)
169
+ matches += edit.expected_matches
170
+ templateLength += edit.replacement.length
171
+ expansionSteps += edit.expected_matches * parts.length
172
+ if (matches > EDIT_LIMITS.regexMatches) {
173
+ return { error: `regex_edits must produce at most ${EDIT_LIMITS.regexMatches} matches in total` }
174
+ }
175
+ if (templateLength > EDIT_LIMITS.regexTemplateCodeUnits) {
176
+ return { error: `regex_edits replacement templates exceed the ${EDIT_LIMITS.regexTemplateCodeUnits} UTF-16 code-unit budget` }
177
+ }
178
+ if (expansionSteps > EDIT_LIMITS.regexExpansionSteps) {
179
+ return { error: `regex_edits exceed the ${EDIT_LIMITS.regexExpansionSteps} replacement-expansion step budget` }
180
+ }
181
+ parsed.push({
182
+ ...edit,
183
+ parts,
184
+ })
185
+ }
186
+ let result
187
+ try {
188
+ result = runInNewContext(REGEX_MATCH_SCRIPT, {
189
+ source,
190
+ rules: parsed.map(({ parts: _parts, ...rule }) => rule),
191
+ maxCaptureSlots: EDIT_LIMITS.regexCaptureSlots,
192
+ }, { timeout: timeoutMs })
193
+ } catch (error) {
194
+ return { error: `regex editing failed or exceeded its ${timeoutMs}ms matching budget: ${error.message}` }
195
+ }
196
+ if (result.error !== undefined) return result
197
+ return { replacements: result.replacements.map(match => ({
198
+ start: match.start,
199
+ end: match.end,
200
+ parts: parsed[match.ruleIndex].parts,
201
+ match,
202
+ index: match.ruleIndex,
203
+ })) }
204
+ }
205
+
206
+ function resolvedPartLength(part) {
207
+ return part.text === undefined ? part.end - part.start + part.suffix.length : part.text.length
208
+ }
209
+
210
+ function materializePart(part, source) {
211
+ return part.text === undefined ? source.slice(part.start, part.end) + part.suffix : part.text
212
+ }
213
+
214
+ function preflightAssembly(source, replacements) {
215
+ let generatedLength = source.length
216
+ let replacementLength = 0
217
+ const prepared = []
218
+ for (const replacement of replacements) {
219
+ const parts = replacement.text === undefined
220
+ ? replacement.parts.map(part => resolvePart(part, replacement.match, source.length))
221
+ : [{ text: replacement.text }]
222
+ let textLength = 0
223
+ for (const part of parts) {
224
+ textLength += resolvedPartLength(part)
225
+ }
226
+ replacementLength += textLength
227
+ generatedLength += textLength - (replacement.end - replacement.start)
228
+ prepared.push({ ...replacement, parts })
229
+ }
230
+ if (replacementLength > EDIT_LIMITS.generatedCodeUnits) {
231
+ return { error: `replacement output exceeds the ${EDIT_LIMITS.generatedCodeUnits} UTF-16 code-unit budget` }
232
+ }
233
+ if (generatedLength > EDIT_LIMITS.generatedCodeUnits) {
234
+ return { error: `edited source exceeds the ${EDIT_LIMITS.generatedCodeUnits} UTF-16 code-unit budget` }
235
+ }
236
+ return { replacements: prepared }
237
+ }
238
+
239
+ function assemble(source, replacements) {
240
+ const chunks = []
241
+ let cursor = 0
242
+ let changed = false
243
+ for (const replacement of replacements) {
244
+ const text = replacement.parts.map(part => materializePart(part, source)).join('')
245
+ chunks.push(source.slice(cursor, replacement.start), text)
246
+ cursor = replacement.end
247
+ if (text.length !== replacement.end - replacement.start
248
+ || !source.startsWith(text, replacement.start)) changed = true
249
+ }
250
+ chunks.push(source.slice(cursor))
251
+ return changed ? { code: chunks.join('') } : { error: 'edits must change at least one matched fragment' }
252
+ }
253
+
254
+ export function editRunCodeSchema() {
255
+ return {
256
+ name: 'edit_run_code',
257
+ description: 'Make a small exact or regular-expression change to the most recent eligible cell captured when this edit call is dispatched, then run the complete corrected cell. A successful edit becomes the next eligible cell. Use this only when replaying the whole cell is safe. If earlier code may already have caused an external effect, use a new run_code cell and its existing variables instead; this tool does not resume at the error location. Send exactly one atomic edits or regex_edits array. Choose edits for a few unique literal fragments; choose regex_edits when one counted pattern covers repeated fragments. Every resolved range must be non-overlapping in the original cell.',
258
+ parameters: {
259
+ type: 'object',
260
+ additionalProperties: false,
261
+ properties: {
262
+ edits: {
263
+ type: 'array', minItems: 1, maxItems: EDIT_LIMITS.exactEdits,
264
+ description: 'Atomic exact replacements, all resolved against the original target cell.',
265
+ items: {
266
+ type: 'object', additionalProperties: false,
267
+ properties: {
268
+ old_string: { type: 'string', minLength: 1, description: 'Exact text in the target cell. It must occur exactly once.' },
269
+ new_string: { type: 'string', description: 'Literal replacement text. Use an empty string to delete the match.' },
270
+ },
271
+ required: ['old_string', 'new_string'],
272
+ },
273
+ },
274
+ regex_edits: {
275
+ type: 'array', minItems: 1, maxItems: EDIT_LIMITS.regexEdits,
276
+ description: 'Atomic regular-expression replacements, all matched against the original target cell.',
277
+ items: {
278
+ type: 'object', additionalProperties: false,
279
+ properties: {
280
+ pattern: { type: 'string', minLength: 1, description: 'JavaScript regular-expression source. Zero-length matches are rejected.' },
281
+ flags: { type: 'string', pattern: '^(?!.*(.).*\\1)[gimsu]*$', description: 'Unique JavaScript g, i, m, s, or u flags. Global matching is always applied.' },
282
+ replacement: { type: 'string', description: 'JavaScript replacement template, including capture references such as $1 or $<name>.' },
283
+ expected_matches: { type: 'integer', minimum: 1, maximum: EDIT_LIMITS.regexMatches, description: 'Exact match count required before any replacement is applied.' },
284
+ },
285
+ required: ['pattern', 'flags', 'replacement', 'expected_matches'],
286
+ },
287
+ },
288
+ },
289
+ oneOf: [{ required: ['edits'] }, { required: ['regex_edits'] }],
290
+ },
291
+ }
292
+ }
293
+
294
+ export function editRejectedCell(value, source, timeoutMs = DEFAULT_REGEX_TIMEOUT_MS) {
295
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
296
+ return rejection('edit_run_code expects an object with exactly one edits or regex_edits array')
297
+ }
298
+ const keys = Object.keys(value)
299
+ if (keys.length !== 1 || !['edits', 'regex_edits'].includes(keys[0]) || !Array.isArray(value[keys[0]])) {
300
+ return rejection('edit_run_code expects exactly one edits or regex_edits array')
301
+ }
302
+ if (source === undefined) return rejection('no run_code cell is currently eligible for safe editing')
303
+ const resolved = keys[0] === 'edits'
304
+ ? exactReplacements(value.edits, source)
305
+ : regexReplacements(value.regex_edits, source, timeoutMs)
306
+ if (resolved.error !== undefined) return rejection(resolved.error)
307
+ const replacements = resolved.replacements.sort((left, right) => left.start - right.start)
308
+ for (let index = 1; index < replacements.length; index += 1) {
309
+ if (replacements[index].start < replacements[index - 1].end) {
310
+ return rejection(`${keys[0]}[${replacements[index].index}] overlaps ${keys[0]}[${replacements[index - 1].index}] in the target cell`)
311
+ }
312
+ }
313
+ const preflight = preflightAssembly(source, replacements)
314
+ if (preflight.error !== undefined) {
315
+ return rejection(keys[0] === 'regex_edits' ? preflight.error.replace(/^edits/, 'regex_edits') : preflight.error)
316
+ }
317
+ const assembled = assemble(source, preflight.replacements)
318
+ if (assembled.error !== undefined) {
319
+ return rejection(keys[0] === 'regex_edits' ? assembled.error.replace(/^edits/, 'regex_edits') : assembled.error)
320
+ }
321
+ return Object.freeze({
322
+ edited: true,
323
+ code: assembled.code,
324
+ description: keys[0] === 'edits'
325
+ ? EDIT_RUN_CODE_EXECUTION_DESCRIPTION
326
+ : REGEX_EDIT_RUN_CODE_EXECUTION_DESCRIPTION,
327
+ })
328
+ }