@hachej/boring-agent 0.4.7 → 0.4.9
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/approval.d.ts +15 -0
- package/dist/approval.js +15 -2
- package/dist/chat-transport.d.ts +10 -1
- package/dist/chat-transport.js +21 -3
- package/dist/conversations.js +1 -1
- package/dist/delegate.d.ts +2 -1
- package/dist/delegate.js +18 -2
- package/dist/mentions.js +1 -1
- package/dist/prompt-context.js +3 -1
- package/dist/sandbox.d.ts +0 -1
- package/dist/sandbox.js +4 -2
- package/package.json +3 -3
package/dist/approval.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface ApprovalExtensionOptions {
|
|
|
56
56
|
readonly name?: string;
|
|
57
57
|
/** Tool names to gate, with their rule. Any native tool works, including Pi's stock `bash`, `write` and `edit`. */
|
|
58
58
|
readonly tools: Readonly<Record<string, ApprovalRule>>;
|
|
59
|
+
/**
|
|
60
|
+
* What happens to a tool call whose tool is NOT named in `tools`. `'allow'` (default) runs it ungated. `'deny'` blocks it before it runs
|
|
61
|
+
* (the model is told it was not run), except the tools listed in `allow`. Approval is not asked for unlisted tools: a native hook can
|
|
62
|
+
* block a call but cannot ask the person, so name a tool in `tools` to have it asked.
|
|
63
|
+
*/
|
|
64
|
+
readonly unlisted?: 'allow' | 'deny';
|
|
65
|
+
/** With `unlisted: 'deny'`: tool names that still run without approval (for example the read tools). Ignored otherwise. */
|
|
66
|
+
readonly allow?: readonly string[];
|
|
59
67
|
}
|
|
60
68
|
/**
|
|
61
69
|
* The approval gate as an opt-in native extension. It brings no tools: it wraps the named tools (native `wraps`, Pi's own
|
|
@@ -64,6 +72,13 @@ export interface ApprovalExtensionOptions {
|
|
|
64
72
|
* `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
|
|
65
73
|
* Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
|
|
66
74
|
*
|
|
75
|
+
* Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
|
|
76
|
+
* `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
|
|
77
|
+
* `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
|
|
78
|
+
* what `tools` names.
|
|
79
|
+
*
|
|
80
|
+
* A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
|
|
81
|
+
*
|
|
67
82
|
* It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
|
|
68
83
|
* not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
|
|
69
84
|
*/
|
package/dist/approval.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineExtension } from '@earendil-works/pi-durable';
|
|
1
|
+
import { ToolTask, defineExtension, hook } from '@earendil-works/pi-durable';
|
|
2
2
|
import { askPerson } from './ask-user.js';
|
|
3
3
|
/** The two answers an approval question accepts. A chat answers with exactly these strings through `answerUserQuestion`. */
|
|
4
4
|
export const APPROVE = 'Approve';
|
|
@@ -13,6 +13,8 @@ export const CANCELLED_PREFIX = 'Not run: the approval question was cancelled.';
|
|
|
13
13
|
* without reading the question document. The card in `registry/pi-chat` repeats this key (a contract test keeps them equal).
|
|
14
14
|
*/
|
|
15
15
|
export const APPROVAL_DETAILS = 'boring.approval';
|
|
16
|
+
const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
|
|
17
|
+
const markGuard = (extension) => { GUARDS.add(extension); return extension; };
|
|
16
18
|
const MEMO = 'boring.approval.started';
|
|
17
19
|
const GATED = new WeakSet();
|
|
18
20
|
const text = (value, isError) => ({ isError, output: [{ type: 'text', text: value }] });
|
|
@@ -112,6 +114,13 @@ const defaultSummary = (name) => (args) => `${name} ${JSON.stringify(args)}`.sli
|
|
|
112
114
|
* `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
|
|
113
115
|
* Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
|
|
114
116
|
*
|
|
117
|
+
* Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
|
|
118
|
+
* `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
|
|
119
|
+
* `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
|
|
120
|
+
* what `tools` names.
|
|
121
|
+
*
|
|
122
|
+
* A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
|
|
123
|
+
*
|
|
115
124
|
* It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
|
|
116
125
|
* not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
|
|
117
126
|
*/
|
|
@@ -123,5 +132,9 @@ export function createApprovalExtension(options) {
|
|
|
123
132
|
...(rule !== true && rule.when ? { when: rule.when } : {}),
|
|
124
133
|
}),
|
|
125
134
|
}));
|
|
126
|
-
|
|
135
|
+
const allowed = new Set([...Object.keys(options.tools), ...options.allow ?? []]);
|
|
136
|
+
const hooks = options.unlisted === 'deny'
|
|
137
|
+
? [hook(ToolTask, { beforeTool: call => allowed.has(call.name) ? undefined : { block: `Not run: ${call.name} is not allowed in this conversation.` } })]
|
|
138
|
+
: [];
|
|
139
|
+
return markGuard(defineExtension({ name: options.name ?? 'boring.approval', wraps, hooks }));
|
|
127
140
|
}
|
package/dist/chat-transport.d.ts
CHANGED
|
@@ -55,7 +55,10 @@ export interface ChatTransportAccess {
|
|
|
55
55
|
* it in `createPromptContext({ context })`; this handler keeps nothing. Without it the `context` field is ignored.
|
|
56
56
|
*/
|
|
57
57
|
readonly requestContext?: (value: Readonly<Record<string, unknown>>) => void | Promise<void>;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Refuse an effect call. Reading stays governed by `authenticate` and `project`. Without it EVERY effect (submit, abort, answer,
|
|
60
|
+
* withdraw, configure) is allowed to whoever `authenticate` accepted: a host with read-only viewers must pass it.
|
|
61
|
+
*/
|
|
59
62
|
readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
|
|
60
63
|
}
|
|
61
64
|
export interface ChatTransportOptions {
|
|
@@ -123,6 +126,12 @@ export interface ConversationSocketOptions {
|
|
|
123
126
|
* hibernation (the original request is gone by then). The same access the HTTP handler gets from `authenticate`. Null closes the socket.
|
|
124
127
|
*/
|
|
125
128
|
readonly access: (attachment: ConversationSocketAttachment) => Promise<ChatTransportAccess | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Origins (`https://app.example`) besides the request's own host that may open the socket. A browser upgrade whose `Origin` is
|
|
131
|
+
* neither the request's host nor listed here is refused (a cross-site page would otherwise ride the visitor's cookies); a request with
|
|
132
|
+
* no `Origin` (not a browser) is not subject to this check, so `authenticate` still decides who it is.
|
|
133
|
+
*/
|
|
134
|
+
readonly allowedOrigins?: readonly string[];
|
|
126
135
|
}
|
|
127
136
|
/** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
|
|
128
137
|
export declare const CHAT_SOCKET_PROTOCOL = "boring.chat";
|
package/dist/chat-transport.js
CHANGED
|
@@ -241,6 +241,8 @@ export function createChatTransportHandler(options) {
|
|
|
241
241
|
|| (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string')
|
|
242
242
|
|| (access.requestContext && !contextValue(input['context'])))
|
|
243
243
|
return failure(400, 'invalid-request');
|
|
244
|
+
if (!await allowed('submit'))
|
|
245
|
+
return failure(403, 'not-authorized'); // before `prepareInput`: a denied submitter reads nothing
|
|
244
246
|
let prepared = input['content'];
|
|
245
247
|
if (access.prepareInput) {
|
|
246
248
|
try {
|
|
@@ -248,7 +250,7 @@ export function createChatTransportHandler(options) {
|
|
|
248
250
|
}
|
|
249
251
|
catch { /* submit what the person typed */ }
|
|
250
252
|
}
|
|
251
|
-
if (
|
|
253
|
+
if (access.revoked?.aborted)
|
|
252
254
|
return failure(403, 'not-authorized');
|
|
253
255
|
if (access.requestContext && input['context'] !== undefined)
|
|
254
256
|
await access.requestContext(input['context']);
|
|
@@ -311,7 +313,7 @@ export function createChatTransportHandler(options) {
|
|
|
311
313
|
if (error instanceof Error && error.code === 'submission-refused') {
|
|
312
314
|
return Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason: 'submission-refused', message: error.message.slice(0, 500) }, { status: 402, headers });
|
|
313
315
|
}
|
|
314
|
-
return failure(503,
|
|
316
|
+
return failure(503, 'source-unavailable'); // the detail stays on the host: a message can carry paths, hosts or credentials
|
|
315
317
|
}
|
|
316
318
|
return failure(404, 'unknown-operation');
|
|
317
319
|
};
|
|
@@ -342,6 +344,20 @@ export function routeSubmissions(conversation, admit) {
|
|
|
342
344
|
}
|
|
343
345
|
/** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
|
|
344
346
|
export const CHAT_SOCKET_PROTOCOL = 'boring.chat';
|
|
347
|
+
/** Whether a WebSocket upgrade comes from the page's own host or a listed origin. No `Origin` header: not a browser, so not subject. */
|
|
348
|
+
function sameOrigin(request, allowed = []) {
|
|
349
|
+
const origin = request.headers.get('origin');
|
|
350
|
+
if (origin === null)
|
|
351
|
+
return true;
|
|
352
|
+
if (allowed.includes(origin))
|
|
353
|
+
return true;
|
|
354
|
+
try {
|
|
355
|
+
return new URL(origin).host === (request.headers.get('host') ?? new URL(request.url).host);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
345
361
|
const ownerTag = (owner) => `owner:${owner}`;
|
|
346
362
|
const conversationTag = (id) => `conversation:${id}`;
|
|
347
363
|
const tagFor = (to) => to?.conversationId !== undefined ? conversationTag(to.conversationId) : to?.owner !== undefined ? ownerTag(to.owner) : undefined;
|
|
@@ -426,6 +442,8 @@ export function createConversationSocketHandler(options) {
|
|
|
426
442
|
}
|
|
427
443
|
async function resume() { await Promise.all(state.getWebSockets().map(socket => attach(socket))); }
|
|
428
444
|
async function accept(socket, request) {
|
|
445
|
+
if (!sameOrigin(request, options.allowedOrigins))
|
|
446
|
+
return false;
|
|
429
447
|
const found = await options.authenticate(request);
|
|
430
448
|
if (!found)
|
|
431
449
|
return false;
|
|
@@ -475,7 +493,7 @@ export function createConversationSocketHandler(options) {
|
|
|
475
493
|
return failure(503, 'authentication-unavailable');
|
|
476
494
|
}
|
|
477
495
|
if (!accepted)
|
|
478
|
-
return failure(401, 'authentication-required');
|
|
496
|
+
return failure(sameOrigin(request, options.allowedOrigins) ? 401 : 403, sameOrigin(request, options.allowedOrigins) ? 'authentication-required' : 'origin-not-allowed');
|
|
479
497
|
const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map(value => value.trim());
|
|
480
498
|
return new Response(null, { status: 101, webSocket: pair[0], ...(offered.includes(CHAT_SOCKET_PROTOCOL) ? { headers: { 'sec-websocket-protocol': CHAT_SOCKET_PROTOCOL } } : {}) });
|
|
481
499
|
},
|
package/dist/conversations.js
CHANGED
|
@@ -359,7 +359,7 @@ export function createConversationsHandler(options) {
|
|
|
359
359
|
return failure(error.status, error.status === 413 ? 'request-too-large' : 'invalid-request');
|
|
360
360
|
if (error instanceof TypeError)
|
|
361
361
|
return failure(400, 'invalid-request');
|
|
362
|
-
return failure(503,
|
|
362
|
+
return failure(503, 'source-unavailable');
|
|
363
363
|
}
|
|
364
364
|
return failure(404, 'unknown-operation');
|
|
365
365
|
};
|
package/dist/delegate.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export interface DelegateOptions {
|
|
|
18
18
|
* calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
|
|
19
19
|
* once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
|
|
20
20
|
* ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
|
|
21
|
-
* child's final text
|
|
21
|
+
* child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
|
|
22
|
+
* sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
|
|
22
23
|
*/
|
|
23
24
|
export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
|
|
24
25
|
//# sourceMappingURL=delegate.d.ts.map
|
package/dist/delegate.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { AssistantEntry, configure, defineTool } from '@earendil-works/pi-durable';
|
|
2
2
|
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
+
const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
|
|
4
|
+
const isGuard = (extension) => GUARDS.has(extension);
|
|
3
5
|
export const DELEGATE_TOOL = 'delegate';
|
|
4
6
|
/**
|
|
5
7
|
* Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
|
|
6
8
|
* calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
|
|
7
9
|
* once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
|
|
8
10
|
* ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
|
|
9
|
-
* child's final text
|
|
11
|
+
* child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
|
|
12
|
+
* sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
|
|
10
13
|
*/
|
|
11
14
|
export function createDelegateTool(options) {
|
|
12
15
|
const name = options.name ?? DELEGATE_TOOL;
|
|
@@ -19,12 +22,14 @@ export function createDelegateTool(options) {
|
|
|
19
22
|
parameters: Type.Object({ task: Type.String({ minLength: 1, description: 'The complete task; the agent sees nothing else of this conversation.' }) }, { additionalProperties: false }),
|
|
20
23
|
replay: 'safe',
|
|
21
24
|
execute: async (args, api, context) => {
|
|
25
|
+
const inherited = (await api.agent(context)).extensions.filter(isGuard);
|
|
26
|
+
const agent = withGuards(child, inherited);
|
|
22
27
|
const childId = await api.commit(async (tx) => {
|
|
23
28
|
const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
|
|
24
29
|
if (existing !== undefined)
|
|
25
30
|
return existing.id;
|
|
26
31
|
const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
|
|
27
|
-
await configure(tx, created.id,
|
|
32
|
+
await configure(tx, created.id, agent);
|
|
28
33
|
return created.id;
|
|
29
34
|
}, context);
|
|
30
35
|
await api.details({ conversationId: childId }, context);
|
|
@@ -42,3 +47,14 @@ export function createDelegateTool(options) {
|
|
|
42
47
|
},
|
|
43
48
|
});
|
|
44
49
|
}
|
|
50
|
+
/** `change` with the parent's guard extensions appended to its extension selection (an array or `add`; an unset selection already inherits the parent's). */
|
|
51
|
+
function withGuards(change, guards) {
|
|
52
|
+
const { extensions } = change;
|
|
53
|
+
if (!guards.length || extensions === undefined || extensions === null)
|
|
54
|
+
return change;
|
|
55
|
+
if (Array.isArray(extensions))
|
|
56
|
+
return { ...change, extensions: [...extensions, ...guards.filter(guard => !extensions.some(own => own.name === guard.name))] };
|
|
57
|
+
const selection = extensions;
|
|
58
|
+
const present = selection.add ?? [];
|
|
59
|
+
return { ...change, extensions: { ...selection, add: [...present, ...guards.filter(guard => !present.some(own => own.name === guard.name))] } };
|
|
60
|
+
}
|
package/dist/mentions.js
CHANGED
|
@@ -113,7 +113,7 @@ export function createMentionResolver(options) {
|
|
|
113
113
|
}
|
|
114
114
|
const cut = bytes.length > room;
|
|
115
115
|
used += Math.min(bytes.length, room);
|
|
116
|
-
added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(
|
|
116
|
+
added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<(\s*\/?\s*file)\b/gi, '<\\$1')}\n</file>` });
|
|
117
117
|
}
|
|
118
118
|
return [...parts, ...added];
|
|
119
119
|
}
|
package/dist/prompt-context.js
CHANGED
|
@@ -14,7 +14,9 @@ const produced = new WeakSet();
|
|
|
14
14
|
* assistant continuation) at the tail. Pure: the input array is not changed.
|
|
15
15
|
*/
|
|
16
16
|
export function withRequestContext(messages, text, tag = 'request-context') {
|
|
17
|
-
|
|
17
|
+
// The text may be browser-supplied: neutralise every opening or closing `tag` in it (any case, spaces allowed) so it cannot end the wrapper.
|
|
18
|
+
const inner = text.replace(new RegExp(`<(\\s*/?\\s*${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![\\w-])`, 'gi'), '<\\$1');
|
|
19
|
+
const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${inner}\n</${tag}>` }], timestamp: Date.now() };
|
|
18
20
|
produced.add(message);
|
|
19
21
|
let end = messages.length;
|
|
20
22
|
while (end > 0 && produced.has(messages[end - 1]))
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Extension } from '@earendil-works/pi-durable';
|
|
2
2
|
import type { JsonValue } from '@earendil-works/chord';
|
|
3
|
-
/** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
|
|
4
3
|
export declare const SANDBOX_DETAILS = "boring.sandbox";
|
|
5
4
|
/** What the host declares about one tool. An unset field is not declared: a call is recorded unless the host declares it safe. */
|
|
6
5
|
export interface SandboxFlags {
|
package/dist/sandbox.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineExtension, hook, ToolTask } from '@earendil-works/pi-durable';
|
|
2
2
|
/** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
|
|
3
|
+
const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
|
|
4
|
+
const markGuard = (extension) => { GUARDS.add(extension); return extension; };
|
|
3
5
|
export const SANDBOX_DETAILS = 'boring.sandbox';
|
|
4
6
|
/** The acknowledgement a recorded call returns when the host does not supply its own. */
|
|
5
7
|
export const defaultAck = (toolName) => `Simulated: ${toolName} was recorded and not run. Nothing was changed.`;
|
|
@@ -45,13 +47,13 @@ export function createSandboxExtension(options) {
|
|
|
45
47
|
const recordOnly = new Set(options.tools.filter(name => sandboxDecision(options.flags?.[name]) === 'record'));
|
|
46
48
|
const simulate = options.simulate ?? ((toolName) => defaultAck(toolName));
|
|
47
49
|
const wraps = [...recordOnly].map(name => ({ tool: name, wrap: (tool) => recorded(tool, simulate) }));
|
|
48
|
-
return defineExtension({
|
|
50
|
+
return markGuard(defineExtension({
|
|
49
51
|
name: options.name ?? 'boring.sandbox',
|
|
50
52
|
wraps,
|
|
51
53
|
hooks: [hook(ToolTask, {
|
|
52
54
|
beforeTool: call => covered.has(call.name) ? undefined : { block: `Not run: ${call.name} is not covered by the sandbox.` },
|
|
53
55
|
})],
|
|
54
|
-
});
|
|
56
|
+
}));
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
57
59
|
* Setup-time check over the public `Extension.wraps` shape. Throws when an extension selected after `sandbox` wraps a tool the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -156,8 +156,8 @@
|
|
|
156
156
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
157
157
|
"@earendil-works/pi-ai": "~1.2.0",
|
|
158
158
|
"@earendil-works/chord": "~1.2.0",
|
|
159
|
-
"@hachej/boring-files": "^0.4.
|
|
160
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
159
|
+
"@hachej/boring-files": "^0.4.9",
|
|
160
|
+
"@hachej/boring-ui-kit": "^0.4.9",
|
|
161
161
|
"xlsx": "^0.20.3"
|
|
162
162
|
},
|
|
163
163
|
"peerDependenciesMeta": {
|