@superjackfruitlabs/salaam 0.0.1 → 0.0.2

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 (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +25 -17
  3. package/dist/src/a2a-backend.js +225 -0
  4. package/dist/src/a2a-inbound.js +487 -0
  5. package/dist/src/a2a-peer.js +370 -0
  6. package/dist/src/a2a-protocol.js +250 -0
  7. package/dist/src/agent-profiles.js +289 -0
  8. package/dist/src/children.js +132 -26
  9. package/dist/src/claude-provider.js +2 -179
  10. package/dist/src/cli-help.js +107 -6
  11. package/dist/src/cli.js +477 -13
  12. package/dist/src/config.js +26 -3
  13. package/dist/src/credentials.js +7 -2
  14. package/dist/src/external-runtime.js +1 -0
  15. package/dist/src/external-work.js +583 -0
  16. package/dist/src/harness-backend.js +466 -0
  17. package/dist/src/harness-container.js +450 -0
  18. package/dist/src/harness-model-gateway.js +104 -0
  19. package/dist/src/harness-prepare.js +88 -0
  20. package/dist/src/harness-probe.js +111 -0
  21. package/dist/src/harness-protocol.js +325 -0
  22. package/dist/src/harness-readiness.js +50 -0
  23. package/dist/src/harness-rpc.js +206 -0
  24. package/dist/src/harness-tool-identity.js +87 -0
  25. package/dist/src/harness-wire-bridge.js +387 -0
  26. package/dist/src/host.js +6 -1
  27. package/dist/src/house-extension.js +147 -64
  28. package/dist/src/live-provider.js +301 -0
  29. package/dist/src/managed.js +33 -3
  30. package/dist/src/protocol.js +9 -5
  31. package/dist/src/provider-auth.js +300 -0
  32. package/dist/src/provider-config.js +65 -17
  33. package/dist/src/provider-model.js +78 -0
  34. package/dist/src/provider-payload.js +264 -0
  35. package/dist/src/provider-transport.js +36 -0
  36. package/dist/src/provider-usage.js +59 -0
  37. package/dist/src/repository-snapshots.js +219 -0
  38. package/dist/src/service.js +274 -9
  39. package/dist/src/store.js +44 -2
  40. package/dist/src/supervisor.js +246 -35
  41. package/dist/src/task-budget.js +17 -8
  42. package/dist/src/task-context.js +15 -0
  43. package/dist/src/task-driver.js +38 -14
  44. package/dist/src/task-policy.js +20 -1
  45. package/dist/src/task-verification.js +3 -0
  46. package/dist/src/task-work.js +31 -0
  47. package/dist/src/team-work.js +378 -0
  48. package/dist/src/tui-view.js +65 -10
  49. package/dist/src/tui.js +4 -2
  50. package/dist/src/usage.js +18 -0
  51. package/dist/src/worker-boundary.js +298 -0
  52. package/dist/src/worker-workspaces.js +274 -0
  53. package/dist/src/workspaces.js +6 -2
  54. package/harness-runtime/codex-pins.json +10 -0
  55. package/harness-runtime/package-lock.json +560 -0
  56. package/harness-runtime/package.json +9 -0
  57. package/npm-shrinkwrap.json +57 -2
  58. package/package.json +2 -1
  59. package/src/a2a-backend.ts +254 -0
  60. package/src/a2a-inbound.ts +585 -0
  61. package/src/a2a-peer.ts +441 -0
  62. package/src/a2a-protocol.ts +286 -0
  63. package/src/agent-profiles.ts +379 -0
  64. package/src/children.ts +168 -27
  65. package/src/claude-provider.ts +2 -189
  66. package/src/cli-help.ts +107 -6
  67. package/src/cli.ts +602 -12
  68. package/src/config.ts +34 -3
  69. package/src/credentials.ts +9 -2
  70. package/src/external-runtime.ts +54 -0
  71. package/src/external-work.ts +663 -0
  72. package/src/harness-backend.ts +557 -0
  73. package/src/harness-container.ts +543 -0
  74. package/src/harness-model-gateway.ts +138 -0
  75. package/src/harness-prepare.ts +113 -0
  76. package/src/harness-probe.ts +150 -0
  77. package/src/harness-protocol.ts +388 -0
  78. package/src/harness-readiness.ts +71 -0
  79. package/src/harness-rpc.ts +244 -0
  80. package/src/harness-tool-identity.ts +91 -0
  81. package/src/harness-wire-bridge.ts +418 -0
  82. package/src/host.ts +11 -6
  83. package/src/house-extension.ts +165 -66
  84. package/src/live-provider.ts +347 -0
  85. package/src/managed.ts +43 -4
  86. package/src/protocol.ts +36 -8
  87. package/src/provider-auth.ts +336 -0
  88. package/src/provider-config.ts +89 -18
  89. package/src/provider-model.ts +86 -0
  90. package/src/provider-payload.ts +263 -0
  91. package/src/provider-transport.ts +51 -0
  92. package/src/provider-usage.ts +64 -0
  93. package/src/repository-snapshots.ts +259 -0
  94. package/src/service.ts +446 -11
  95. package/src/store.ts +60 -4
  96. package/src/supervisor.ts +294 -41
  97. package/src/task-budget.ts +24 -11
  98. package/src/task-context.ts +17 -0
  99. package/src/task-driver.ts +51 -15
  100. package/src/task-policy.ts +23 -3
  101. package/src/task-verification.ts +2 -0
  102. package/src/task-work.ts +46 -1
  103. package/src/team-work.ts +554 -0
  104. package/src/tui-client.ts +5 -0
  105. package/src/tui-view.ts +164 -12
  106. package/src/tui.ts +5 -2
  107. package/src/usage.ts +17 -0
  108. package/src/worker-boundary.ts +395 -0
  109. package/src/worker-workspaces.ts +390 -0
  110. package/src/workspaces.ts +4 -2
@@ -0,0 +1,264 @@
1
+ import { getCurrentTools } from '@earendil-works/pi-ai';
2
+ import { canonical, object } from './protocol.js';
3
+ import { getProviderModel, validateClaudePayload } from './provider-config.js';
4
+ const common = ['model', 'stream', 'tools', 'tool_choice', 'temperature', 'top_p', 'metadata'];
5
+ const allowedFields = {
6
+ 'anthropic-messages': [
7
+ ...common,
8
+ 'messages',
9
+ 'system',
10
+ 'max_tokens',
11
+ 'betas',
12
+ 'thinking',
13
+ 'output_config',
14
+ 'top_k',
15
+ 'stop_sequences',
16
+ ],
17
+ 'openai-completions': [
18
+ ...common,
19
+ 'messages',
20
+ 'max_tokens',
21
+ 'max_completion_tokens',
22
+ 'store',
23
+ 'stream_options',
24
+ 'reasoning',
25
+ 'reasoning_effort',
26
+ 'thinking',
27
+ 'chat_template_args',
28
+ 'enable_thinking',
29
+ 'tool_stream',
30
+ 'prompt_cache_key',
31
+ 'prompt_cache_retention',
32
+ 'frequency_penalty',
33
+ 'presence_penalty',
34
+ 'repetition_penalty',
35
+ 'min_p',
36
+ 'top_k',
37
+ 'stop',
38
+ ],
39
+ 'mistral-conversations': [
40
+ ...common,
41
+ 'messages',
42
+ 'maxTokens',
43
+ 'max_tokens',
44
+ 'toolChoice',
45
+ 'reasoningEffort',
46
+ 'reasoning_effort',
47
+ 'promptMode',
48
+ 'prompt_mode',
49
+ 'promptCacheKey',
50
+ 'prompt_cache_key',
51
+ ],
52
+ 'openai-responses': [
53
+ ...common,
54
+ 'input',
55
+ 'store',
56
+ 'max_output_tokens',
57
+ 'include',
58
+ 'reasoning',
59
+ 'prompt_cache_key',
60
+ 'prompt_cache_options',
61
+ 'prompt_cache_retention',
62
+ 'service_tier',
63
+ 'text',
64
+ 'parallel_tool_calls',
65
+ 'instructions',
66
+ ],
67
+ 'azure-openai-responses': [
68
+ ...common,
69
+ 'input',
70
+ 'store',
71
+ 'max_output_tokens',
72
+ 'include',
73
+ 'reasoning',
74
+ 'prompt_cache_key',
75
+ 'service_tier',
76
+ 'text',
77
+ 'parallel_tool_calls',
78
+ ],
79
+ 'openai-codex-responses': [
80
+ ...common,
81
+ 'input',
82
+ 'store',
83
+ 'include',
84
+ 'reasoning',
85
+ 'prompt_cache_key',
86
+ 'service_tier',
87
+ 'text',
88
+ 'parallel_tool_calls',
89
+ 'instructions',
90
+ ],
91
+ 'google-generative-ai': ['model', 'contents', 'config'],
92
+ 'google-vertex': ['model', 'contents', 'config'],
93
+ 'bedrock-converse-stream': [
94
+ 'modelId',
95
+ 'messages',
96
+ 'system',
97
+ 'inferenceConfig',
98
+ 'toolConfig',
99
+ 'additionalModelRequestFields',
100
+ 'requestMetadata',
101
+ ],
102
+ 'pi-messages': ['model', 'context', 'options'],
103
+ };
104
+ function keys(value, allowed) {
105
+ if (Object.keys(value).some((key) => !allowed.includes(key) && value[key] !== undefined))
106
+ throw new Error('Unsupported provider execution feature');
107
+ }
108
+ function cap(value, route) {
109
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > route.max_tokens)
110
+ throw new Error('Unapproved provider output limit');
111
+ }
112
+ function inventory(values, names, kind) {
113
+ if (!Array.isArray(values))
114
+ throw new Error('Provider tool inventory missing');
115
+ const actual = [];
116
+ for (const value of values) {
117
+ const tool = object(value);
118
+ if (kind === 'google') {
119
+ if (Object.keys(tool).some((key) => key !== 'functionDeclarations') ||
120
+ !Array.isArray(tool.functionDeclarations))
121
+ throw new Error('Provider server tools disabled');
122
+ actual.push(...tool.functionDeclarations.map((entry) => String(object(entry).name)));
123
+ }
124
+ else if (kind === 'bedrock') {
125
+ if (Object.keys(tool).some((key) => key !== 'toolSpec'))
126
+ throw new Error('Provider server tools disabled');
127
+ actual.push(String(object(tool.toolSpec).name));
128
+ }
129
+ else if (kind === 'function') {
130
+ if (tool.type !== 'function')
131
+ throw new Error('Provider server tools disabled');
132
+ actual.push(String(tool.name ?? object(tool.function).name));
133
+ }
134
+ else
135
+ actual.push(String(tool.name));
136
+ }
137
+ if (canonical(actual.sort()) !== canonical([...names].sort()))
138
+ throw new Error('Unapproved provider tool inventory');
139
+ }
140
+ function lastText(messages, frame, parts, role = 'user') {
141
+ if (!Array.isArray(messages) || !messages.length)
142
+ throw new Error('Provider messages missing');
143
+ const last = object(messages.at(-1));
144
+ const content = last[parts];
145
+ const blocks = Array.isArray(content)
146
+ ? content.filter((block) => !(Object.keys(object(block)).length === 1 && object(block).cachePoint))
147
+ : [];
148
+ const text = typeof content === 'string' ? content : blocks.length ? object(blocks.at(-1)).text : undefined;
149
+ if (last.role !== role || text !== frame)
150
+ throw new Error('Required task context missing from provider payload');
151
+ }
152
+ /** Validate the API-specific payload after extension hooks. No hosted tools or hidden continuations. */
153
+ export function validateProviderPayload(value, route, frame, tools, allowUncapped = false) {
154
+ const p = object(value), api = getProviderModel(route)?.api;
155
+ keys(p, allowedFields[String(api)] ?? []);
156
+ for (const key of [
157
+ 'fallbacks',
158
+ 'fallback_models',
159
+ 'models',
160
+ 'route',
161
+ 'mcp_servers',
162
+ 'container',
163
+ 'context_management',
164
+ 'previous_response_id',
165
+ 'conversation',
166
+ 'background',
167
+ 'environment',
168
+ 'agents',
169
+ 'built_in_tools',
170
+ 'cachedContent',
171
+ ])
172
+ if (p[key] !== undefined && p[key] !== false)
173
+ throw new Error('Unsupported provider execution feature');
174
+ const deployment = route.provider === 'azure'
175
+ ? (route.connection?.configuration.AZURE_OPENAI_DEPLOYMENT_NAME_MAP?.split(',')
176
+ .map((entry) => entry.trim().split('='))
177
+ .find(([id]) => id === route.model)?.[1]
178
+ ?.trim() ?? route.model)
179
+ : route.model;
180
+ if (api === 'anthropic-messages')
181
+ return validateClaudePayload(p, route, frame, tools);
182
+ if (api === 'google-generative-ai' || api === 'google-vertex') {
183
+ if (p.model !== deployment)
184
+ throw new Error('Unapproved provider model');
185
+ const config = object(p.config);
186
+ keys(config, [
187
+ 'maxOutputTokens',
188
+ 'temperature',
189
+ 'topP',
190
+ 'topK',
191
+ 'systemInstruction',
192
+ 'tools',
193
+ 'toolConfig',
194
+ 'thinkingConfig',
195
+ 'abortSignal',
196
+ 'httpOptions',
197
+ ]);
198
+ if (config.httpOptions !== undefined &&
199
+ canonical(config.httpOptions) !==
200
+ canonical({ retryOptions: { attempts: 1 }, timeout: 120000 }))
201
+ throw new Error('Unapproved Google HTTP controls');
202
+ for (const key of ['cachedContent', 'automaticFunctionCalling', 'responseModalities'])
203
+ if (config[key] !== undefined)
204
+ throw new Error('Unsupported Google execution feature');
205
+ cap(config.maxOutputTokens, route);
206
+ lastText(p.contents, frame, 'parts');
207
+ inventory(config.tools ?? [], tools, 'google');
208
+ }
209
+ else if (api === 'bedrock-converse-stream') {
210
+ if (p.modelId !== route.model)
211
+ throw new Error('Unapproved provider model');
212
+ keys(object(p.inferenceConfig), ['maxTokens', 'temperature', 'topP', 'stopSequences']);
213
+ keys(object(p.additionalModelRequestFields ?? {}), [
214
+ 'thinking',
215
+ 'reasoningConfig',
216
+ 'reasoning_effort',
217
+ 'reasoning',
218
+ 'output_config',
219
+ 'anthropic_beta',
220
+ ]);
221
+ cap(object(p.inferenceConfig).maxTokens, route);
222
+ lastText(p.messages, frame, 'content');
223
+ inventory(object(p.toolConfig).tools ?? [], tools, 'bedrock');
224
+ if (p.guardrailConfig || p.promptVariables || p.performanceConfig)
225
+ throw new Error('Unsupported Bedrock execution feature');
226
+ }
227
+ else if (api === 'pi-messages') {
228
+ if (p.model !== deployment)
229
+ throw new Error('Unapproved provider model');
230
+ keys(object(p.options), [
231
+ 'temperature',
232
+ 'maxTokens',
233
+ 'reasoning',
234
+ 'cacheRetention',
235
+ 'sessionId',
236
+ 'toolChoice',
237
+ ]);
238
+ cap(object(p.options).maxTokens, route);
239
+ const context = object(p.context);
240
+ lastText(context.messages, frame, 'content');
241
+ inventory(getCurrentTools(context.messages), tools, 'pi');
242
+ }
243
+ else if (['openai-responses', 'azure-openai-responses', 'openai-codex-responses'].includes(String(api))) {
244
+ if (p.model !== deployment)
245
+ throw new Error('Unapproved provider model');
246
+ if (p.stream !== true)
247
+ throw new Error('Provider streaming required');
248
+ if (!allowUncapped || p.max_output_tokens !== undefined)
249
+ cap(p.max_output_tokens, route);
250
+ lastText(p.input, frame, 'content');
251
+ inventory(p.tools ?? [], tools, 'function');
252
+ if (p.store !== false)
253
+ throw new Error('Provider storage disabled');
254
+ }
255
+ else if (api === 'openai-completions' || api === 'mistral-conversations') {
256
+ if (p.model !== deployment || p.stream !== true)
257
+ throw new Error('Unapproved provider model');
258
+ cap(p.max_completion_tokens ?? p.max_tokens ?? p.maxTokens, route);
259
+ lastText(p.messages, frame, 'content');
260
+ inventory(p.tools ?? [], tools, 'function');
261
+ }
262
+ else
263
+ throw new Error('Unsupported provider chat API');
264
+ }
@@ -0,0 +1,36 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
3
+ const scope = new AsyncLocalStorage();
4
+ let installed = false;
5
+ let underlyingFetch;
6
+ /** Worker-local SDK interception, scoped to the active model call (not auth refresh). */
7
+ export function installProviderTransport() {
8
+ if (installed)
9
+ return underlyingFetch;
10
+ installed = true;
11
+ const originalFetch = (underlyingFetch = globalThis.fetch);
12
+ globalThis.fetch = (input, init) => scope.getStore()?.fetch(input, init) ??
13
+ Promise.reject(new Error('Unadmitted worker network request'));
14
+ const send = BedrockRuntimeClient.prototype.send;
15
+ BedrockRuntimeClient.prototype.send = function (...args) {
16
+ const gate = scope.getStore();
17
+ if (gate) {
18
+ // Pi 1.3.0 doesn't forward maxRetries to this SDK. Disable SDK retries explicitly.
19
+ this.config.retryStrategy = async () => ({
20
+ mode: 'standard',
21
+ retry: async (next, request) => next(request),
22
+ });
23
+ const handler = this.config.requestHandler;
24
+ const handle = handler.handle.bind(handler);
25
+ handler.handle = async (request, options) => {
26
+ await gate.bedrock(request);
27
+ return handle(request, options);
28
+ };
29
+ }
30
+ return Reflect.apply(send, this, args);
31
+ };
32
+ return originalFetch;
33
+ }
34
+ export function withProviderTransport(transport, fn) {
35
+ return scope.run(transport, fn);
36
+ }
@@ -0,0 +1,59 @@
1
+ function record(value) {
2
+ return typeof value === 'object' && value !== null ? value : {};
3
+ }
4
+ const token = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
5
+ /** A normalized zero is not evidence that a provider reported zero usage. */
6
+ export class ProviderUsageReceipt {
7
+ input = false;
8
+ output = false;
9
+ observe(api, value) {
10
+ const event = record(value);
11
+ let input, output;
12
+ switch (api) {
13
+ case 'anthropic-messages': {
14
+ const usage = record(event.type === 'message_start' ? record(event.message).usage : event.usage);
15
+ input = usage.input_tokens;
16
+ output = event.type === 'message_delta' ? usage.output_tokens : undefined;
17
+ break;
18
+ }
19
+ case 'google-generative-ai':
20
+ case 'google-vertex': {
21
+ const usage = record(event.usageMetadata);
22
+ input = usage.promptTokenCount;
23
+ output = usage.candidatesTokenCount;
24
+ break;
25
+ }
26
+ case 'bedrock-converse-stream': {
27
+ const usage = record(record(event.metadata).usage);
28
+ input = usage.inputTokens;
29
+ output = usage.outputTokens;
30
+ break;
31
+ }
32
+ case 'openai-responses':
33
+ case 'azure-openai-responses':
34
+ case 'openai-codex-responses': {
35
+ const usage = record(record(event.response).usage);
36
+ input = usage.input_tokens;
37
+ output = usage.output_tokens;
38
+ break;
39
+ }
40
+ case 'pi-messages': {
41
+ const usage = record(event.usage);
42
+ input = usage.input;
43
+ output = usage.output;
44
+ break;
45
+ }
46
+ default: {
47
+ const usage = record(event.usage);
48
+ input = usage.prompt_tokens ?? usage.promptTokens;
49
+ output = usage.completion_tokens ?? usage.completionTokens;
50
+ break;
51
+ }
52
+ }
53
+ this.input ||= token(input);
54
+ this.output ||= token(output);
55
+ }
56
+ get complete() {
57
+ return this.input && this.output;
58
+ }
59
+ }
@@ -0,0 +1,219 @@
1
+ import { digest, integer, ServiceError, string } from './protocol.js';
2
+ import { readTree, filePath } from './workspaces.js';
3
+ import { workspaceDiff } from './workspace-diff.js';
4
+ export function repositoryPath(value) {
5
+ const path = filePath(value);
6
+ if (path
7
+ .split('/')
8
+ .some((part) => /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.aws|\.ssh|\.codex|\.gemini|\.claude|\.mcp\.json|\.salaam|\.pi|\.agents|\.state|credentials)$/i.test(part)))
9
+ throw new ServiceError('unsafe_path', 'Credential and control files are outside specialist scope');
10
+ return path;
11
+ }
12
+ export function repositoryTree(tree) {
13
+ return Object.fromEntries(Object.entries(tree).filter(([path]) => {
14
+ try {
15
+ repositoryPath(path);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }));
22
+ }
23
+ export class RepositorySnapshots {
24
+ store;
25
+ artifacts;
26
+ workspaces;
27
+ constructor(store, artifacts, workspaces) {
28
+ this.store = store;
29
+ this.artifacts = artifacts;
30
+ this.workspaces = workspaces;
31
+ }
32
+ /** Caller commits these references with the child intent. No live mount is handed to a reader. */
33
+ capture(parent, child) {
34
+ const workspace = this.workspaces.verify(parent), tree = repositoryTree(readTree(workspace.root));
35
+ if (digest(readTree(workspace.root)) !== workspace.digest)
36
+ throw new ServiceError('workspace_changed', 'Workspace changed during specialist admission');
37
+ const base = repositoryTree(JSON.parse(this.artifacts.read(workspace.baseline)));
38
+ const currentArtifact = this.artifacts.prepare(child, JSON.stringify(tree)), baseArtifact = this.artifacts.prepare(child, JSON.stringify(base));
39
+ currentArtifact.commit();
40
+ baseArtifact.commit();
41
+ return {
42
+ artifact_id: currentArtifact.id,
43
+ base_artifact: baseArtifact.id,
44
+ digest: digest(tree),
45
+ };
46
+ }
47
+ tree(binding) {
48
+ if (!binding.repository)
49
+ throw new ServiceError('authority_denied', 'No repository snapshot grant');
50
+ const tree = JSON.parse(this.artifacts.read(binding.repository.artifact_id));
51
+ if (digest(tree) !== binding.repository.digest)
52
+ throw new ServiceError('artifact_corrupt', 'Repository snapshot changed');
53
+ return tree;
54
+ }
55
+ read(binding, args) {
56
+ const tree = this.tree(binding), action = string(args.action, 'action', 32), offset = integer(args.offset ?? 0, 'offset');
57
+ const identity = {
58
+ workspace_id: binding.workspace?.id,
59
+ revision: binding.workspace?.revision,
60
+ digest: binding.repository.digest,
61
+ artifact_id: binding.repository.artifact_id,
62
+ };
63
+ if (action === 'inspect')
64
+ return { ...identity, authority: 'snapshot-read-only' };
65
+ if (action === 'list') {
66
+ const paths = Object.keys(tree).sort();
67
+ return {
68
+ ...identity,
69
+ files: paths.slice(offset, offset + 20).map((path) => ({
70
+ path,
71
+ hash: digest(tree[path]),
72
+ bytes: Buffer.from(tree[path].content, 'base64').length,
73
+ })),
74
+ next_offset: offset + 20 < paths.length ? offset + 20 : null,
75
+ };
76
+ }
77
+ if (action === 'read') {
78
+ const path = repositoryPath(args.path), file = tree[path];
79
+ if (!file)
80
+ throw new ServiceError('not_found', 'Snapshot file not found', 404);
81
+ const bytes = Buffer.from(file.content, 'base64'), text = bytes.toString('utf8');
82
+ if (text.includes('\0') || !Buffer.from(text).equals(bytes))
83
+ throw new ServiceError('binary_file', 'File is not UTF-8 text');
84
+ const lines = text.split('\n'), page = [];
85
+ let used = 0, next = offset;
86
+ while (next < lines.length && page.length < 100) {
87
+ const line = lines[next];
88
+ if (Buffer.byteLength(line) > 4096)
89
+ throw new ServiceError('line_limit', 'Line exceeds text limit');
90
+ if ((used += Buffer.byteLength(line)) > 8192)
91
+ break;
92
+ page.push({ line: next + 1, text: line });
93
+ next++;
94
+ }
95
+ return {
96
+ ...identity,
97
+ path,
98
+ hash: digest(file),
99
+ lines: page,
100
+ next_offset: next < lines.length ? next : null,
101
+ };
102
+ }
103
+ if (action === 'search') {
104
+ const query = string(args.query, 'query', 256), matches = [];
105
+ let index = 0, next = null;
106
+ outer: for (const path of Object.keys(tree).sort()) {
107
+ const bytes = Buffer.from(tree[path].content, 'base64'), text = bytes.toString('utf8');
108
+ if (text.includes('\0') || !Buffer.from(text).equals(bytes))
109
+ continue;
110
+ const lines = text.split('\n');
111
+ for (let line = 0; line < lines.length; line++)
112
+ if (lines[line].includes(query)) {
113
+ if (index++ < offset)
114
+ continue;
115
+ if (matches.length === 10) {
116
+ next = index - 1;
117
+ break outer;
118
+ }
119
+ matches.push({
120
+ path,
121
+ line: line + 1,
122
+ text: lines[line].slice(0, 512),
123
+ hash: digest(tree[path]),
124
+ });
125
+ }
126
+ }
127
+ return { ...identity, matches, next_offset: next };
128
+ }
129
+ if (action === 'diff' && binding.profile.role === 'reviewer') {
130
+ const base = JSON.parse(this.artifacts.read(binding.repository.base_artifact));
131
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(tree)])]
132
+ .sort()
133
+ .filter((path) => digest(base[path] ?? null) !== digest(tree[path] ?? null));
134
+ return {
135
+ ...identity,
136
+ changes: paths.slice(offset, offset + 5).map((path) => ({
137
+ path,
138
+ before_hash: base[path] ? digest(base[path]) : null,
139
+ after_hash: tree[path] ? digest(tree[path]) : null,
140
+ diff: workspaceDiff({
141
+ path,
142
+ before: base[path] ?? null,
143
+ after: tree[path] ?? null,
144
+ }).slice(0, 8192),
145
+ })),
146
+ next_offset: offset + 5 < paths.length ? offset + 5 : null,
147
+ };
148
+ }
149
+ throw new ServiceError('authority_denied', 'Specialists can only inspect, list, search or read their snapshot; reviewers can inspect its diff');
150
+ }
151
+ report(binding, raw) {
152
+ if (!binding.tools.includes('salaam_findings'))
153
+ throw new ServiceError('authority_denied', 'No findings grant');
154
+ if (!Array.isArray(raw) || raw.length > 32)
155
+ throw new ServiceError('invalid_field', 'At most 32 findings');
156
+ const tree = this.tree(binding);
157
+ const findings = raw.map((item) => {
158
+ if (!item || typeof item !== 'object' || Array.isArray(item))
159
+ throw new ServiceError('invalid_field', 'Invalid finding');
160
+ const f = item, path = repositoryPath(f.path), file = tree[path];
161
+ if (!file)
162
+ throw new ServiceError('not_found', 'Finding file is absent from the reviewed snapshot', 404);
163
+ const line = integer(f.line, 'line'), end = integer(f.end_line ?? line, 'end_line'), severity = string(f.severity, 'severity', 16);
164
+ const source = Buffer.from(file.content, 'base64').toString('utf8').split('\n');
165
+ if (!['critical', 'high', 'medium', 'low'].includes(severity) ||
166
+ line < 1 ||
167
+ end < line ||
168
+ end >= line + 20 ||
169
+ end > source.length)
170
+ throw new ServiceError('invalid_field', 'Invalid finding location or severity');
171
+ return {
172
+ path,
173
+ line,
174
+ end_line: end,
175
+ severity,
176
+ message: string(f.message, 'message', 2048),
177
+ hash: digest(file),
178
+ evidence: source
179
+ .slice(line - 1, end)
180
+ .join('\n')
181
+ .slice(0, 8192),
182
+ };
183
+ });
184
+ const input = digest(findings);
185
+ return this.store.transaction(() => {
186
+ const prior = this.store.db
187
+ .prepare('SELECT * FROM specialist_reports WHERE worker_id=?')
188
+ .get(binding.worker_id);
189
+ if (prior) {
190
+ if (prior.input_digest !== input)
191
+ throw new ServiceError('request_conflict', 'Final findings already bind different content');
192
+ return {
193
+ artifact_id: String(prior.artifact_id),
194
+ kind: 'analysis',
195
+ verified_success: false,
196
+ };
197
+ }
198
+ const artifact = this.artifacts.prepare(binding.session_id, JSON.stringify({
199
+ schema_version: 1,
200
+ worker_id: binding.worker_id,
201
+ attempt_id: binding.attempt_id,
202
+ binding_digest: digest(binding),
203
+ snapshot_digest: binding.repository.digest,
204
+ kind: 'analysis',
205
+ findings,
206
+ }));
207
+ artifact.commit();
208
+ this.store.db
209
+ .prepare('INSERT INTO specialist_reports VALUES (?,?,?)')
210
+ .run(binding.worker_id, input, artifact.id);
211
+ this.store.event(binding.owner_session, 'specialist.findings', {
212
+ worker_id: binding.worker_id,
213
+ artifact_id: artifact.id,
214
+ kind: 'analysis',
215
+ });
216
+ return { artifact_id: artifact.id, kind: 'analysis', verified_success: false };
217
+ });
218
+ }
219
+ }