@myagentroam/node 0.10.20 → 0.10.22
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.
- package/dist/capabilities.js +4 -0
- package/dist/connector.js +59 -1
- package/dist/runner/mar-agent/runtime-model-factory.js +1 -0
- package/dist/runner/mar-agent-runner.js +1 -1
- package/dist/service/hosted-conversation-node-service.js +7 -3
- package/dist/service/model-usage-service.js +29 -1
- package/dist/service/native-session-watch-service.js +10 -0
- package/dist/service/prompt-completion-attention.js +66 -0
- package/dist/service/prompt-completion-cache.js +368 -0
- package/dist/service/prompt-completion-context.js +87 -0
- package/dist/service/prompt-completion-generator.js +450 -0
- package/dist/service/prompt-completion-journal.js +65 -0
- package/dist/service/prompt-completion-node-service.js +241 -0
- package/dist/service/prompt-completion-service.js +315 -0
- package/dist/service/prompt-completion-task-context.js +51 -0
- package/dist/service/prompt-completion-workspace-source.js +63 -0
- package/dist/service/session-catalog-service.js +1 -1
- package/dist/service/workspace-watch-service.js +4 -0
- package/dist/service/workspace-workbench-service.js +6 -0
- package/package.json +4 -4
package/dist/capabilities.js
CHANGED
|
@@ -13,6 +13,7 @@ const runtimeCredentialCapability = () => ({
|
|
|
13
13
|
namespacedTools: true,
|
|
14
14
|
responsesOptionalParameters: true,
|
|
15
15
|
responsesHttpContinuation: true,
|
|
16
|
+
conversationModels: true,
|
|
16
17
|
httpVersions: ['1.1', '2']
|
|
17
18
|
});
|
|
18
19
|
const workspaceDirectTransferCapability = () => ({
|
|
@@ -56,6 +57,7 @@ export function unavailableCapabilities() {
|
|
|
56
57
|
openCode: { available: false },
|
|
57
58
|
marAgent: { available: true, version: nodeAppVersion },
|
|
58
59
|
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
60
|
+
promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
|
|
59
61
|
chatgptLogin: { apiVersion: 1, loopback: true },
|
|
60
62
|
workspaceDirectTransfer: workspaceDirectTransferCapability(),
|
|
61
63
|
terminalDirect: terminalDirectCapability()
|
|
@@ -91,6 +93,7 @@ export function detectCapabilities(commands = {}) {
|
|
|
91
93
|
},
|
|
92
94
|
marAgent: { available: true, version: nodeAppVersion },
|
|
93
95
|
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
96
|
+
promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
|
|
94
97
|
chatgptLogin: { apiVersion: 1, loopback: true },
|
|
95
98
|
workspaceDirectTransfer: workspaceDirectTransferCapability(),
|
|
96
99
|
terminalDirect: terminalDirectCapability()
|
|
@@ -130,6 +133,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
|
|
|
130
133
|
},
|
|
131
134
|
marAgent: { available: true, version: nodeAppVersion },
|
|
132
135
|
marAgentRuntimeCredentials: runtimeCredentialCapability(),
|
|
136
|
+
promptCompletion: { apiVersion: 1, contextVersion: 3, automaticTriggers: true },
|
|
133
137
|
chatgptLogin: { apiVersion: 1, loopback: true },
|
|
134
138
|
workspaceDirectTransfer: workspaceDirectTransferCapability(),
|
|
135
139
|
terminalDirect: terminalDirectCapability()
|
package/dist/connector.js
CHANGED
|
@@ -102,6 +102,8 @@ import { ComputerUseEnablementService } from './service/computer-use-enablement-
|
|
|
102
102
|
import { safeErrorCode } from './util/safe-error.js';
|
|
103
103
|
import { RuntimeCredentialControlService } from './service/runtime-credential-control-service.js';
|
|
104
104
|
import { MarAgentRuntimeModelFactory } from './runner/mar-agent/runtime-model-factory.js';
|
|
105
|
+
import { PromptCompletionNodeService } from './service/prompt-completion-node-service.js';
|
|
106
|
+
import { PromptCompletionWorkspaceSource } from './service/prompt-completion-workspace-source.js';
|
|
105
107
|
import { MarAgentModelTransportTestService } from './service/mar-agent-model-transport-test-service.js';
|
|
106
108
|
import { ChatgptLoginService } from './service/chatgpt-login-service.js';
|
|
107
109
|
const HEARTBEAT_MS = 15_000;
|
|
@@ -219,6 +221,7 @@ export class NodeConnector {
|
|
|
219
221
|
sessionIdentityService;
|
|
220
222
|
nativeSessionProjectionService;
|
|
221
223
|
conversationHistoryService;
|
|
224
|
+
promptCompletion;
|
|
222
225
|
conversationSegmentService;
|
|
223
226
|
runnerInteractionService;
|
|
224
227
|
runnerChannelMessages;
|
|
@@ -1191,13 +1194,17 @@ export class NodeConnector {
|
|
|
1191
1194
|
nativeIds: (session) => this.nativeSessionProjectionService.nativeIds(session),
|
|
1192
1195
|
forget: (session, nativeIds) => {
|
|
1193
1196
|
this.nativeSessionProjectionService.forget(session, nativeIds);
|
|
1197
|
+
this.promptCompletion.invalidateSession(session.workspaceId, session.id);
|
|
1194
1198
|
void this.runAttachmentService.releaseSession(session.id);
|
|
1195
1199
|
},
|
|
1196
1200
|
refreshActivity: (session) => this.conversationHistoryService.refreshExternalActivity(session),
|
|
1197
1201
|
externalActivity: (sessionId) => this.sessionState.externalActivity.get(sessionId),
|
|
1198
1202
|
isWatching: (sessionId) => this.nativeSessionWatchService.isWatching(sessionId),
|
|
1199
1203
|
history: (session, input) => this.conversationHistoryService.read(session, input),
|
|
1200
|
-
invalidateHistory: (sessionId) =>
|
|
1204
|
+
invalidateHistory: (sessionId, workspaceId) => {
|
|
1205
|
+
this.nativeSessionWatchService.invalidate(sessionId);
|
|
1206
|
+
this.promptCompletion.invalidateSession(workspaceId, sessionId);
|
|
1207
|
+
},
|
|
1201
1208
|
invalidateWorkspaceSessions: (workspaceId) => {
|
|
1202
1209
|
this.workspaceSessionService.invalidateCache(workspaceId);
|
|
1203
1210
|
void this.workspaceWorkbenchService?.invalidateTopic(workspaceId, 'sessions');
|
|
@@ -1297,6 +1304,7 @@ export class NodeConnector {
|
|
|
1297
1304
|
onReconnectScheduled: () => this.metrics.reconnectScheduled(),
|
|
1298
1305
|
onMessage: (raw) => this.handleMessage(raw),
|
|
1299
1306
|
onClose: (code, reason) => {
|
|
1307
|
+
this.promptCompletion?.disconnect();
|
|
1300
1308
|
this.usageOutbox?.disconnected();
|
|
1301
1309
|
this.directTransferService?.close();
|
|
1302
1310
|
this.runtimeCredentials.disconnected();
|
|
@@ -1477,6 +1485,7 @@ export class NodeConnector {
|
|
|
1477
1485
|
this.workspaceCoordinator.invalidateChanges(workspaceId);
|
|
1478
1486
|
this.workspaceSessionService.invalidate(workspaceId);
|
|
1479
1487
|
this.workspaceCoordinator.clearWatches(workspaceId);
|
|
1488
|
+
this.promptCompletion?.invalidateWorkspace(workspaceId);
|
|
1480
1489
|
});
|
|
1481
1490
|
this.workspaceWorkbenchService = new WorkspaceWorkbenchService({
|
|
1482
1491
|
requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
|
|
@@ -1496,6 +1505,53 @@ export class NodeConnector {
|
|
|
1496
1505
|
nodeGeneration: () => this.nodeGeneration,
|
|
1497
1506
|
emitWatchEvent: (payload) => this.send('watch.event', payload)
|
|
1498
1507
|
});
|
|
1508
|
+
const completionWorkspace = new PromptCompletionWorkspaceSource({
|
|
1509
|
+
requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
|
|
1510
|
+
observesWorkspace: (workspaceId, actor, connectionId) => this.workspaceWatchService.observes(workspaceId, actor.sessionHash, connectionId),
|
|
1511
|
+
observesSession: (workspaceId, sessionId, actor, connectionId) => this.nativeSessionWatchService.observes(actor.sessionHash, connectionId, workspaceId, sessionId),
|
|
1512
|
+
resolveSession: (sessionId) => this.nativeSessionProjectionService.resolve(sessionId),
|
|
1513
|
+
cachedSessions: (workspaceId) => this.workspaceState.sessionCache.get(workspaceId)?.sessions ?? [],
|
|
1514
|
+
readHistory: (session, limit) => this.conversationHistoryService.read(session, { limit })
|
|
1515
|
+
});
|
|
1516
|
+
this.promptCompletion = new PromptCompletionNodeService({
|
|
1517
|
+
generation: () => this.nodeGeneration,
|
|
1518
|
+
readAttentionFile: (input, _actor, event) => {
|
|
1519
|
+
return this.workspaceWorkbenchService.readPredictionFile(input.target.workspaceId, event.path, event.worktreePath);
|
|
1520
|
+
},
|
|
1521
|
+
taskState: (input) => {
|
|
1522
|
+
const id = input.sessionId;
|
|
1523
|
+
if (!id || !this.runtime.getAgentSession(id))
|
|
1524
|
+
return { goal: 'UNKNOWN', queue: 'UNKNOWN' };
|
|
1525
|
+
const queue = this.runtime.sessionQueue(id);
|
|
1526
|
+
const run = this.runtime.activeSessionRun(id) ?? this.runtime.listRunsForSession(id)[0];
|
|
1527
|
+
const goal = this.goalRuntime.get(id);
|
|
1528
|
+
return {
|
|
1529
|
+
...(run ? { runStatus: run.status } : {}),
|
|
1530
|
+
goal: goal
|
|
1531
|
+
? {
|
|
1532
|
+
id: goal.id,
|
|
1533
|
+
objective: goal.objective,
|
|
1534
|
+
status: goal.status,
|
|
1535
|
+
revision: goal.revision
|
|
1536
|
+
}
|
|
1537
|
+
: null,
|
|
1538
|
+
queue: {
|
|
1539
|
+
version: queue.version,
|
|
1540
|
+
paused: queue.paused,
|
|
1541
|
+
items: queue.itemIds.flatMap((itemId) => {
|
|
1542
|
+
const item = this.runtime.getQueueItem(itemId);
|
|
1543
|
+
return item ? [{ queueItemId: item.id, content: item.content }] : [];
|
|
1544
|
+
})
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
},
|
|
1548
|
+
modelFactory: this.marAgentRuntimeModels,
|
|
1549
|
+
usage: this.modelUsage,
|
|
1550
|
+
observed: (input, actor) => completionWorkspace.observed(input, actor),
|
|
1551
|
+
readPage: (input, actor, signal) => completionWorkspace.readPage(input, actor, signal),
|
|
1552
|
+
references: (workspaceId, _actor, signal) => completionWorkspace.references(workspaceId, signal)
|
|
1553
|
+
});
|
|
1554
|
+
this.operationRouter.registerAll(this.promptCompletion.operations());
|
|
1499
1555
|
for (const operation of CONVERSATION_TEMPLATE_OPERATIONS)
|
|
1500
1556
|
this.operationRouter.register(operation, (data) => {
|
|
1501
1557
|
const handler = this.conversationTemplateOperations?.[operation];
|
|
@@ -1726,6 +1782,7 @@ export class NodeConnector {
|
|
|
1726
1782
|
this.stopping = undefined;
|
|
1727
1783
|
this.stopped = false;
|
|
1728
1784
|
this.config ??= await loadNodeConfig(this.configPath);
|
|
1785
|
+
this.promptCompletion.initializeJournal(nodeDataDirectory(this.config, this.configPath));
|
|
1729
1786
|
await this.upgradeCoordinator.loadResult(resolve(dirname(nodeDatabasePath(this.config, this.configPath)), 'upgrade-result.json'));
|
|
1730
1787
|
await assertLocalSqlitePath(nodeDatabasePath(this.config, this.configPath));
|
|
1731
1788
|
this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
|
|
@@ -2083,6 +2140,7 @@ export class NodeConnector {
|
|
|
2083
2140
|
}
|
|
2084
2141
|
this.claudeClient.execution.userInputs.clear();
|
|
2085
2142
|
this.nativeSessionWatchService.clear();
|
|
2143
|
+
this.promptCompletion.dispose();
|
|
2086
2144
|
this.sessionIdentityService.dispose();
|
|
2087
2145
|
this.sessionLifecycleService.clear();
|
|
2088
2146
|
this.continuousRunCoordinator.clear();
|
|
@@ -62,6 +62,7 @@ export class MarAgentRuntimeModelFactory {
|
|
|
62
62
|
outputCapabilities: configuration.outputCapabilities,
|
|
63
63
|
reasoningEfforts: configuration.reasoningEfforts,
|
|
64
64
|
defaultReasoningEffort: resolveMarAgentDefaultReasoningEffort(configuration.defaultReasoningEffort, configuration.reasoningEfforts),
|
|
65
|
+
conversationModel: configuration.conversationModel ?? true,
|
|
65
66
|
titleGeneration: configuration.titleGeneration,
|
|
66
67
|
codeMode: configuration.codeMode,
|
|
67
68
|
...(configuration.serviceTier === undefined
|
|
@@ -95,7 +95,7 @@ export class MarAgentRunner extends AbstractRunner {
|
|
|
95
95
|
return capabilities.marAgent?.available === true;
|
|
96
96
|
}
|
|
97
97
|
profileForValidation() {
|
|
98
|
-
const models = this.#models.filter((model) => model.enabled);
|
|
98
|
+
const models = this.#models.filter((model) => model.enabled && model.conversationModel !== false);
|
|
99
99
|
return {
|
|
100
100
|
runner: 'mar-agent',
|
|
101
101
|
models: models.map((model, index) => ({
|
|
@@ -259,8 +259,10 @@ export class HostedConversationNodeService {
|
|
|
259
259
|
: this.latestAvailableTemplateVersionId(conversation.templateId);
|
|
260
260
|
const displayVersionId = latestVersionId ?? conversation.activeVersionId;
|
|
261
261
|
const version = await this.loadTemplateVersion(displayVersionId, conversation.templateId);
|
|
262
|
-
const models = networkModels(allowedModels(version, availableModels), version.draft.networkMode);
|
|
263
|
-
if (models.length > 0
|
|
262
|
+
const models = networkModels(allowedModels(version, availableModels), version.draft.networkMode).filter((model) => model.conversationModel !== false);
|
|
263
|
+
if (models.length > 0 &&
|
|
264
|
+
availableModels.find((model) => model.id === conversation.modelId)?.conversationModel !==
|
|
265
|
+
false) {
|
|
264
266
|
const configuredModelId = conversation.modelId;
|
|
265
267
|
const selected = selectModel(version, availableModels, conversation.modelId);
|
|
266
268
|
if (selected.id !== conversation.modelId)
|
|
@@ -1707,10 +1709,12 @@ function allowedModels(version, models) {
|
|
|
1707
1709
|
return models.filter((model) => model.enabled && allowed.has(model.modelId));
|
|
1708
1710
|
}
|
|
1709
1711
|
function selectModel(version, models, requestedModelId) {
|
|
1710
|
-
const available = allowedModels(version, models);
|
|
1712
|
+
const available = allowedModels(version, models).filter((model) => model.conversationModel !== false);
|
|
1711
1713
|
const requested = available.find((candidate) => candidate.id === requestedModelId);
|
|
1712
1714
|
if (requested !== undefined)
|
|
1713
1715
|
return requested;
|
|
1716
|
+
if (models.some((candidate) => candidate.id === requestedModelId && candidate.conversationModel === false))
|
|
1717
|
+
throw new Error('HOSTED_CONVERSATION_MODEL_NOT_AVAILABLE');
|
|
1714
1718
|
const defaultModel = available.find((candidate) => candidate.modelId === version.modelPolicy.defaultId);
|
|
1715
1719
|
if (defaultModel !== undefined)
|
|
1716
1720
|
return defaultModel;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { modelUsageGrantSchema, modelUsageGrantClaimsSchema, modelUsageRecordSchema } from '@myagentroam/protocol';
|
|
2
2
|
import { nodeLog } from '../operational.js';
|
|
3
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
import { normalizeMarAgentUsage } from '../runner/mar-agent/conversation-normalizer.js';
|
|
4
5
|
/** Execution-private attribution never enters Run projections or browser Watch events. */
|
|
5
6
|
export class NodeModelUsageService {
|
|
6
7
|
outbox;
|
|
@@ -27,7 +28,13 @@ export class NodeModelUsageService {
|
|
|
27
28
|
const claims = modelUsageGrantClaimsSchema.parse(JSON.parse(Buffer.from(parsedGrant.token.split('.')[0], 'base64url').toString()));
|
|
28
29
|
if (claims.nodeId !== this.nodeId() || claims.scopeId !== parsedGrant.scopeId)
|
|
29
30
|
return;
|
|
30
|
-
this.bindings.set(runId, {
|
|
31
|
+
this.bindings.set(runId, {
|
|
32
|
+
userId: claims.userId,
|
|
33
|
+
grant: parsedGrant,
|
|
34
|
+
scopeId: claims.scopeId,
|
|
35
|
+
sessionId,
|
|
36
|
+
runner
|
|
37
|
+
});
|
|
31
38
|
}
|
|
32
39
|
catch {
|
|
33
40
|
nodeLog('model.usage.invalid', { code: 'USAGE_GRANT_INVALID' });
|
|
@@ -36,6 +43,27 @@ export class NodeModelUsageService {
|
|
|
36
43
|
release(runId) {
|
|
37
44
|
this.bindings.delete(runId);
|
|
38
45
|
}
|
|
46
|
+
completion(executionId, grant, sessionId, expectedUserId) {
|
|
47
|
+
this.bind(executionId, grant, sessionId, 'mar-agent');
|
|
48
|
+
if (expectedUserId !== undefined && this.bindings.get(executionId)?.userId !== expectedUserId)
|
|
49
|
+
this.release(executionId);
|
|
50
|
+
if (!this.bindings.has(executionId))
|
|
51
|
+
throw new Error('PROMPT_COMPLETION_USAGE_GRANT_REQUIRED');
|
|
52
|
+
return {
|
|
53
|
+
onModelUsage: (usage) => this.record(executionId, {
|
|
54
|
+
...normalizeMarAgentUsage({
|
|
55
|
+
...usage,
|
|
56
|
+
sessionId,
|
|
57
|
+
executionId,
|
|
58
|
+
purpose: 'other',
|
|
59
|
+
agentRole: 'root'
|
|
60
|
+
}),
|
|
61
|
+
purpose: 'prompt_completion'
|
|
62
|
+
}),
|
|
63
|
+
onModelUsageFailure: () => nodeLog('model.usage.invalid', { code: 'PROMPT_COMPLETION_USAGE_FAILED' }),
|
|
64
|
+
release: () => this.release(executionId)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
39
67
|
promoteSession(previousId, nextId) {
|
|
40
68
|
for (const [runId, binding] of this.bindings)
|
|
41
69
|
if (binding.sessionId === previousId)
|
|
@@ -199,6 +199,16 @@ export class NativeSessionWatchService {
|
|
|
199
199
|
has(sessionId) {
|
|
200
200
|
return this.watches.has(sessionId);
|
|
201
201
|
}
|
|
202
|
+
observes(sessionHash, connectionId, workspaceId, sessionId) {
|
|
203
|
+
const ownerKey = `${sessionHash}:${connectionId}`;
|
|
204
|
+
const lease = this.leases.get(ownerKey);
|
|
205
|
+
return (lease !== undefined &&
|
|
206
|
+
lease.ready &&
|
|
207
|
+
lease.expiresAt > Date.now() &&
|
|
208
|
+
lease.sessionId === sessionId &&
|
|
209
|
+
lease.workspaceId === workspaceId &&
|
|
210
|
+
this.sessionWatchSlots.owns(ownerKey, lease.slotToken));
|
|
211
|
+
}
|
|
202
212
|
isWatching(sessionId) {
|
|
203
213
|
return this.has(sessionId);
|
|
204
214
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isAbsolute, win32 } from 'node:path';
|
|
2
|
+
/** Browser observations are hints, not authority to read paths or execute instructions. */
|
|
3
|
+
export async function projectPredictionAttention(events, read, signal, now = Date.now()) {
|
|
4
|
+
const result = [];
|
|
5
|
+
let remaining = 4096;
|
|
6
|
+
for (const event of events.slice(-20).reverse()) {
|
|
7
|
+
signal.throwIfAborted();
|
|
8
|
+
if (event.observedAt > now ||
|
|
9
|
+
now - event.observedAt >= 180_000 ||
|
|
10
|
+
isAbsolute(event.path) ||
|
|
11
|
+
win32.isAbsolute(event.path) ||
|
|
12
|
+
event.path.split(/[\\/]/u).includes('..') ||
|
|
13
|
+
event.path.includes('\0') ||
|
|
14
|
+
event.path.startsWith('mar-attachment:'))
|
|
15
|
+
continue;
|
|
16
|
+
const metadata = {
|
|
17
|
+
kind: event.kind,
|
|
18
|
+
path: event.path,
|
|
19
|
+
worktreePath: event.worktreePath,
|
|
20
|
+
source: event.source,
|
|
21
|
+
observedAt: event.observedAt
|
|
22
|
+
};
|
|
23
|
+
// Diff blocks without a verified content identity are metadata-only, never read in full.
|
|
24
|
+
if (event.kind === 'DIFF' || !event.revision || event.startLine === undefined) {
|
|
25
|
+
result.push(metadata);
|
|
26
|
+
if (result.length >= 8)
|
|
27
|
+
break;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const value = (await read(event));
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
if (value.revision !== event.revision ||
|
|
34
|
+
value.encoding !== 'utf-8' ||
|
|
35
|
+
typeof value.content !== 'string')
|
|
36
|
+
continue;
|
|
37
|
+
const lines = value.content.split('\n');
|
|
38
|
+
// A bounded prefix can end mid-line. Never present that tail as a verified full line.
|
|
39
|
+
if (value.truncated)
|
|
40
|
+
lines.pop();
|
|
41
|
+
const endLine = Math.min(event.endLine, event.startLine + 79, lines.length);
|
|
42
|
+
if (event.startLine > endLine)
|
|
43
|
+
continue;
|
|
44
|
+
const text = lines
|
|
45
|
+
.slice(event.startLine - 1, endLine)
|
|
46
|
+
.join('\n')
|
|
47
|
+
.slice(0, Math.min(2048, remaining));
|
|
48
|
+
remaining -= text.length;
|
|
49
|
+
result.push({
|
|
50
|
+
...metadata,
|
|
51
|
+
revision: value.revision,
|
|
52
|
+
startLine: event.startLine,
|
|
53
|
+
endLine,
|
|
54
|
+
text
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (signal.aborted)
|
|
59
|
+
throw error;
|
|
60
|
+
// Missing, moved, changed, binary or inaccessible optional references are omitted.
|
|
61
|
+
}
|
|
62
|
+
if (result.length >= 8 || remaining <= 0)
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(result);
|
|
66
|
+
}
|