typeclaw 0.1.0
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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import { send, sendHttp } from '@/hostd/client'
|
|
5
|
+
import { containerSocketPath } from '@/hostd/paths'
|
|
6
|
+
import type { Stream } from '@/stream'
|
|
7
|
+
|
|
8
|
+
const ACK_TIMEOUT_MS = 5_000
|
|
9
|
+
const EXIT_DELAY_MS = 500
|
|
10
|
+
|
|
11
|
+
export type CreateRestartToolOptions = {
|
|
12
|
+
containerName: string
|
|
13
|
+
exit?: (code: number) => void
|
|
14
|
+
socketPath?: string
|
|
15
|
+
hostdUrl?: string
|
|
16
|
+
hostdToken?: string
|
|
17
|
+
// Optional so unit tests and ad-hoc tool construction keep working without
|
|
18
|
+
// building a Stream. In production wiring, every live AgentSession's
|
|
19
|
+
// broadcast subscriber turns this signal into a transcript entry.
|
|
20
|
+
stream?: Stream
|
|
21
|
+
// Identifies the session whose `restart` tool execution fired the broadcast.
|
|
22
|
+
// Subscribers compare against their own SessionManager.getSessionId() to
|
|
23
|
+
// pick the right notice variant (proactive-confirmation for the originator,
|
|
24
|
+
// do-not-acknowledge for siblings). Required when stream is set; without it
|
|
25
|
+
// every session would get the sibling notice and the originator would never
|
|
26
|
+
// confirm restart completion proactively — the exact bug this dispatch
|
|
27
|
+
// fixes. Required even when stream is absent so the type stays simple and
|
|
28
|
+
// the field's presence documents the runtime contract.
|
|
29
|
+
originatingSessionId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
33
|
+
|
|
34
|
+
export type ContainerRestartingBroadcast = {
|
|
35
|
+
kind: 'container-restarting'
|
|
36
|
+
restartedAt: string
|
|
37
|
+
originatingSessionId: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRestartTool({
|
|
41
|
+
containerName,
|
|
42
|
+
exit,
|
|
43
|
+
socketPath,
|
|
44
|
+
hostdUrl,
|
|
45
|
+
hostdToken,
|
|
46
|
+
stream,
|
|
47
|
+
originatingSessionId,
|
|
48
|
+
}: CreateRestartToolOptions) {
|
|
49
|
+
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
50
|
+
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
51
|
+
const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
|
|
52
|
+
|
|
53
|
+
return defineTool({
|
|
54
|
+
name: 'restart',
|
|
55
|
+
label: 'Restart Container',
|
|
56
|
+
description:
|
|
57
|
+
'Restart the typeclaw container this agent is running in. The host daemon ACKs the request, ' +
|
|
58
|
+
'this process exits, and the host daemon then runs `typeclaw stop` followed by `typeclaw start` ' +
|
|
59
|
+
'for the agent folder. Use when on-disk source has changed in a way that `reload` cannot pick up — ' +
|
|
60
|
+
'e.g. the typeclaw CLI itself was updated, the Dockerfile template changed, or a boot-only config ' +
|
|
61
|
+
'field needs to take effect (port, mounts, plugins). The current session is lost; the ' +
|
|
62
|
+
'TUI must reconnect after the new container is up. Pass `build: true` to also rebuild the ' +
|
|
63
|
+
'Docker image (equivalent to `typeclaw restart --build`) — required when a dependency in the ' +
|
|
64
|
+
'Dockerfile template changed but the image already exists, since `start` only rebuilds if the ' +
|
|
65
|
+
'image is missing or `build` is set.',
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
build: Type.Optional(
|
|
68
|
+
Type.Boolean({
|
|
69
|
+
description:
|
|
70
|
+
'When true, rebuild the Docker image (`docker build`) before starting the new container. ' +
|
|
71
|
+
'Default false (reuse the existing image if present). Set this when the Dockerfile template ' +
|
|
72
|
+
'or its inputs have changed since the image was last built.',
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
}),
|
|
76
|
+
async execute(_toolCallId, params) {
|
|
77
|
+
const build = params.build === true
|
|
78
|
+
const request = { kind: 'restart' as const, containerName, build }
|
|
79
|
+
const reply =
|
|
80
|
+
httpUrl && httpToken
|
|
81
|
+
? await sendHttp(request, { timeoutMs: ACK_TIMEOUT_MS, url: httpUrl, token: httpToken })
|
|
82
|
+
: await send(request, { timeoutMs: ACK_TIMEOUT_MS, socket: socketPath ?? containerSocketPath() })
|
|
83
|
+
if (!reply.ok) {
|
|
84
|
+
const details: RestartToolDetails = { ok: false, containerName, reason: reply.reason }
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text' as const, text: `restart denied: ${reply.reason}` }],
|
|
87
|
+
details,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Hostd ACK == restart is committed. Fan out the notice to every live
|
|
92
|
+
// session BEFORE arming the exit timer. Stream broker delivery is
|
|
93
|
+
// synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
|
|
94
|
+
// does a synchronous JSONL write, so the fan-out completes inside this
|
|
95
|
+
// tick — well before the EXIT_DELAY_MS timer fires.
|
|
96
|
+
const broadcast: ContainerRestartingBroadcast = {
|
|
97
|
+
kind: 'container-restarting',
|
|
98
|
+
restartedAt: new Date().toISOString(),
|
|
99
|
+
originatingSessionId,
|
|
100
|
+
}
|
|
101
|
+
stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
102
|
+
|
|
103
|
+
// Schedule the exit on the next tick so the tool result is delivered to
|
|
104
|
+
// the model before the process dies. The host daemon polls for the
|
|
105
|
+
// container's removal before re-running `start`, so a small delay here
|
|
106
|
+
// does not gate the restart end-to-end.
|
|
107
|
+
setTimeout(() => doExit(0), EXIT_DELAY_MS)
|
|
108
|
+
|
|
109
|
+
const details: RestartToolDetails = { ok: true, containerName }
|
|
110
|
+
const buildSuffix = build ? ' (with image rebuild)' : ''
|
|
111
|
+
return {
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: 'text' as const,
|
|
115
|
+
text: `restart${buildSuffix} scheduled for ${containerName}; this process will exit shortly and a new container will be started by the host daemon.`,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
details,
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import { formatLocalDateTime } from '@/shared'
|
|
5
|
+
import type { ScanFilter, Stream, StreamMessage, TargetFilter } from '@/stream'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 50
|
|
8
|
+
const MAX_LIMIT = 100
|
|
9
|
+
const MAX_PAYLOAD_SUMMARY_CHARS = 200
|
|
10
|
+
|
|
11
|
+
const TARGET_KINDS = ['broadcast', 'session', 'new-session', 'cron'] as const
|
|
12
|
+
type TargetKind = (typeof TARGET_KINDS)[number]
|
|
13
|
+
|
|
14
|
+
export type CreateStreamSnapshotToolOptions = {
|
|
15
|
+
stream: Stream
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createStreamSnapshotTool({ stream }: CreateStreamSnapshotToolOptions) {
|
|
19
|
+
return defineTool({
|
|
20
|
+
name: 'stream_snapshot',
|
|
21
|
+
label: 'Stream Snapshot',
|
|
22
|
+
description:
|
|
23
|
+
'Snapshot recent activity on the in-process message stream that connects the WS server, cron scheduler, and any subagents. ' +
|
|
24
|
+
'Useful for: confirming a cron job actually fired, seeing what user prompts arrived recently, observing broadcast notifications, ' +
|
|
25
|
+
'or debugging why something did not happen. Read-only — this tool cannot publish messages. Returns the most recent N events ' +
|
|
26
|
+
'matching the optional filter (target_kind, target_id, since_ms_ago).',
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
target_kind: Type.Optional(
|
|
29
|
+
Type.Union(
|
|
30
|
+
TARGET_KINDS.map((k) => Type.Literal(k)),
|
|
31
|
+
{ description: 'Filter to a specific target kind. Omit to see all targets.' },
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
target_id: Type.Optional(
|
|
35
|
+
Type.String({
|
|
36
|
+
description:
|
|
37
|
+
'Pin to a specific session id (session), subagent name (new-session), or job id (cron). Ignored for broadcast.',
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
since_ms_ago: Type.Optional(
|
|
41
|
+
Type.Integer({
|
|
42
|
+
description: 'Only return events from the last N milliseconds. Defaults to no time filter.',
|
|
43
|
+
minimum: 1,
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
limit: Type.Optional(
|
|
47
|
+
Type.Integer({
|
|
48
|
+
description: `Max number of events to return (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}). Most recent are kept.`,
|
|
49
|
+
minimum: 1,
|
|
50
|
+
maximum: MAX_LIMIT,
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
async execute(_toolCallId, params) {
|
|
56
|
+
const limit = clampLimit(params.limit)
|
|
57
|
+
const filter = buildFilter(params.target_kind, params.target_id, params.since_ms_ago, limit)
|
|
58
|
+
const events = stream.scan(filter)
|
|
59
|
+
return formatResult(events, params)
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clampLimit(value: number | undefined): number {
|
|
65
|
+
if (value === undefined) return DEFAULT_LIMIT
|
|
66
|
+
return Math.min(Math.max(1, Math.floor(value)), MAX_LIMIT)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildFilter(
|
|
70
|
+
kind: TargetKind | undefined,
|
|
71
|
+
id: string | undefined,
|
|
72
|
+
sinceMsAgo: number | undefined,
|
|
73
|
+
limit: number,
|
|
74
|
+
): ScanFilter {
|
|
75
|
+
const filter: ScanFilter = { limit }
|
|
76
|
+
if (kind !== undefined) filter.target = buildTargetFilter(kind, id)
|
|
77
|
+
if (sinceMsAgo !== undefined) filter.sinceTs = Date.now() - sinceMsAgo
|
|
78
|
+
return filter
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildTargetFilter(kind: TargetKind, id: string | undefined): TargetFilter {
|
|
82
|
+
switch (kind) {
|
|
83
|
+
case 'broadcast':
|
|
84
|
+
return { kind }
|
|
85
|
+
case 'session':
|
|
86
|
+
return id !== undefined ? { kind, sessionId: id } : { kind }
|
|
87
|
+
case 'new-session':
|
|
88
|
+
return id !== undefined ? { kind, subagent: id } : { kind }
|
|
89
|
+
case 'cron':
|
|
90
|
+
return id !== undefined ? { kind, jobId: id } : { kind }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatResult(
|
|
95
|
+
events: StreamMessage[],
|
|
96
|
+
params: { target_kind?: TargetKind; target_id?: string; since_ms_ago?: number; limit?: number },
|
|
97
|
+
) {
|
|
98
|
+
const filterDesc = describeFilter(params)
|
|
99
|
+
const details = {
|
|
100
|
+
count: events.length,
|
|
101
|
+
filter: filterDesc,
|
|
102
|
+
events: events.map((e) => ({
|
|
103
|
+
id: e.id,
|
|
104
|
+
ts: e.ts,
|
|
105
|
+
target: e.target,
|
|
106
|
+
payload: e.payload,
|
|
107
|
+
...(e.replyTo !== undefined ? { replyTo: e.replyTo } : {}),
|
|
108
|
+
...(e.meta !== undefined ? { meta: e.meta } : {}),
|
|
109
|
+
})),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (events.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: 'text' as const, text: `No stream events matching ${filterDesc}.` }],
|
|
115
|
+
details,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const header = `${events.length} stream event(s) matching ${filterDesc} (oldest → newest):`
|
|
120
|
+
const lines = [header, '']
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
lines.push(formatEventLine(event))
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: 'text' as const, text: lines.join('\n').trimEnd() }],
|
|
126
|
+
details,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeFilter(params: {
|
|
131
|
+
target_kind?: TargetKind
|
|
132
|
+
target_id?: string
|
|
133
|
+
since_ms_ago?: number
|
|
134
|
+
limit?: number
|
|
135
|
+
}): string {
|
|
136
|
+
const parts: string[] = []
|
|
137
|
+
if (params.target_kind !== undefined) {
|
|
138
|
+
parts.push(
|
|
139
|
+
params.target_id !== undefined
|
|
140
|
+
? `target=${params.target_kind}:${params.target_id}`
|
|
141
|
+
: `target=${params.target_kind}`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
if (params.since_ms_ago !== undefined) parts.push(`since=${params.since_ms_ago}ms`)
|
|
145
|
+
if (params.limit !== undefined) parts.push(`limit=${params.limit}`)
|
|
146
|
+
return parts.length === 0 ? 'all (default limit)' : parts.join(', ')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatEventLine(event: StreamMessage): string {
|
|
150
|
+
const time = formatLocalDateTime(new Date(event.ts))
|
|
151
|
+
const targetLabel = describeTarget(event.target)
|
|
152
|
+
const payloadSummary = summarizePayload(event.payload)
|
|
153
|
+
const replyMarker = event.replyTo !== undefined ? ` [reply→${event.replyTo}]` : ''
|
|
154
|
+
return `${time} ${targetLabel}${replyMarker} ${payloadSummary}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function describeTarget(target: StreamMessage['target']): string {
|
|
158
|
+
switch (target.kind) {
|
|
159
|
+
case 'broadcast':
|
|
160
|
+
return 'broadcast'
|
|
161
|
+
case 'session':
|
|
162
|
+
return `session:${target.sessionId}`
|
|
163
|
+
case 'new-session':
|
|
164
|
+
return `new-session:${target.subagent}`
|
|
165
|
+
case 'cron':
|
|
166
|
+
return `cron:${target.jobId}`
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function summarizePayload(payload: unknown): string {
|
|
171
|
+
let text: string
|
|
172
|
+
try {
|
|
173
|
+
text = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
174
|
+
} catch {
|
|
175
|
+
text = '<unserializable>'
|
|
176
|
+
}
|
|
177
|
+
if (text.length > MAX_PAYLOAD_SUMMARY_CHARS) {
|
|
178
|
+
return `${text.slice(0, MAX_PAYLOAD_SUMMARY_CHARS)}… (${text.length} chars)`
|
|
179
|
+
}
|
|
180
|
+
return text
|
|
181
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { MAX_RESPONSE_BYTES } from './types'
|
|
2
|
+
|
|
3
|
+
export type FetchResult = {
|
|
4
|
+
body: string
|
|
5
|
+
contentType: string
|
|
6
|
+
finalUrl: string
|
|
7
|
+
httpStatus: number
|
|
8
|
+
bytesIn: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class WebfetchError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'WebfetchError'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_HEADERS: Record<string, string> = {
|
|
19
|
+
'User-Agent': 'typeclaw/0 (+https://github.com/code-yeongyu/typeclaw)',
|
|
20
|
+
Accept: 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.1',
|
|
21
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeUrl(input: string): string {
|
|
25
|
+
const trimmed = input.trim()
|
|
26
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
27
|
+
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
|
28
|
+
throw new WebfetchError('URL must use http:// or https://')
|
|
29
|
+
}
|
|
30
|
+
return trimmed
|
|
31
|
+
}
|
|
32
|
+
return `https://${trimmed}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function fetchWithLimits(
|
|
36
|
+
url: string,
|
|
37
|
+
timeoutSeconds: number,
|
|
38
|
+
parentSignal?: AbortSignal,
|
|
39
|
+
): Promise<FetchResult> {
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
const timeout = setTimeout(() => controller.abort(new Error('timeout')), timeoutSeconds * 1000)
|
|
42
|
+
const onAbort = () => controller.abort(parentSignal?.reason)
|
|
43
|
+
parentSignal?.addEventListener('abort', onAbort, { once: true })
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(url, { headers: DEFAULT_HEADERS, signal: controller.signal, redirect: 'follow' })
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new WebfetchError(`Fetch failed: HTTP ${response.status} ${response.statusText}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const contentLengthHeader = response.headers.get('content-length')
|
|
52
|
+
if (contentLengthHeader) {
|
|
53
|
+
const declared = Number(contentLengthHeader)
|
|
54
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
55
|
+
throw new WebfetchError(
|
|
56
|
+
`Response too large (${formatBytes(declared)} exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const buffer = await response.arrayBuffer()
|
|
62
|
+
if (buffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
63
|
+
throw new WebfetchError(
|
|
64
|
+
`Response too large (${formatBytes(buffer.byteLength)} exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = new TextDecoder('utf-8', { fatal: false }).decode(buffer)
|
|
69
|
+
return {
|
|
70
|
+
body,
|
|
71
|
+
contentType: response.headers.get('content-type') ?? '',
|
|
72
|
+
finalUrl: response.url || url,
|
|
73
|
+
httpStatus: response.status,
|
|
74
|
+
bytesIn: buffer.byteLength,
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (
|
|
78
|
+
controller.signal.aborted &&
|
|
79
|
+
controller.signal.reason instanceof Error &&
|
|
80
|
+
controller.signal.reason.message === 'timeout'
|
|
81
|
+
) {
|
|
82
|
+
throw new WebfetchError(`Request timed out after ${timeoutSeconds}s`)
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof WebfetchError) throw error
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
86
|
+
throw new WebfetchError(`Fetch failed: ${message}`)
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timeout)
|
|
89
|
+
parentSignal?.removeEventListener('abort', onAbort)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseMimeType(contentType: string): string {
|
|
94
|
+
const semi = contentType.indexOf(';')
|
|
95
|
+
return (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatBytes(bytes: number): string {
|
|
99
|
+
if (bytes < 1024) return `${bytes}B`
|
|
100
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
101
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { webfetchTool } from './tool'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type GrepOptions = {
|
|
2
|
+
pattern: string
|
|
3
|
+
before?: number
|
|
4
|
+
after?: number
|
|
5
|
+
limit?: number
|
|
6
|
+
offset?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class GrepError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'GrepError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function applyGrep(content: string, options: GrepOptions): string {
|
|
17
|
+
const before = Math.max(0, options.before ?? 0)
|
|
18
|
+
const after = Math.max(0, options.after ?? 0)
|
|
19
|
+
const limit = Math.max(1, options.limit ?? 100)
|
|
20
|
+
const offset = Math.max(0, options.offset ?? 0)
|
|
21
|
+
|
|
22
|
+
const matcher = compile(options.pattern)
|
|
23
|
+
const lines = content.split('\n')
|
|
24
|
+
|
|
25
|
+
const matchingIndices: number[] = []
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
if (matcher.test(lines[i] ?? '')) matchingIndices.push(i)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (matchingIndices.length === 0) {
|
|
31
|
+
return `No matches for pattern: ${options.pattern}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contextIndices = new Set<number>()
|
|
35
|
+
for (const idx of matchingIndices) {
|
|
36
|
+
const start = Math.max(0, idx - before)
|
|
37
|
+
const end = Math.min(lines.length - 1, idx + after)
|
|
38
|
+
for (let i = start; i <= end; i++) contextIndices.add(i)
|
|
39
|
+
}
|
|
40
|
+
const sorted = Array.from(contextIndices).sort((a, b) => a - b)
|
|
41
|
+
const page = sorted.slice(offset, offset + limit)
|
|
42
|
+
|
|
43
|
+
const matching = new Set(matchingIndices)
|
|
44
|
+
const out: string[] = []
|
|
45
|
+
let prev = -2
|
|
46
|
+
for (const idx of page) {
|
|
47
|
+
if (prev !== -2 && idx > prev + 1) out.push('--')
|
|
48
|
+
prev = idx
|
|
49
|
+
const sep = matching.has(idx) ? ':' : '-'
|
|
50
|
+
out.push(`${idx + 1}${sep}${lines[idx] ?? ''}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const totalMatches = matchingIndices.length
|
|
54
|
+
const shown = page.length
|
|
55
|
+
const totalContext = sorted.length
|
|
56
|
+
const header = `Found ${totalMatches} matching line(s); showing ${shown} of ${totalContext} context line(s).`
|
|
57
|
+
return `${header}\n${out.join('\n')}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Always build a fresh, non-global RegExp. The `g` flag carries `lastIndex`
|
|
61
|
+
// state across `.test()` calls, which silently skips matches when reused in a
|
|
62
|
+
// loop (oh-my-openagent PR #195 hit this exact bug).
|
|
63
|
+
function compile(pattern: string): RegExp {
|
|
64
|
+
try {
|
|
65
|
+
return new RegExp(pattern, 'i')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
68
|
+
throw new GrepError(`Invalid regex pattern: ${message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { raw } from 'jq-wasm'
|
|
2
|
+
|
|
3
|
+
export class JqError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'JqError'
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function applyJq(content: string, query: string): Promise<string> {
|
|
11
|
+
let parsed: unknown
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(content)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
16
|
+
throw new JqError(`Response is not valid JSON: ${message}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = await raw(parsed as string | object, query)
|
|
21
|
+
if (result.exitCode !== 0 || result.stderr) {
|
|
22
|
+
const detail = result.stderr.trim() || `exit code ${result.exitCode}`
|
|
23
|
+
throw new JqError(`jq query failed: ${detail}`)
|
|
24
|
+
}
|
|
25
|
+
return result.stdout.replace(/\n+$/, '')
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof JqError) throw error
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
29
|
+
throw new JqError(`jq query failed: ${message}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Readability } from '@mozilla/readability'
|
|
2
|
+
import { JSDOM } from 'jsdom'
|
|
3
|
+
import TurndownService from 'turndown'
|
|
4
|
+
|
|
5
|
+
const turndown = new TurndownService({
|
|
6
|
+
headingStyle: 'atx',
|
|
7
|
+
codeBlockStyle: 'fenced',
|
|
8
|
+
bulletListMarker: '-',
|
|
9
|
+
emDelimiter: '*',
|
|
10
|
+
hr: '---',
|
|
11
|
+
})
|
|
12
|
+
turndown.remove(['script', 'style', 'meta', 'link', 'noscript', 'iframe'])
|
|
13
|
+
|
|
14
|
+
type ReadabilityDocument = ConstructorParameters<typeof Readability>[0]
|
|
15
|
+
|
|
16
|
+
export function applyReadability(html: string, url: string): string {
|
|
17
|
+
const dom = new JSDOM(html, { url })
|
|
18
|
+
const document = dom.window.document.cloneNode(true) as unknown as ReadabilityDocument
|
|
19
|
+
const article = new Readability(document).parse()
|
|
20
|
+
|
|
21
|
+
const source = article?.content?.trim() ? article.content : html
|
|
22
|
+
const markdown = turndown.turndown(source).trim()
|
|
23
|
+
|
|
24
|
+
if (!markdown) return 'Readability extracted no content from this page.'
|
|
25
|
+
|
|
26
|
+
if (article?.title) {
|
|
27
|
+
return `# ${article.title}\n\n${markdown}`
|
|
28
|
+
}
|
|
29
|
+
return markdown
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio'
|
|
2
|
+
|
|
3
|
+
export class SelectorError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'SelectorError'
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function applySelector(html: string, selector: string): string {
|
|
11
|
+
let $: cheerio.CheerioAPI
|
|
12
|
+
try {
|
|
13
|
+
$ = cheerio.load(html)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
16
|
+
throw new SelectorError(`Failed to parse HTML: ${message}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let matches: cheerio.Cheerio<unknown>
|
|
20
|
+
try {
|
|
21
|
+
matches = $(selector) as unknown as cheerio.Cheerio<unknown>
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
24
|
+
throw new SelectorError(`Invalid CSS selector "${selector}": ${message}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (matches.length === 0) {
|
|
28
|
+
return `No elements matched selector: ${selector}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const blocks: string[] = []
|
|
32
|
+
matches.each((index, element) => {
|
|
33
|
+
const text = $(element as Parameters<cheerio.CheerioAPI>[0])
|
|
34
|
+
.text()
|
|
35
|
+
.replace(/\s+/g, ' ')
|
|
36
|
+
.trim()
|
|
37
|
+
blocks.push(`[${index + 1}] ${text}`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return `Matched ${matches.length} element(s) for "${selector}":\n${blocks.join('\n')}`
|
|
41
|
+
}
|