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,53 @@
|
|
|
1
|
+
import { type MembershipResolverResult } from './membership'
|
|
2
|
+
import type { ChannelHistoryMessage, FetchHistoryResult } from './types'
|
|
3
|
+
|
|
4
|
+
// History-derived membership counts only the authors visible in the most
|
|
5
|
+
// recent N history messages — explicitly NOT a full guild/workspace
|
|
6
|
+
// membership snapshot. We mark `truncated: true` so the engagement layer
|
|
7
|
+
// (`resolveEffectiveHumans`) treats it as a quieting hint rather than
|
|
8
|
+
// ground truth and `Math.max`-folds it with persisted speakers.
|
|
9
|
+
//
|
|
10
|
+
// Used as a fallback when the platform's authoritative membership
|
|
11
|
+
// endpoint is unavailable (Discord 403 from missing GUILD_MEMBERS
|
|
12
|
+
// privileged intent, Slack `missing_scope` / `not_in_channel`, or any
|
|
13
|
+
// future adapter that lacks an enumeration capability), and as the
|
|
14
|
+
// preferred answer when the authoritative endpoint says "too many to
|
|
15
|
+
// enumerate" — a 1000-member guild's "5 active speakers" is more useful
|
|
16
|
+
// for engagement than "1000 members minus self".
|
|
17
|
+
//
|
|
18
|
+
// Returns a `transient` failure when history fetch itself fails, so the
|
|
19
|
+
// cache retries soon. Returns a count of zero humans/bots (rather than a
|
|
20
|
+
// failure) when history is empty — a brand-new channel is a legitimate
|
|
21
|
+
// state, not an error.
|
|
22
|
+
export const HISTORY_LOOKBACK_LIMIT = 100
|
|
23
|
+
|
|
24
|
+
export type DeriveMembershipDeps = {
|
|
25
|
+
fetchHistory: (limit: number) => Promise<FetchHistoryResult>
|
|
26
|
+
now: () => number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function deriveMembershipFromHistory(deps: DeriveMembershipDeps): Promise<MembershipResolverResult> {
|
|
30
|
+
const result = await deps.fetchHistory(HISTORY_LOOKBACK_LIMIT)
|
|
31
|
+
if (!result.ok) return { kind: 'transient' }
|
|
32
|
+
return countAuthors(result.messages, deps.now())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function countAuthors(messages: readonly ChannelHistoryMessage[], fetchedAt: number): MembershipResolverResult {
|
|
36
|
+
// Dedupe by author id; once we've classified an author as bot or human
|
|
37
|
+
// (via the adapter's `isBot` flag), keep that classification stable
|
|
38
|
+
// even if a later message disagrees. The first occurrence wins for
|
|
39
|
+
// determinism — Discord's `author.bot` and Slack's `subtype === 'bot_message'`
|
|
40
|
+
// are stable for a given user, so disagreement would indicate a data
|
|
41
|
+
// glitch we don't want to swing the count on.
|
|
42
|
+
const seen = new Map<string, boolean>()
|
|
43
|
+
for (const m of messages) {
|
|
44
|
+
if (!seen.has(m.authorId)) seen.set(m.authorId, m.isBot)
|
|
45
|
+
}
|
|
46
|
+
let humans = 0
|
|
47
|
+
let bots = 0
|
|
48
|
+
for (const isBot of seen.values()) {
|
|
49
|
+
if (isBot) bots++
|
|
50
|
+
else humans++
|
|
51
|
+
}
|
|
52
|
+
return { humans, bots, fetchedAt, truncated: true }
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ChannelKey } from './types'
|
|
2
|
+
|
|
3
|
+
export const MEMBERSHIP_ENUMERATION_CAP = 50
|
|
4
|
+
// Engagement decisions read this every inbound but tolerate moderate
|
|
5
|
+
// staleness — the count rarely changes between turns. The router
|
|
6
|
+
// invalidates the cache when a previously-unseen author posts (see
|
|
7
|
+
// router.ts), so the only practical sources of staleness this TTL
|
|
8
|
+
// governs are: (1) silent leavers we don't notice until next refetch,
|
|
9
|
+
// (2) lurkers with permission overwrites who never speak. Both are
|
|
10
|
+
// quieting hints at most. 30min matches the upper bound of "session
|
|
11
|
+
// idle" so the cache typically expires around the same time the
|
|
12
|
+
// LiveSession would be GC'd anyway.
|
|
13
|
+
export const MEMBERSHIP_CACHE_TTL_MS = 30 * 60 * 1000
|
|
14
|
+
export const MEMBERSHIP_CACHE_PERMANENT_TTL_MS = 5 * 60 * 1000
|
|
15
|
+
export const MEMBERSHIP_CACHE_TRANSIENT_TTL_MS = 30_000
|
|
16
|
+
export const MEMBERSHIP_FRESHNESS_MS = 60_000
|
|
17
|
+
export const MEMBERSHIP_COLD_FETCH_TIMEOUT_MS = 1500
|
|
18
|
+
|
|
19
|
+
export type MembershipCount = {
|
|
20
|
+
humans: number
|
|
21
|
+
bots: number
|
|
22
|
+
fetchedAt: number
|
|
23
|
+
truncated: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MembershipResolverFailure = { kind: 'transient' } | { kind: 'permanent' }
|
|
27
|
+
|
|
28
|
+
export type MembershipResolverResult = MembershipCount | MembershipResolverFailure
|
|
29
|
+
|
|
30
|
+
export type MembershipResolver = (key: ChannelKey) => Promise<MembershipResolverResult>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ChannelParticipant } from '@/agent/session-origin'
|
|
2
|
+
|
|
3
|
+
export const PARTICIPANTS_MAX_PERSISTED = 50
|
|
4
|
+
export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
5
|
+
|
|
6
|
+
export function updateParticipants(
|
|
7
|
+
current: readonly ChannelParticipant[],
|
|
8
|
+
authorId: string,
|
|
9
|
+
authorName: string,
|
|
10
|
+
now: number,
|
|
11
|
+
isBot: boolean = false,
|
|
12
|
+
): ChannelParticipant[] {
|
|
13
|
+
const map = new Map<string, ChannelParticipant>()
|
|
14
|
+
for (const p of current) map.set(p.authorId, p)
|
|
15
|
+
|
|
16
|
+
const existing = map.get(authorId)
|
|
17
|
+
if (existing) {
|
|
18
|
+
// Once an author is flagged as a bot, keep that flag sticky. A peer bot
|
|
19
|
+
// briefly behaving like a human (e.g. an admin posting via the bot's
|
|
20
|
+
// webhook from a DM) shouldn't reset the classification — engagement
|
|
21
|
+
// semantics rely on this being stable across a participant's lifetime.
|
|
22
|
+
map.set(authorId, {
|
|
23
|
+
...existing,
|
|
24
|
+
authorName,
|
|
25
|
+
lastMessageAt: now,
|
|
26
|
+
messageCount: existing.messageCount + 1,
|
|
27
|
+
isBot: existing.isBot === true || isBot,
|
|
28
|
+
})
|
|
29
|
+
} else {
|
|
30
|
+
map.set(authorId, {
|
|
31
|
+
authorId,
|
|
32
|
+
authorName,
|
|
33
|
+
firstMessageAt: now,
|
|
34
|
+
lastMessageAt: now,
|
|
35
|
+
messageCount: 1,
|
|
36
|
+
isBot,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cutoff = now - PARTICIPANTS_MAX_AGE_MS
|
|
41
|
+
const fresh = Array.from(map.values()).filter((p) => p.lastMessageAt >= cutoff)
|
|
42
|
+
|
|
43
|
+
if (fresh.length <= PARTICIPANTS_MAX_PERSISTED) return fresh
|
|
44
|
+
|
|
45
|
+
fresh.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
|
|
46
|
+
return fresh.slice(0, PARTICIPANTS_MAX_PERSISTED)
|
|
47
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ChannelParticipant } from '@/agent/session-origin'
|
|
5
|
+
|
|
6
|
+
import type { AdapterId } from './schema'
|
|
7
|
+
import type { ChannelKey } from './types'
|
|
8
|
+
|
|
9
|
+
const FILE_VERSION = 3
|
|
10
|
+
|
|
11
|
+
// `sessionFile` is the basename (not the full path) of the JSONL transcript
|
|
12
|
+
// for this (adapter, workspace, chat, thread) tuple. pi-coding-agent writes
|
|
13
|
+
// session files as `${ISO_TIMESTAMP}_${UUID}.jsonl`, where the UUID matches
|
|
14
|
+
// `sessionId` but the timestamp prefix only exists at write time. Without
|
|
15
|
+
// the basename persisted, reopen attempts can only guess the path from the
|
|
16
|
+
// UUID, which never matches on disk — every restart silently creates a
|
|
17
|
+
// fresh session and the channel loses its transcript memory.
|
|
18
|
+
//
|
|
19
|
+
// `sessionFile` is optional because v2 records (pre-fix) only carried the
|
|
20
|
+
// UUID. Those are migrated in-place at load time by globbing the sessions
|
|
21
|
+
// directory for `*_${sessionId}.jsonl`; if no match is found the file is
|
|
22
|
+
// considered lost and reopen will fall back to a fresh session.
|
|
23
|
+
export type ChannelSessionRecord = {
|
|
24
|
+
adapter: AdapterId
|
|
25
|
+
workspace: string
|
|
26
|
+
chat: string
|
|
27
|
+
thread: string | null
|
|
28
|
+
sessionId: string
|
|
29
|
+
sessionFile?: string
|
|
30
|
+
participants: ChannelParticipant[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type FileV3 = {
|
|
34
|
+
version: 3
|
|
35
|
+
sessions: ChannelSessionRecord[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type FileV2 = {
|
|
39
|
+
version: 2
|
|
40
|
+
sessions: Array<Omit<ChannelSessionRecord, 'sessionFile'>>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ChannelSessionsLogger = {
|
|
44
|
+
warn: (msg: string) => void
|
|
45
|
+
error: (msg: string) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const consoleLogger: ChannelSessionsLogger = {
|
|
49
|
+
warn: (m) => console.warn(m),
|
|
50
|
+
error: (m) => console.error(m),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function channelsSessionsPath(agentDir: string): string {
|
|
54
|
+
return join(agentDir, 'channels', 'sessions.json')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionsDirOf(agentDir: string): string {
|
|
58
|
+
return join(agentDir, 'sessions')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadChannelSessions(
|
|
62
|
+
agentDir: string,
|
|
63
|
+
logger: ChannelSessionsLogger = consoleLogger,
|
|
64
|
+
): Promise<ChannelSessionRecord[]> {
|
|
65
|
+
const path = channelsSessionsPath(agentDir)
|
|
66
|
+
let raw: string
|
|
67
|
+
try {
|
|
68
|
+
raw = await readFile(path, 'utf8')
|
|
69
|
+
} catch {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
let parsed: unknown
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logger.error(`[channels] ${path} corrupted: ${describe(err)}; starting fresh`)
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
if (!isObject(parsed)) {
|
|
80
|
+
logger.warn(`[channels] ${path} not an object; ignored`)
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
const version = (parsed as { version?: unknown }).version
|
|
84
|
+
if (version === FILE_VERSION) {
|
|
85
|
+
const file = parsed as FileV3
|
|
86
|
+
if (!Array.isArray(file.sessions)) return []
|
|
87
|
+
return file.sessions.filter(isValidRecord)
|
|
88
|
+
}
|
|
89
|
+
if (version === 2) {
|
|
90
|
+
const file = parsed as FileV2
|
|
91
|
+
if (!Array.isArray(file.sessions)) return []
|
|
92
|
+
const v2Records = file.sessions.filter(isValidV2Record)
|
|
93
|
+
return await migrateV2Records(agentDir, v2Records, logger)
|
|
94
|
+
}
|
|
95
|
+
logger.warn(`[channels] ${path} version ${String(version)} not supported (expected 2 or ${FILE_VERSION}); ignored`)
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function saveChannelSessions(
|
|
100
|
+
agentDir: string,
|
|
101
|
+
sessions: readonly ChannelSessionRecord[],
|
|
102
|
+
logger: ChannelSessionsLogger = consoleLogger,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const path = channelsSessionsPath(agentDir)
|
|
105
|
+
const payload: FileV3 = { version: FILE_VERSION, sessions: dedupe(sessions) }
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(dirname(path), { recursive: true })
|
|
108
|
+
const tmp = `${path}.tmp`
|
|
109
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
110
|
+
const { rename } = await import('node:fs/promises')
|
|
111
|
+
await rename(tmp, path)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.error(`[channels] failed to persist sessions: ${describe(err)}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// One-shot migration from v2 (sessionId only) to v3 (sessionId + sessionFile).
|
|
118
|
+
// pi-coding-agent writes session files as `${ISO_TIMESTAMP}_${UUID}.jsonl`,
|
|
119
|
+
// so we look for any file ending in `_${sessionId}.jsonl`. If a directory
|
|
120
|
+
// scan fails we leave sessionFile undefined; the next reopen attempt will
|
|
121
|
+
// fall back to a fresh session (the same broken behavior v2 had — but at
|
|
122
|
+
// least the next successful create will populate sessionFile correctly and
|
|
123
|
+
// we'll be migrated forward.)
|
|
124
|
+
async function migrateV2Records(
|
|
125
|
+
agentDir: string,
|
|
126
|
+
v2Records: readonly Omit<ChannelSessionRecord, 'sessionFile'>[],
|
|
127
|
+
logger: ChannelSessionsLogger,
|
|
128
|
+
): Promise<ChannelSessionRecord[]> {
|
|
129
|
+
if (v2Records.length === 0) return []
|
|
130
|
+
const sessionsDir = sessionsDirOf(agentDir)
|
|
131
|
+
let entries: string[]
|
|
132
|
+
try {
|
|
133
|
+
entries = await readdir(sessionsDir)
|
|
134
|
+
} catch {
|
|
135
|
+
logger.warn(`[channels] could not scan ${sessionsDir} for v2→v3 migration; sessionFile left empty`)
|
|
136
|
+
return v2Records.map((r) => ({ ...r }))
|
|
137
|
+
}
|
|
138
|
+
// pi-coding-agent writes files as `${ISO_TIMESTAMP}_${UUID}.jsonl` where
|
|
139
|
+
// the ISO timestamp uses `-` (no `_`) and the UUID may contain `-`. Split
|
|
140
|
+
// on the FIRST underscore so the trailing portion is the full UUID even
|
|
141
|
+
// when the UUID contains hyphens.
|
|
142
|
+
const bySessionIdSuffix = new Map<string, string>()
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.endsWith('.jsonl')) continue
|
|
145
|
+
const underscore = entry.indexOf('_')
|
|
146
|
+
if (underscore < 0) continue
|
|
147
|
+
const trailing = entry.slice(underscore + 1, -'.jsonl'.length)
|
|
148
|
+
bySessionIdSuffix.set(trailing, entry)
|
|
149
|
+
}
|
|
150
|
+
return v2Records.map((r) => {
|
|
151
|
+
const matched = bySessionIdSuffix.get(r.sessionId)
|
|
152
|
+
if (matched === undefined) {
|
|
153
|
+
logger.warn(
|
|
154
|
+
`[channels] v2→v3: no session file matching *_${r.sessionId}.jsonl in ${sessionsDir}; ` +
|
|
155
|
+
`sessionFile left empty (next inbound will create a fresh session for ${r.adapter}:${r.chat}:${r.thread ?? ''})`,
|
|
156
|
+
)
|
|
157
|
+
return { ...r }
|
|
158
|
+
}
|
|
159
|
+
return { ...r, sessionFile: matched }
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
|
|
164
|
+
const seen = new Map<string, ChannelSessionRecord>()
|
|
165
|
+
for (const s of sessions) {
|
|
166
|
+
seen.set(`${s.adapter}:${s.workspace}:${s.chat}:${s.thread ?? ''}`, s)
|
|
167
|
+
}
|
|
168
|
+
return Array.from(seen.values())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function findRecord(
|
|
172
|
+
sessions: readonly ChannelSessionRecord[],
|
|
173
|
+
key: ChannelKey,
|
|
174
|
+
): ChannelSessionRecord | undefined {
|
|
175
|
+
return sessions.find(
|
|
176
|
+
(s) =>
|
|
177
|
+
s.adapter === key.adapter &&
|
|
178
|
+
s.workspace === key.workspace &&
|
|
179
|
+
s.chat === key.chat &&
|
|
180
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
185
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isValidV2Record(v: unknown): v is Omit<ChannelSessionRecord, 'sessionFile'> {
|
|
189
|
+
if (!isObject(v)) return false
|
|
190
|
+
const r = v as Record<string, unknown>
|
|
191
|
+
return (
|
|
192
|
+
typeof r.adapter === 'string' &&
|
|
193
|
+
typeof r.workspace === 'string' &&
|
|
194
|
+
typeof r.chat === 'string' &&
|
|
195
|
+
(r.thread === null || typeof r.thread === 'string') &&
|
|
196
|
+
typeof r.sessionId === 'string' &&
|
|
197
|
+
Array.isArray(r.participants)
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isValidRecord(v: unknown): v is ChannelSessionRecord {
|
|
202
|
+
if (!isValidV2Record(v)) return false
|
|
203
|
+
const r = v as Record<string, unknown>
|
|
204
|
+
return r.sessionFile === undefined || typeof r.sessionFile === 'string'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function describe(err: unknown): string {
|
|
208
|
+
return err instanceof Error ? err.message : String(err)
|
|
209
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Reloadable, ReloadResult } from '@/reload'
|
|
2
|
+
|
|
3
|
+
import type { ChannelManager } from './manager'
|
|
4
|
+
|
|
5
|
+
export type CreateChannelsReloadableOptions = {
|
|
6
|
+
manager: ChannelManager
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createChannelsReloadable({ manager }: CreateChannelsReloadableOptions): Reloadable {
|
|
10
|
+
return {
|
|
11
|
+
scope: 'channels',
|
|
12
|
+
description: 'channels adapters and live config',
|
|
13
|
+
reload: async (): Promise<ReloadResult> => {
|
|
14
|
+
try {
|
|
15
|
+
const diff = await manager.reload()
|
|
16
|
+
const parts: string[] = []
|
|
17
|
+
if (diff.started.length > 0) parts.push(`${diff.started.length} started`)
|
|
18
|
+
if (diff.stopped.length > 0) parts.push(`${diff.stopped.length} stopped`)
|
|
19
|
+
if (diff.restartRequired.length > 0) parts.push(`${diff.restartRequired.length} restart-required`)
|
|
20
|
+
const summary = parts.length === 0 ? 'no adapter changes' : parts.join(', ')
|
|
21
|
+
return { scope: 'channels', ok: true, summary, details: diff }
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
24
|
+
return { scope: 'channels', ok: false, reason: message }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|