typeclaw 0.20.0 → 0.21.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/package.json +1 -1
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +27 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +69 -0
- package/src/channels/adapters/github/index.ts +11 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +71 -31
- package/src/channels/router.ts +112 -10
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -9
- package/src/tui/index.ts +70 -18
package/src/channels/router.ts
CHANGED
|
@@ -51,6 +51,8 @@ import type {
|
|
|
51
51
|
HistoryCallback,
|
|
52
52
|
InboundAttachment,
|
|
53
53
|
InboundMessage,
|
|
54
|
+
RemoveReactionCallback,
|
|
55
|
+
RemoveReactionRequest,
|
|
54
56
|
OutboundCallback,
|
|
55
57
|
OutboundMessage,
|
|
56
58
|
QuoteAnchorSource,
|
|
@@ -282,6 +284,7 @@ type QueuedInbound = {
|
|
|
282
284
|
authorIsBot: boolean
|
|
283
285
|
externalMessageId: string
|
|
284
286
|
reactionRef?: ReactionRef
|
|
287
|
+
engageReaction?: Promise<ReactionRef | null>
|
|
285
288
|
isBotMention: boolean
|
|
286
289
|
replyToBotMessageId: string | null
|
|
287
290
|
isDm: boolean
|
|
@@ -353,6 +356,11 @@ type LiveSession = {
|
|
|
353
356
|
// origin so `channel_react` reacts to the triggering message, not whichever
|
|
354
357
|
// inbound happens to be latest in the queue. Null on reminder-only turns.
|
|
355
358
|
currentTurnReactionRef: ReactionRef | null
|
|
359
|
+
// One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
|
|
360
|
+
// resolving to its removable per-instance ref (or null). A debounced turn can
|
|
361
|
+
// batch several inbounds that each got their own :eyes:, so every entry is
|
|
362
|
+
// removed after the reply. Empty on turns with no reactable inbound.
|
|
363
|
+
currentTurnEngageReactions: Array<Promise<ReactionRef | null>>
|
|
356
364
|
lastTurnAuthorIds: Set<string>
|
|
357
365
|
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
358
366
|
// prior batch), preserved across the drain finally-block which resets
|
|
@@ -538,6 +546,9 @@ export type ChannelRouter = {
|
|
|
538
546
|
registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
539
547
|
unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
540
548
|
react: (req: ReactionRequest) => Promise<ReactionResult>
|
|
549
|
+
registerRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
550
|
+
unregisterRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
551
|
+
removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
541
552
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
542
553
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
543
554
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
@@ -757,6 +768,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
757
768
|
let liveGeneration = 0
|
|
758
769
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
759
770
|
const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
|
|
771
|
+
const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
|
|
760
772
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
761
773
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
762
774
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
@@ -1122,6 +1134,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1122
1134
|
currentTurnAuthorId: null,
|
|
1123
1135
|
currentTurnAuthorIds: new Set(),
|
|
1124
1136
|
currentTurnReactionRef: null,
|
|
1137
|
+
currentTurnEngageReactions: [],
|
|
1125
1138
|
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
1126
1139
|
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
1127
1140
|
// `grantStickyForReplyTargets` as the fallback when
|
|
@@ -1254,6 +1267,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1254
1267
|
receivedAt: now(),
|
|
1255
1268
|
ts: item.message.ts,
|
|
1256
1269
|
source: 'prefetch',
|
|
1270
|
+
...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
|
|
1257
1271
|
})
|
|
1258
1272
|
} else {
|
|
1259
1273
|
observed.push({
|
|
@@ -1532,10 +1546,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1532
1546
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1533
1547
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1534
1548
|
live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
|
|
1549
|
+
live.currentTurnEngageReactions = batch.flatMap((m) =>
|
|
1550
|
+
m.engageReaction !== undefined ? [m.engageReaction] : [],
|
|
1551
|
+
)
|
|
1535
1552
|
live.consecutiveSends.clear()
|
|
1536
1553
|
live.lastSentText.clear()
|
|
1537
1554
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1538
1555
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1556
|
+
live.currentTurnEngageReactions = []
|
|
1539
1557
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1540
1558
|
// restore the author identity from the prior turn so author-
|
|
1541
1559
|
// scoped role resolution still works on this turn. The drain
|
|
@@ -1550,6 +1568,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1550
1568
|
// author prior turn like alice→bob restores `bob`, not alice.
|
|
1551
1569
|
live.currentTurnAuthorId = live.lastTurnAuthorId
|
|
1552
1570
|
live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
|
|
1571
|
+
} else {
|
|
1572
|
+
live.currentTurnEngageReactions = []
|
|
1553
1573
|
}
|
|
1554
1574
|
|
|
1555
1575
|
// Update the live origin holder so this turn's tool.before events
|
|
@@ -1576,6 +1596,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1576
1596
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1577
1597
|
const promptStart = now()
|
|
1578
1598
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1599
|
+
const engageAddPromises = live.currentTurnEngageReactions
|
|
1579
1600
|
live.turnSeq++
|
|
1580
1601
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1581
1602
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
@@ -1590,6 +1611,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1590
1611
|
live.consecutiveSends.clear()
|
|
1591
1612
|
live.lastSentText.clear()
|
|
1592
1613
|
} finally {
|
|
1614
|
+
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
1615
|
+
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
1593
1616
|
await fireSessionTurnEnd(live)
|
|
1594
1617
|
}
|
|
1595
1618
|
await fireSessionIdle(live)
|
|
@@ -1603,6 +1626,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1603
1626
|
live.currentTurnAuthorId = null
|
|
1604
1627
|
live.currentTurnAuthorIds = new Set()
|
|
1605
1628
|
live.currentTurnReactionRef = null
|
|
1629
|
+
live.currentTurnEngageReactions = []
|
|
1606
1630
|
live.currentTurnAttachments = []
|
|
1607
1631
|
await stopTypingHeartbeat(live)
|
|
1608
1632
|
}
|
|
@@ -1852,11 +1876,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1852
1876
|
|
|
1853
1877
|
publishInbound(event, 'engage', live.sessionId)
|
|
1854
1878
|
|
|
1855
|
-
autoReactOnEngage(event)
|
|
1879
|
+
const engageReaction = autoReactOnEngage(event)
|
|
1856
1880
|
|
|
1857
1881
|
updateLoopGuard(live, event)
|
|
1858
1882
|
|
|
1859
|
-
enqueue(live, event)
|
|
1883
|
+
enqueue(live, event, engageReaction)
|
|
1860
1884
|
|
|
1861
1885
|
// Start showing "typing..." the moment we know we're going to engage,
|
|
1862
1886
|
// so users see the indicator during the debounce window — not just
|
|
@@ -1938,7 +1962,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1938
1962
|
}
|
|
1939
1963
|
}
|
|
1940
1964
|
|
|
1941
|
-
const enqueue = (
|
|
1965
|
+
const enqueue = (
|
|
1966
|
+
live: LiveSession,
|
|
1967
|
+
event: InboundMessage,
|
|
1968
|
+
engageReaction: Promise<ReactionRef | null> | null,
|
|
1969
|
+
): void => {
|
|
1942
1970
|
live.promptQueue.push({
|
|
1943
1971
|
text: event.text,
|
|
1944
1972
|
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
@@ -1947,6 +1975,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1947
1975
|
authorIsBot: event.authorIsBot,
|
|
1948
1976
|
externalMessageId: event.externalMessageId,
|
|
1949
1977
|
...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
|
|
1978
|
+
...(engageReaction !== null ? { engageReaction } : {}),
|
|
1950
1979
|
isBotMention: event.isBotMention,
|
|
1951
1980
|
replyToBotMessageId: event.replyToBotMessageId,
|
|
1952
1981
|
isDm: event.isDm,
|
|
@@ -1977,6 +2006,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1977
2006
|
reactionCallbacks.get(adapter)?.delete(cb)
|
|
1978
2007
|
}
|
|
1979
2008
|
|
|
2009
|
+
const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2010
|
+
let set = removeReactionCallbacks.get(adapter)
|
|
2011
|
+
if (!set) {
|
|
2012
|
+
set = new Set()
|
|
2013
|
+
removeReactionCallbacks.set(adapter, set)
|
|
2014
|
+
}
|
|
2015
|
+
set.add(cb)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2019
|
+
removeReactionCallbacks.get(adapter)?.delete(cb)
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1980
2022
|
const react = async (req: ReactionRequest): Promise<ReactionResult> => {
|
|
1981
2023
|
if (req.reactionRef.adapter !== req.adapter) {
|
|
1982
2024
|
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
@@ -2001,15 +2043,34 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2001
2043
|
return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
|
|
2002
2044
|
}
|
|
2003
2045
|
|
|
2046
|
+
const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
|
|
2047
|
+
if (req.reactionRef.adapter !== req.adapter) {
|
|
2048
|
+
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
2049
|
+
}
|
|
2050
|
+
const callbacks = removeReactionCallbacks.get(req.adapter)
|
|
2051
|
+
if (!callbacks || callbacks.size === 0) {
|
|
2052
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
|
|
2053
|
+
}
|
|
2054
|
+
let lastError: ReactionResult | undefined
|
|
2055
|
+
for (const cb of Array.from(callbacks)) {
|
|
2056
|
+
const result = await cb(req).catch(
|
|
2057
|
+
(err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2058
|
+
)
|
|
2059
|
+
if (result.ok) return result
|
|
2060
|
+
lastError = result
|
|
2061
|
+
}
|
|
2062
|
+
return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2004
2065
|
// Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
|
|
2005
2066
|
// moment we decide to engage, replacing the old "On it" ack comment on
|
|
2006
2067
|
// GitHub. Fire-and-forget so a reaction failure (missing permission, the
|
|
2007
2068
|
// adapter not supporting reactions, a transient API error) can NEVER block
|
|
2008
2069
|
// engagement, enqueueing, or the agent's actual reply. No reactionRef =
|
|
2009
2070
|
// nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
|
|
2010
|
-
const autoReactOnEngage = (event: InboundMessage):
|
|
2011
|
-
if (event.reactionRef === undefined) return
|
|
2012
|
-
|
|
2071
|
+
const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
|
|
2072
|
+
if (event.reactionRef === undefined) return null
|
|
2073
|
+
const addResult = react({
|
|
2013
2074
|
adapter: event.adapter,
|
|
2014
2075
|
workspace: event.workspace,
|
|
2015
2076
|
chat: event.chat,
|
|
@@ -2017,6 +2078,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2017
2078
|
reactionRef: event.reactionRef,
|
|
2018
2079
|
emoji: ENGAGE_REACTION_EMOJI,
|
|
2019
2080
|
})
|
|
2081
|
+
const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
|
|
2082
|
+
void addResult
|
|
2020
2083
|
.then((result) => {
|
|
2021
2084
|
if (!result.ok && result.code !== 'unsupported') {
|
|
2022
2085
|
logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
|
|
@@ -2025,6 +2088,37 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2025
2088
|
.catch((err) => {
|
|
2026
2089
|
logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
|
|
2027
2090
|
})
|
|
2091
|
+
return addReactionRef
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
|
|
2095
|
+
for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
|
|
2099
|
+
void addPromise
|
|
2100
|
+
.then((reactionRef) => {
|
|
2101
|
+
if (reactionRef === null) return undefined
|
|
2102
|
+
return removeReaction({
|
|
2103
|
+
adapter: live.key.adapter,
|
|
2104
|
+
workspace: live.key.workspace,
|
|
2105
|
+
chat: live.key.chat,
|
|
2106
|
+
thread: live.key.thread,
|
|
2107
|
+
reactionRef,
|
|
2108
|
+
})
|
|
2109
|
+
})
|
|
2110
|
+
.then((result) => {
|
|
2111
|
+
if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
|
|
2112
|
+
logger.info(
|
|
2113
|
+
`[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
|
|
2114
|
+
)
|
|
2115
|
+
}
|
|
2116
|
+
})
|
|
2117
|
+
.catch((err) => {
|
|
2118
|
+
logger.info(
|
|
2119
|
+
`[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
|
|
2120
|
+
)
|
|
2121
|
+
})
|
|
2028
2122
|
}
|
|
2029
2123
|
|
|
2030
2124
|
const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
@@ -2728,6 +2822,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2728
2822
|
registerReaction,
|
|
2729
2823
|
unregisterReaction,
|
|
2730
2824
|
react,
|
|
2825
|
+
registerRemoveReaction,
|
|
2826
|
+
unregisterRemoveReaction,
|
|
2827
|
+
removeReaction,
|
|
2731
2828
|
registerTyping,
|
|
2732
2829
|
unregisterTyping,
|
|
2733
2830
|
registerChannelNameResolver,
|
|
@@ -2929,14 +3026,19 @@ function composeTurnPrompt(
|
|
|
2929
3026
|
'**Do not acknowledge or reply to this notice.**',
|
|
2930
3027
|
'',
|
|
2931
3028
|
'You are woken on every message from someone you recently talked with, so',
|
|
2932
|
-
'most turns you should stay quiet.
|
|
3029
|
+
'most turns you should stay quiet. In a group the target shifts every',
|
|
3030
|
+
'message: before replying, identify who THIS latest message is aimed at.',
|
|
3031
|
+
'Reply ONLY when:',
|
|
2933
3032
|
'- the current message is addressed to you (by name, @-mention, or reply), or',
|
|
2934
3033
|
'- it directly continues your own last exchange and clearly wants an answer',
|
|
2935
3034
|
' (e.g. a follow-up question about what you just said).',
|
|
2936
3035
|
'',
|
|
2937
|
-
'
|
|
2938
|
-
'
|
|
2939
|
-
'
|
|
3036
|
+
'If it is aimed at someone else — another person by name or @-mention, a',
|
|
3037
|
+
'reply to their message, or another bot — it is not your turn, even if you',
|
|
3038
|
+
'were just talking with its author. Otherwise too — chatter, side-',
|
|
3039
|
+
'conversation, banter, or anything not actually waiting on you — reply with',
|
|
3040
|
+
'`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
|
|
3041
|
+
'When unsure, prefer silence.',
|
|
2940
3042
|
'',
|
|
2941
3043
|
'---',
|
|
2942
3044
|
'',
|
package/src/channels/types.ts
CHANGED
|
@@ -132,12 +132,27 @@ export type ReactionRequest = {
|
|
|
132
132
|
emoji: string
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export type RemoveReactionRequest = {
|
|
136
|
+
adapter: AdapterId
|
|
137
|
+
workspace: string
|
|
138
|
+
chat: string
|
|
139
|
+
thread?: string | null
|
|
140
|
+
// Per-reaction-instance ref returned by ReactionResult.reactionRef from the
|
|
141
|
+
// add request, not the inbound message target ref used by ReactionRequest.
|
|
142
|
+
reactionRef: ReactionRef
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
|
|
136
146
|
|
|
137
|
-
export type ReactionResult =
|
|
147
|
+
export type ReactionResult =
|
|
148
|
+
// Optional success ref identifies THIS created reaction instance for later
|
|
149
|
+
// removal, not the original message target. Adapters that cannot remove omit it.
|
|
150
|
+
{ ok: true; reactionRef?: ReactionRef } | { ok: false; error: string; code?: ReactionErrorCode }
|
|
138
151
|
|
|
139
152
|
export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
|
|
140
153
|
|
|
154
|
+
export type RemoveReactionCallback = (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
155
|
+
|
|
141
156
|
// File on disk that the agent wants to attach to an outbound message. The
|
|
142
157
|
// agent runs inside a container with /agent bind-mounted from the host;
|
|
143
158
|
// `path` should be an absolute path the container can `readFile`. The
|
package/src/cli/builtins.ts
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
|
|
4
|
+
import { findAgentDir } from '@/init'
|
|
5
|
+
|
|
6
|
+
import { createEscController } from './inspect-controller'
|
|
7
|
+
import { c, cancel, errorLine, isCancel } from './ui'
|
|
8
|
+
|
|
9
|
+
const ESC_DEBOUNCE_MS = 50
|
|
10
|
+
const QUIT_KEY = 0x71
|
|
11
|
+
|
|
12
|
+
export const dreamsCommand = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: 'dreams',
|
|
15
|
+
description: "browse the dreaming subagent's memory-consolidation journal from git history (host stage)",
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
limit: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'show at most N most-recent dreams',
|
|
21
|
+
},
|
|
22
|
+
json: {
|
|
23
|
+
type: 'boolean',
|
|
24
|
+
description: 'emit one JSON object per dream (subject-level)',
|
|
25
|
+
default: false,
|
|
26
|
+
},
|
|
27
|
+
details: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'with --json, hydrate each dream with its consolidated fragments/shards/skills',
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
35
|
+
const color = useColor()
|
|
36
|
+
const limit = parseLimit(args.limit)
|
|
37
|
+
const interactive = isInteractive() && args.json !== true
|
|
38
|
+
|
|
39
|
+
const result = await runDreams({
|
|
40
|
+
agentDir: cwd,
|
|
41
|
+
json: args.json === true,
|
|
42
|
+
details: args.details === true,
|
|
43
|
+
color,
|
|
44
|
+
...(limit !== undefined ? { limit } : {}),
|
|
45
|
+
selectDream: (entries, selectOpts) => clackSelect(entries, color, selectOpts?.initialSha),
|
|
46
|
+
...(interactive ? { viewDream: () => waitForViewerKey(color) } : {}),
|
|
47
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
52
|
+
process.exit(result.exitCode)
|
|
53
|
+
}
|
|
54
|
+
process.exit(result.exitCode)
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function isInteractive(): boolean {
|
|
59
|
+
return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
63
|
+
|
|
64
|
+
// Esc routes through createEscController so a standalone Esc returns 'back'
|
|
65
|
+
// while a multi-byte CSI sequence (↑/↓ arrows) does not. Teardown restores
|
|
66
|
+
// raw mode but deliberately does NOT pause stdin: clack cannot re-flow a
|
|
67
|
+
// paused process.stdin under Bun, so the next picker would freeze — the same
|
|
68
|
+
// reason cli/inspect.ts leaves the stream flowing on its return path.
|
|
69
|
+
export async function waitForViewerKey(color: boolean, input: RawInput = process.stdin): Promise<ViewAction> {
|
|
70
|
+
const stdin = input
|
|
71
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return 'exit'
|
|
72
|
+
|
|
73
|
+
process.stdout.write(`${viewerHintLine(color)}\n`)
|
|
74
|
+
|
|
75
|
+
const ctrl = createEscController({ debounceMs: ESC_DEBOUNCE_MS })
|
|
76
|
+
const escSignal = ctrl.armForStream()
|
|
77
|
+
|
|
78
|
+
return new Promise<ViewAction>((resolve) => {
|
|
79
|
+
let settled = false
|
|
80
|
+
const finish = (action: ViewAction): void => {
|
|
81
|
+
if (settled) return
|
|
82
|
+
settled = true
|
|
83
|
+
escSignal.removeEventListener('abort', onEscAbort)
|
|
84
|
+
stdin.off('data', onData)
|
|
85
|
+
ctrl.dispose()
|
|
86
|
+
try {
|
|
87
|
+
stdin.setRawMode(false)
|
|
88
|
+
} catch {
|
|
89
|
+
/* terminal already torn down */
|
|
90
|
+
}
|
|
91
|
+
resolve(action)
|
|
92
|
+
}
|
|
93
|
+
const onEscAbort = (): void => finish('back')
|
|
94
|
+
const onData = (chunk: Buffer): void => {
|
|
95
|
+
if (chunk[0] === QUIT_KEY) {
|
|
96
|
+
finish('exit')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
100
|
+
if (sigint) finish('exit')
|
|
101
|
+
}
|
|
102
|
+
escSignal.addEventListener('abort', onEscAbort, { once: true })
|
|
103
|
+
stdin.setRawMode(true)
|
|
104
|
+
stdin.resume()
|
|
105
|
+
stdin.on('data', onData)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function viewerHintLine(color: boolean): string {
|
|
110
|
+
const text = '(esc to go back to the list · q to quit)'
|
|
111
|
+
return color ? c.dim(text) : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseLimit(raw: unknown): number | undefined {
|
|
115
|
+
if (typeof raw !== 'string') return undefined
|
|
116
|
+
const n = Number.parseInt(raw, 10)
|
|
117
|
+
return Number.isFinite(n) && n > 0 ? n : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function clackSelect(
|
|
121
|
+
entries: DreamEntry[],
|
|
122
|
+
color: boolean,
|
|
123
|
+
initialSha: string | undefined,
|
|
124
|
+
): Promise<DreamEntry | null> {
|
|
125
|
+
const { select } = await import('@clack/prompts')
|
|
126
|
+
const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
|
|
127
|
+
const picked = await select<string>({
|
|
128
|
+
message: `Pick a dream to open (${entries.length} total)`,
|
|
129
|
+
options: entries.map((entry) => ({
|
|
130
|
+
value: entry.sha,
|
|
131
|
+
label: renderListRow(entry, { color }),
|
|
132
|
+
})),
|
|
133
|
+
initialValue: preferred,
|
|
134
|
+
})
|
|
135
|
+
if (isCancel(picked)) {
|
|
136
|
+
cancel('Cancelled.')
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
return entries.find((entry) => entry.sha === picked) ?? null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function useColor(): boolean {
|
|
143
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
144
|
+
if (process.env.FORCE_COLOR === '0') return false
|
|
145
|
+
if (process.env.FORCE_COLOR) return true
|
|
146
|
+
return Boolean(process.stdout.isTTY)
|
|
147
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ const main = defineCommand({
|
|
|
23
23
|
reload: () => import('./reload').then((m) => m.reload),
|
|
24
24
|
logs: () => import('./logs').then((m) => m.logsCommand),
|
|
25
25
|
inspect: () => import('./inspect').then((m) => m.inspectCommand),
|
|
26
|
+
dreams: () => import('./dreams').then((m) => m.dreamsCommand),
|
|
26
27
|
shell: () => import('./shell').then((m) => m.shellCommand),
|
|
27
28
|
compose: () => import('./compose').then((m) => m.composeCommand),
|
|
28
29
|
channel: () => import('./channel').then((m) => m.channelCommand),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type GitResult = { exitCode: number; stdout: string; stderr: string }
|
|
2
|
+
export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
|
|
3
|
+
|
|
4
|
+
export type RawCommit = {
|
|
5
|
+
sha: string
|
|
6
|
+
shortSha: string
|
|
7
|
+
committedAt: string
|
|
8
|
+
subject: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ResolveRepoResult = { ok: true; root: string } | { ok: false; reason: 'not-a-repo' | 'git-failed' }
|
|
12
|
+
|
|
13
|
+
const FIELD_SEP = '\x1f'
|
|
14
|
+
const RECORD_SEP = '\x1e'
|
|
15
|
+
|
|
16
|
+
export async function resolveGitRepo(cwd: string, spawnGit: SpawnGit = defaultSpawnGit): Promise<ResolveRepoResult> {
|
|
17
|
+
const res = await spawnGit(['rev-parse', '--show-toplevel'], cwd)
|
|
18
|
+
if (res.exitCode === 0) {
|
|
19
|
+
const root = res.stdout.trim()
|
|
20
|
+
if (root.length > 0) return { ok: true, root }
|
|
21
|
+
return { ok: false, reason: 'git-failed' }
|
|
22
|
+
}
|
|
23
|
+
if (/not a git repository/i.test(res.stderr)) return { ok: false, reason: 'not-a-repo' }
|
|
24
|
+
return { ok: false, reason: 'git-failed' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DREAM_SUBJECT_PREFIX = 'dream: '
|
|
28
|
+
|
|
29
|
+
export async function readDreamCommitLog(
|
|
30
|
+
root: string,
|
|
31
|
+
opts: { limit?: number } = {},
|
|
32
|
+
spawnGit: SpawnGit = defaultSpawnGit,
|
|
33
|
+
): Promise<RawCommit[]> {
|
|
34
|
+
// --grep is only a cheap pre-filter: it matches ANY line of the commit
|
|
35
|
+
// message, so a non-dream commit with a `dream: ...` body line slips
|
|
36
|
+
// through. The subject is the authoritative contract, so the prefix filter
|
|
37
|
+
// below is what actually decides membership — and the limit is applied
|
|
38
|
+
// AFTER it so body-matching impostors can't consume a slot and shrink the
|
|
39
|
+
// result below the requested count.
|
|
40
|
+
const args = ['log', '--grep=^dream: ', `--format=%H${FIELD_SEP}%h${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`]
|
|
41
|
+
|
|
42
|
+
const res = await spawnGit(args, root)
|
|
43
|
+
if (res.exitCode !== 0) return []
|
|
44
|
+
const dreams = parseLogOutput(res.stdout).filter((c) => c.subject.startsWith(DREAM_SUBJECT_PREFIX))
|
|
45
|
+
if (opts.limit !== undefined && opts.limit > 0) return dreams.slice(0, opts.limit)
|
|
46
|
+
return dreams
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseLogOutput(stdout: string): RawCommit[] {
|
|
50
|
+
const commits: RawCommit[] = []
|
|
51
|
+
for (const record of stdout.split(RECORD_SEP)) {
|
|
52
|
+
const trimmed = record.replace(/^\n+/, '')
|
|
53
|
+
if (trimmed.length === 0) continue
|
|
54
|
+
const [sha, shortSha, committedAt, subject] = trimmed.split(FIELD_SEP)
|
|
55
|
+
if (sha === undefined || shortSha === undefined || committedAt === undefined || subject === undefined) continue
|
|
56
|
+
commits.push({ sha, shortSha, committedAt, subject })
|
|
57
|
+
}
|
|
58
|
+
return commits
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readDreamCommitShow(
|
|
62
|
+
root: string,
|
|
63
|
+
sha: string,
|
|
64
|
+
spawnGit: SpawnGit = defaultSpawnGit,
|
|
65
|
+
): Promise<{ nameStatus: string; patch: string } | null> {
|
|
66
|
+
const nameStatus = await spawnGit(['show', '--no-color', '--find-renames', '--format=', '--name-status', sha], root)
|
|
67
|
+
if (nameStatus.exitCode !== 0) return null
|
|
68
|
+
const patch = await spawnGit(['show', '--no-color', '--format=', '--unified=0', sha], root)
|
|
69
|
+
if (patch.exitCode !== 0) return null
|
|
70
|
+
return { nameStatus: nameStatus.stdout, patch: patch.stdout }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultSpawnGit: SpawnGit = async (args, cwd) => {
|
|
74
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
75
|
+
if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
|
|
76
|
+
try {
|
|
77
|
+
const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
78
|
+
const exitCode = await proc.exited
|
|
79
|
+
const stdout = await new Response(proc.stdout).text()
|
|
80
|
+
const stderr = await new Response(proc.stderr).text()
|
|
81
|
+
return { exitCode, stdout, stderr }
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
|
|
84
|
+
}
|
|
85
|
+
}
|