diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65f3c4ffc..30815f451 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -125,6 +125,11 @@ jobs: cd commonly-mcp npm test + - name: Run CLI lint + run: | + cd cli + npm run lint + - name: Run CLI tests run: | cd cli diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs new file mode 100644 index 000000000..6e1258bce --- /dev/null +++ b/cli/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { + es2022: true, + node: true, + }, + extends: ['eslint:recommended'], + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + // CLI commands deliberately report progress and recovery guidance locally. + 'no-console': 'off', + // Adapter contracts keep unused context parameters for a uniform shape. + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, +}; diff --git a/cli/__tests__/enforcement.test.mjs b/cli/__tests__/enforcement.test.mjs index 830dfb34b..31a338b7d 100644 --- a/cli/__tests__/enforcement.test.mjs +++ b/cli/__tests__/enforcement.test.mjs @@ -450,6 +450,29 @@ describe('deliverChatReply', () => { expect(post).toHaveBeenCalledWith(messagesPath, { content: 'short answer' }); }); + test('a normal-return refusal is not recorded as a single-message delivery', async () => { + // The runtime route returns HTTP 200 for this policy refusal. The resolved + // promise proves only that the server decided, not that it created a row. + const post = jest.fn().mockResolvedValue({ + success: false, + refused: true, + reason: 'consecutive_run_cap', + consecutive: 3, + guidance: 'Do not retry this message unchanged.', + }); + const res = await deliverChatReply({ client: { post }, podId: 'pod-1', text: 'short answer' }); + + expect(res).toEqual({ + mode: 'refused', + messages: 0, + attemptedMessages: 1, + refused: true, + reason: 'consecutive_run_cap', + consecutive: 3, + guidance: 'Do not retry this message unchanged.', + }); + }); + test('a split-sized reply posts its chunks in order', async () => { const post = jest.fn().mockResolvedValue({}); const text = `${'a'.repeat(390)}\n\n${'b'.repeat(390)}`; @@ -459,8 +482,171 @@ describe('deliverChatReply', () => { expect(post.mock.calls[1][1].content).toBe('b'.repeat(390)); }); - test('a document-sized reply uploads whole and posts one lead message with the file card', async () => { - const post = jest.fn().mockResolvedValue({}); + test('a split reply stops at a normal-return refusal and reports only delivered chunks', async () => { + const refusal = { + success: false, + refused: true, + reason: 'consecutive_run_cap', + consecutive: 3, + guidance: 'Wait for someone else to speak.', + }; + const post = jest.fn() + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce(refusal); + const text = `${'a'.repeat(390)}\n\n${'b'.repeat(390)}\n\n${'c'.repeat(390)}`; + + const res = await deliverChatReply({ client: { post }, podId: 'pod-1', text }); + + expect(res).toEqual({ + mode: 'refused', + messages: 2, + attemptedMessages: 3, + refused: true, + reason: 'consecutive_run_cap', + consecutive: 3, + guidance: 'Wait for someone else to speak.', + }); + expect(post).toHaveBeenCalledTimes(3); + expect(post.mock.calls.map(([, body]) => body.content)).toEqual([ + 'a'.repeat(390), 'b'.repeat(390), 'c'.repeat(390), + ]); + }); + + // Prose overflow is a THREAD now, not an attachment (Sam 57691). + // + // These pin the rung itself, not just its return value. The old behaviour + // and the new one both post a first message containing the reply's opening + // line, so asserting "posted something that starts with Point 0" stays + // green across the regression — the distinguishing evidence is whether the + // CONTINUATIONS carry a threadRootId, and whether the full text survives + // without an upload at all. + describe('prose overflow continues in a thread', () => { + const prose = Array.from({ length: 8 }, (_, i) => `Point ${i}: ${'x'.repeat(300)}`).join('\n\n'); + + test('posts the headline to the channel and the rest under it', async () => { + const post = jest.fn() + .mockResolvedValueOnce({ message: { id: 4242 } }) + .mockResolvedValue({}); + const upload = jest.fn(); + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text: prose }); + + expect(res.mode).toBe('thread'); + expect(res.threadRootId).toBe('4242'); + expect(upload).not.toHaveBeenCalled(); + + // First call is top-level: no threadRootId at all, not a null one. + expect(post.mock.calls[0][1]).toEqual({ content: expect.stringContaining('Point 0:') }); + // Every continuation is rooted at the headline. + for (const call of post.mock.calls.slice(1)) { + expect(call[1].threadRootId).toBe('4242'); + } + expect(post.mock.calls.length).toBeGreaterThan(1); + }); + + test('no word is lost — the chunks reassemble to the original', async () => { + const post = jest.fn() + .mockResolvedValueOnce({ message: { id: 7 } }) + .mockResolvedValue({}); + await deliverChatReply({ client: { post, upload: jest.fn() }, podId: 'pod-1', text: prose }); + const posted = post.mock.calls.map((c) => c[1].content).join('\n\n'); + expect(posted).toBe(prose); + }); + + test('accepts _id as well as id, since the two shapes are both live', async () => { + const post = jest.fn() + .mockResolvedValueOnce({ message: { _id: 99 } }) + .mockResolvedValue({}); + const res = await deliverChatReply({ client: { post, upload: jest.fn() }, podId: 'pod-1', text: prose }); + expect(res.threadRootId).toBe('99'); + }); + + test('a refused thread headline stops without falling through to an attachment', async () => { + const refusal = { + refused: true, + reason: 'consecutive_run_cap', + guidance: 'Wait for someone else to speak.', + }; + const post = jest.fn().mockResolvedValue(refusal); + const upload = jest.fn(); + + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text: prose }); + + expect(res).toEqual({ + mode: 'refused', + messages: 0, + attemptedMessages: splitForChat(prose).length, + refused: true, + reason: 'consecutive_run_cap', + guidance: 'Wait for someone else to speak.', + }); + expect(post).toHaveBeenCalledTimes(1); + expect(upload).not.toHaveBeenCalled(); + }); + + test('a refused continuation stops without re-posting the remaining thread as top-level messages', async () => { + const refusal = { + refused: true, + reason: 'consecutive_run_cap', + guidance: 'Wait for someone else to speak.', + }; + const post = jest.fn() + .mockResolvedValueOnce({ message: { id: 'root-1' } }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce(refusal); + const upload = jest.fn(); + + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text: prose }); + + expect(res).toMatchObject({ + mode: 'refused', + messages: 2, + attemptedMessages: splitForChat(prose).length, + reason: 'consecutive_run_cap', + }); + expect(post).toHaveBeenCalledTimes(3); + expect(post.mock.calls.slice(1).every(([, body]) => body.threadRootId === 'root-1')).toBe(true); + expect(upload).not.toHaveBeenCalled(); + }); + + test('never guesses a root, and never re-posts the headline it already sent', async () => { + // A continuation posted with a missing root becomes another TOP-LEVEL + // message, so refusing is the only safe read of an unknown response. + // But the headline is already in the room by then — attaching here + // would lead with the same opening line a second time. Post the + // remainder instead. + const post = jest.fn().mockResolvedValue({}); + const upload = jest.fn(); + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text: prose }); + expect(res.mode).toBe('thread-fallback'); + expect(upload).not.toHaveBeenCalled(); + const openings = post.mock.calls.filter((c) => c[1].content.startsWith('Point 0:')); + expect(openings).toHaveLength(1); + }); + + test('an indivisible oversize unit still attaches — that one IS a document', async () => { + // The control that keeps this change honest: threading prose must not + // swallow the case attachment was always correct for. + const post = jest.fn().mockResolvedValue({}); + const upload = jest.fn().mockResolvedValue({ + fileName: 'srv.md', originalName: 'reply.md', size: 950, kind: 'document', + }); + const fence = `\`\`\`js\n${'const x = 1;\n'.repeat(72)}\`\`\``; + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text: fence }); + expect(res.mode).toBe('attach'); + expect(post.mock.calls.every((c) => c[1].threadRootId === undefined)).toBe(true); + }); + }); + + // Was 'a document-sized reply uploads whole and posts one lead message with + // the file card'. Its fixture is eight paragraphs of prose, which is the + // case Sam 57691 reclassified: prose that outgrew a message is not a + // document. The test's INTENT — nothing is cut, the whole reply survives — + // is preserved verbatim below, now carried by the thread instead of a file. + test('a document-sized reply keeps every word, now in a thread rather than a file', async () => { + const post = jest.fn() + .mockResolvedValueOnce({ message: { id: 1234 } }) + .mockResolvedValue({}); const upload = jest.fn().mockResolvedValue({ fileName: 'srv-name.md', originalName: 'reply.md', size: 2000, kind: 'document', }); @@ -469,17 +655,28 @@ describe('deliverChatReply', () => { const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text, uploadName: 'reply.md', }); - expect(res).toEqual({ mode: 'attach', messages: 1 }); - expect(upload).toHaveBeenCalledWith( - '/api/agents/runtime/pods/pod-1/uploads', - expect.objectContaining({ fileName: 'reply.md', contentType: 'text/markdown' }), - ); - // The uploaded file carries the FULL text — nothing is cut. - expect(upload.mock.calls[0][1].fileBuffer.toString('utf8')).toBe(text); + expect(res.mode).toBe('thread'); + expect(upload).not.toHaveBeenCalled(); + // The thread carries the FULL text — nothing is cut. Same assertion the + // attach version made about the file buffer. + expect(post.mock.calls.map((cl) => cl[1].content).join('\n\n')).toBe(text); + expect(post.mock.calls[0][1].content).toContain('Point 0:'); // opening stays the headline + }); + + test('an attachment-card refusal does not masquerade as an attachment delivery', async () => { + const post = jest.fn().mockResolvedValue({ + refused: true, reason: 'consecutive_run_cap', guidance: 'Do not retry unchanged.', + }); + const upload = jest.fn().mockResolvedValue({ fileName: 'srv.md', originalName: 'reply.md' }); + const text = `\`\`\`js\n${'const x = 1;\n'.repeat(72)}\`\`\``; + + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text }); + + expect(res).toMatchObject({ + mode: 'refused', messages: 0, attemptedMessages: 1, reason: 'consecutive_run_cap', + }); expect(post).toHaveBeenCalledTimes(1); - const { content } = post.mock.calls[0][1]; - expect(content).toContain('Point 0:'); // leads with the reply's own opening - expect(content).toContain('[[upload:srv-name.md|reply.md|2000|document]]'); + expect(upload).toHaveBeenCalledTimes(1); }); test('a document-sized single fence attaches — it cannot ride the single-post branch (msg 53018)', async () => { @@ -531,18 +728,80 @@ describe('deliverChatReply', () => { expect(post.mock.calls[0][1].content).toContain('Here is the diff:'); }); - test('upload failure degrades to posting every chunk — flood beats truncation or silence', async () => { - const post = jest.fn().mockResolvedValue({}); - const upload = jest.fn().mockRejectedValue(new Error('older server')); + // Same guarantee as before — flood beats truncation or silence — but this + // prose fixture now degrades through the THREAD rung, not the upload rung. + // A server that returns no message id cannot root a thread, and the reply + // must still arrive whole. + test('a rootless server degrades to posting every chunk — flood beats truncation or silence', async () => { + const post = jest.fn().mockResolvedValue({}); // no id anywhere in the response + const upload = jest.fn(); const log = jest.fn(); const text = Array.from({ length: 6 }, () => 'y'.repeat(350)).join('\n\n'); const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text, log, }); - expect(res.mode).toBe('split-fallback'); + expect(res.mode).toBe('thread-fallback'); expect(post).toHaveBeenCalledTimes(res.messages); + // Every word arrives, and the headline is posted exactly once — the + // duplicate-opening bug this fallback exists to avoid. expect(post.mock.calls.map((c) => c[1].content).join('\n\n')).toBe(text); - expect(log).toHaveBeenCalledWith(expect.stringContaining('older server')); + expect(post.mock.calls.filter((c) => c[1].content === 'y'.repeat(350)).length).toBe(6); + expect(log).toHaveBeenCalledWith(expect.stringContaining('cannot root the thread')); + }); + + // The two tests above both fail at the ROOT-ID step, before a single + // continuation has posted. That is why a boolean `headlinePosted` passed + // them: at that instant "something posted" and "one thing posted" are the + // same statement. They stop being the same the moment a continuation + // succeeds and a later one throws. + test('a continuation that fails PART WAY resumes from there — no chunk posted twice', async () => { + const chunks = Array.from({ length: 6 }, (_, i) => `${'z'.repeat(340)}${i}`); + const text = chunks.join('\n\n'); + let n = 0; + const post = jest.fn().mockImplementation(async () => { + n += 1; + // 1 = headline, 2 and 3 = continuations that land in the thread, + // 4 = the one that dies. Chunks 1 and 2 are already in the room. + if (n === 4) throw new Error('upstream 503'); + return { message: { id: 'root-1' } }; + }); + const log = jest.fn(); + const res = await deliverChatReply({ + client: { post, upload: jest.fn() }, podId: 'pod-1', text, log, + }); + + expect(res.mode).toBe('thread-fallback'); + // The whole reply arrives, and NOTHING arrives twice. With the boolean, + // chunks 1 and 2 were re-posted top-level and this count read 9. + expect(post).toHaveBeenCalledTimes(chunks.length + 1); // +1 for the throw + const delivered = post.mock.calls + .map((c) => c[1].content) + .filter((_, i) => i !== 3); // the call that threw delivered nothing + expect(delivered).toEqual(chunks); + for (const chunk of chunks) { + expect(delivered.filter((c) => c === chunk)).toHaveLength(1); + } + // The resumed chunks go top-level — that is the fallback, not a regression. + expect(post.mock.calls.slice(4).every((c) => c[1].threadRootId === undefined)).toBe(true); + expect(log).toHaveBeenCalledWith(expect.stringContaining('posting the remainder top-level')); + }); + + test('the attachment fallback also stops at a normal-return refusal', async () => { + const post = jest.fn() + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ + refused: true, reason: 'consecutive_run_cap', guidance: 'Wait for a reply first.', + }); + const upload = jest.fn().mockRejectedValue(new Error('older server')); + const fence = `\`\`\`js\n${'const x = 1;\n'.repeat(72)}\`\`\``; + const text = `${'y'.repeat(350)}\n\n${fence}\n\n${'z'.repeat(350)}`; + + const res = await deliverChatReply({ client: { post, upload }, podId: 'pod-1', text }); + + expect(res).toMatchObject({ + mode: 'refused', messages: 1, attemptedMessages: splitForChat(text).length, reason: 'consecutive_run_cap', + }); + expect(post).toHaveBeenCalledTimes(2); }); }); diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 0f45b3de4..6716a99ef 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -125,6 +125,73 @@ describe('performRun', () => { ); }); + test('a normal-return run-cap refusal is acked as a refusal, not a posted reply', async () => { + // The post route deliberately responds 200 with { refused: true }. This + // is terminal guidance — retrying the same event would duplicate the two + // chunks that did land — so the wrapper must expose it locally and ack the + // event as no_action rather than throw into at-least-once redelivery. + const guidance = 'Wait for someone else to speak; do not retry unchanged.'; + let messagePosts = 0; + const mockGet = jest.fn().mockResolvedValue({ events: [makeEvent({ _id: 'evt-run-cap' })] }); + const mockPost = jest.fn(async (route) => { + if (route === '/api/agents/runtime/pods/pod-abc/messages') { + messagePosts += 1; + return messagePosts < 3 + ? { success: true } + : { + success: false, + refused: true, + reason: 'consecutive_run_cap', + consecutive: 3, + guidance, + }; + } + return {}; + }); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + const onError = jest.fn(); + const text = `${'a'.repeat(390)}\n\n${'b'.repeat(390)}\n\n${'c'.repeat(390)}`; + const spawn = jest.fn(async () => ({ text })); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'my-stub', + onError, + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(mockPost.mock.calls.filter(([route]) => route === '/api/agents/runtime/pods/pod-abc/messages')) + .toHaveLength(3); + expect(mockPost).toHaveBeenCalledWith( + '/api/agents/runtime/events/evt-run-cap/ack', + { + result: { + outcome: 'no_action', + reason: 'consecutive_run_cap', + details: { + mode: 'refused', + postedMessages: 2, + attemptedMessages: 3, + consecutive: 3, + guidance, + }, + }, + }, + ); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ + code: 'agent_delivery_refused', + reason: 'consecutive_run_cap', + postedMessages: 2, + attemptedMessages: 3, + })); + expect(onError.mock.calls[0][0].message).toContain(guidance); + }); + test('first_contact event is forwarded to the adapter like a mention', async () => { const events = [makeEvent({ _id: 'evt-first-contact', diff --git a/cli/package-lock.json b/cli/package-lock.json index d52e35706..1f0ae5006 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.8", + "version": "0.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.8", + "version": "0.1.21", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" @@ -15,6 +15,7 @@ "commonly": "src/index.js" }, "devDependencies": { + "eslint": "^8.56.0", "jest": "^29.7.0" }, "engines": { @@ -517,6 +518,137 @@ "dev": true, "license": "MIT" }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -886,6 +1018,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1029,6 +1199,53 @@ "dev": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1543,6 +1760,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1573,6 +1797,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -1630,6 +1867,203 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1644,6 +2078,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -1694,6 +2174,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1701,6 +2188,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -1711,6 +2215,19 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1738,6 +2255,28 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1835,6 +2374,48 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1842,6 +2423,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1882,6 +2470,43 @@ "node": ">=10.17.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -1954,6 +2579,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1974,6 +2609,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1984,6 +2632,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2718,6 +3376,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2725,6 +3390,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2738,6 +3417,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -2758,6 +3447,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -2778,6 +3481,13 @@ "node": ">=8" } }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2948,6 +3658,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3003,6 +3731,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -3102,6 +3843,16 @@ "node": ">=8" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -3144,6 +3895,16 @@ "node": ">= 6" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3161,6 +3922,27 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3232,6 +4014,58 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3446,6 +4280,13 @@ "node": ">=8" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -3466,6 +4307,19 @@ "node": ">=8.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -3527,6 +4381,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -3568,6 +4432,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/cli/package.json b/cli/package.json index fe18ee1a5..cb35c4ae9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.18", + "version": "0.1.21", "license": "Apache-2.0", "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast", "type": "module", @@ -28,7 +28,8 @@ ], "scripts": { "start": "node src/index.js", - "lint": "eslint src/", + "lint": "eslint src --ext .js", + "lint:fix": "eslint src --ext .js --fix", "test": "node --experimental-vm-modules node_modules/.bin/jest", "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\"" }, @@ -36,6 +37,7 @@ "commander": "^12.0.0" }, "devDependencies": { + "eslint": "^8.56.0", "jest": "^29.7.0" }, "jest": { diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 9f7e10436..c5406367f 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1069,6 +1069,7 @@ export const performRun = ({ && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText); const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply; let delivered = agentPostedItself; + let deliveryRefusal = null; if (event.type === 'agent.ask') { if (silentReply) { @@ -1154,11 +1155,32 @@ export const performRun = ({ uploadName: `${agentName}-reply-${event._id}.md`, log: (line) => log(`[${event.type}] ${line}`), }); - delivered = true; - log( - `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as ` - + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`, - ); + if (delivery.refused) { + // A run-cap refusal is a successful HTTP request but not a delivery. + // Ack it so the kernel does not replay the same text (the server + // guidance expressly says not to retry unchanged), while preserving + // the refusal and its partial-post count for the seat and event ledger. + deliveryRefusal = delivery; + const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.'; + const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`; + log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`); + onError?.(Object.assign( + new Error(`${event.type} wrapper delivery refused ${detail}: ${guidance}`), + { + code: 'agent_delivery_refused', + reason: delivery.reason, + eventId: event._id, + postedMessages: delivery.messages, + attemptedMessages: delivery.attemptedMessages, + }, + )); + } else { + delivered = true; + log( + `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as ` + + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`, + ); + } } if (result.memorySummary) { try { @@ -1176,6 +1198,21 @@ export const performRun = ({ // streak. Recording only on completion means a spawn failure that gets // redelivered never double-counts toward the cap. cascadeGovernor.record(eventPodId, trigger); + if (deliveryRefusal) { + return { + outcome: 'no_action', + reason: deliveryRefusal.reason, + details: { + mode: deliveryRefusal.mode, + postedMessages: deliveryRefusal.messages, + attemptedMessages: deliveryRefusal.attemptedMessages, + ...(typeof deliveryRefusal.consecutive === 'number' + ? { consecutive: deliveryRefusal.consecutive } + : {}), + ...(deliveryRefusal.guidance ? { guidance: deliveryRefusal.guidance } : {}), + }, + }; + } return { outcome: delivered ? 'posted' : 'no_action' }; }; diff --git a/cli/src/commands/login.js b/cli/src/commands/login.js index 25845e62a..a6514d346 100644 --- a/cli/src/commands/login.js +++ b/cli/src/commands/login.js @@ -7,7 +7,7 @@ import { createInterface } from 'readline'; import { login as apiLogin } from '../lib/api.js'; -import { saveInstance, LOCAL_URL } from '../lib/config.js'; +import { saveInstance } from '../lib/config.js'; const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve)); @@ -88,8 +88,8 @@ export const registerWhoami = (program) => { .command('whoami') .description('Show current auth state') .option('--instance ', 'Target instance') - .action(async (opts) => { - const { getActiveInstance, listInstances } = await import('../lib/config.js'); + .action(async () => { + const { listInstances } = await import('../lib/config.js'); const instances = listInstances(); if (instances.length === 0) { diff --git a/cli/src/lib/enforcement.js b/cli/src/lib/enforcement.js index 6ee272daf..1fcf42d79 100644 --- a/cli/src/lib/enforcement.js +++ b/cli/src/lib/enforcement.js @@ -540,16 +540,35 @@ export const splitForChat = (text, { limit = 400 } = {}) => { * fits in one message → post as-is * splits into ≤ maxChunks → post the chunks in order ("two short * messages beat one wall") - * longer than a split answer → it is a document, not a message: upload - * the FULL text as a file and post one - * message — the reply's own opening plus - * the file card. Nothing is cut; the file - * holds everything. + * longer than a split answer → post the FIRST chunk to the channel and + * continue the rest in a thread under it. + * one indivisible oversize unit → it is a genuine document: upload the FULL + * text and post one message — the reply's + * own opening plus the file card. * - * If the upload fails (older server, network), fall back to posting every - * chunk: a message flood is a tone violation, silence or truncation is a - * correctness violation, and the contract itself ranks content above tone - * ("NEVER hit that by cutting content"). + * The thread rung is new (Sam 57691) and it replaces attachment as the answer + * for PROSE overflow. Before threads existed, a long analysis had nowhere to + * go but a file, and that was the right workaround. It is now the wrong one: + * an attachment is un-quotable, un-followable, and an all-or-nothing read, + * so overflowing into one buries the tail of a reply in a surface nobody can + * respond to. A thread keeps every word addressable and scopes the read. + * + * This is also the layer the rule has to live at. The pod-context cue already + * tells agents "prose overflow goes in a thread, not an attachment" (#1176), + * and the cue could not have been obeyed: the model does not choose the + * delivery mode, THIS FUNCTION does, and it only knew how to attach. A cue + * that promises what the wrapper contradicts teaches the agent it is failing + * at something it never controlled. + * + * Attachment is kept for the case it was always right for — a single atomic + * unit over `attachThreshold` (a long fence, an unbreakable run). That is a + * document by construction, not prose that outgrew a message. + * + * If threading fails (older server with no threadRootId support, no id in the + * response), fall back to attach, then to posting every chunk: a message + * flood is a tone violation, silence or truncation is a correctness + * violation, and the contract ranks content above tone ("NEVER hit that by + * cutting content"). */ export const deliverChatReply = async ({ client, @@ -563,6 +582,24 @@ export const deliverChatReply = async ({ }) => { const messagesPath = `/api/agents/runtime/pods/${podId}/messages`; const chunks = splitForChat(text, { limit }); + // The runtime route uses HTTP 200 for a policy refusal: it is a completed + // request, but no message was created. Keep that distinction at the client + // boundary so every delivery mode shares it rather than treating a resolved + // promise as proof of a post. + const postMessage = (body) => client.post(messagesPath, body); + const refused = (response, messages, attemptedMessages) => ({ + mode: 'refused', + messages, + attemptedMessages, + refused: true, + reason: response.reason || 'message_refused', + ...(typeof response.guidance === 'string' && response.guidance + ? { guidance: response.guidance } + : {}), + ...(typeof response.consecutive === 'number' + ? { consecutive: response.consecutive } + : {}), + }); // An atomic unit (a fenced block, an unbreakable word-run) can exceed the // limit by construction — splitForChat keeps it whole rather than breaking // its rendering. The tone contract's own rule covers it: over ~800 chars of @@ -571,15 +608,71 @@ export const deliverChatReply = async ({ // the gate (found by the fleet's implementation audit, Sharpen msg 53018). const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold); if (chunks.length <= 1 && !hasIndivisibleOversize) { - await client.post(messagesPath, { content: chunks[0] ?? text }); + const response = await postMessage({ content: chunks[0] ?? text }); + if (response?.refused === true) return refused(response, 0, 1); return { mode: 'single', messages: 1 }; } if (chunks.length <= maxChunks && !hasIndivisibleOversize) { + let messages = 0; for (const chunk of chunks) { // eslint-disable-next-line no-await-in-loop - await client.post(messagesPath, { content: chunk }); // in order, so the reply reads top-down + const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down + if (response?.refused === true) return refused(response, messages, chunks.length); + messages += 1; + } + return { mode: 'split', messages }; + } + // PROSE OVERFLOW → THREAD. Only when nothing is indivisibly oversize: a + // fence too big to split is a document and belongs in the attach rung below. + if (!hasIndivisibleOversize) { + // A COUNT, not a flag. The recovery below resumes from here, and a boolean + // can only distinguish "nothing posted" from "something posted" — it cannot + // say how much. Fail a continuation at chunk 3 with a boolean and chunks 1 + // and 2 are already in the thread, then get posted again top-level; the + // reader sees them twice. Getting this wrong is silent, which is also why + // the attach rung below leads with `chunks[0]`: falling through after a + // successful headline duplicates the opening line. Two of this suite's + // existing tests caught that one, and none caught this one, because both + // fail at the root-id step before any continuation has posted. + let posted = 0; + try { + const rootRes = await postMessage({ content: chunks[0] }); + if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length); + posted = 1; + // The runtime route answers `res.json(result)` with the created row on + // `result.message`. Accept either id field; refuse to guess if neither + // is present, because a continuation posted with a missing root would + // silently become another top-level message — the exact flood this rung + // exists to prevent. + const rootId = rootRes?.message?.id ?? rootRes?.message?._id ?? rootRes?.id ?? rootRes?._id; + if (!rootId) throw new Error('no message id in post response — cannot root the thread'); + for (const chunk of chunks.slice(1)) { + // eslint-disable-next-line no-await-in-loop + const response = await postMessage({ content: chunk, threadRootId: String(rootId) }); + if (response?.refused === true) return refused(response, posted, chunks.length); + posted += 1; + } + return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) }; + } catch (err) { + if (posted > 0) { + // The opening is already in the room. Post the REMAINDER top-level — + // never the whole text again. This is the old flood, minus the + // duplicate, and it is still preferable to attaching: content ranks + // above tone, and the reader would otherwise see the same paragraph + // twice with the rest hidden in a file. + log(`thread continuation failed (${err.message}) — posting the remainder top-level`); + for (const chunk of chunks.slice(posted)) { + // eslint-disable-next-line no-await-in-loop + const response = await postMessage({ content: chunk }); + if (response?.refused === true) return refused(response, posted, chunks.length); + posted += 1; + } + return { mode: 'thread-fallback', messages: chunks.length }; + } + // Nothing reached the room, so the attach rung below is free to lead + // with the opening as it always did. + log(`thread headline failed (${err.message}) — falling back to attach`); } - return { mode: 'split', messages: chunks.length }; } try { const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, { @@ -596,14 +689,18 @@ export const deliverChatReply = async ({ const lead = chunks[0] && chunks[0].length <= limit ? chunks[0] : '(reply too large for chat — attached in full)'; - await client.post(messagesPath, { content: `${lead}\n\n${directive}` }); + const response = await postMessage({ content: `${lead}\n\n${directive}` }); + if (response?.refused === true) return refused(response, 0, 1); return { mode: 'attach', messages: 1 }; } catch (err) { log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`); + let messages = 0; for (const chunk of chunks) { // eslint-disable-next-line no-await-in-loop - await client.post(messagesPath, { content: chunk }); + const response = await postMessage({ content: chunk }); + if (response?.refused === true) return refused(response, messages, chunks.length); + messages += 1; } - return { mode: 'split-fallback', messages: chunks.length }; + return { mode: 'split-fallback', messages }; } }; diff --git a/cli/src/lib/environment.js b/cli/src/lib/environment.js index fb877cbee..a2e4ca8f5 100644 --- a/cli/src/lib/environment.js +++ b/cli/src/lib/environment.js @@ -18,7 +18,7 @@ import { readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile, } from 'fs/promises'; import { existsSync } from 'fs'; -import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path'; +import { isAbsolute, join, resolve as pathResolve, basename } from 'path'; import { homedir } from 'os'; // ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ────────── diff --git a/package.json b/package.json index a8372636d..4dc2bd6d3 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,14 @@ "start": "docker compose up", "dev:backend": "cd backend && npm run dev", "dev:frontend": "cd frontend && npm start", - "lint": "npm run lint:backend && npm run lint:frontend", + "lint": "npm run lint:cli && npm run lint:backend && npm run lint:frontend", "lint:backend": "cd backend && npm run lint", "lint:frontend": "cd frontend && npm run lint", - "lint:fix": "npm run lint:fix:backend && npm run lint:fix:frontend", + "lint:cli": "cd cli && npm run lint", + "lint:fix": "npm run lint:fix:cli && npm run lint:fix:backend && npm run lint:fix:frontend", "lint:fix:backend": "cd backend && npm run lint:fix", "lint:fix:frontend": "cd frontend && npm run lint:fix", + "lint:fix:cli": "cd cli && npm run lint:fix", "verify:moltbot-tools": "node scripts/verify-moltbot-tool-contract.js", "verify:litellm-patch-runner": "node scripts/verify-litellm-patch-runner.js", "prepare": "husky" @@ -30,6 +32,9 @@ ], "frontend/src/**/*.{js,jsx}": [ "./frontend/node_modules/.bin/eslint --fix" + ], + "cli/src/**/*.js": [ + "./cli/node_modules/.bin/eslint --fix" ] } }