typeclaw 0.27.0 → 0.28.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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +103 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +2 -0
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/config/config.ts +19 -288
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// In-process record of REAL github review actions performed during the current
|
|
2
|
+
// turn, shared across two plugin boundaries: github-cli-auth records a formal
|
|
3
|
+
// review / thread-resolve here after the `gh` command SUCCEEDS, and channel-reply
|
|
4
|
+
// consults it before sending a verdict/close-out reply. If the agent claims a
|
|
5
|
+
// verdict in prose but this ledger shows no matching action this turn, the reply
|
|
6
|
+
// is a false receipt (see channel-reply.ts). State is per-session and reset at
|
|
7
|
+
// turn start, so a claim must be backed by an action in the SAME turn.
|
|
8
|
+
|
|
9
|
+
export type ReviewVerdict = 'APPROVE' | 'REQUEST_CHANGES'
|
|
10
|
+
|
|
11
|
+
type PrKey = string
|
|
12
|
+
type ThreadKey = string
|
|
13
|
+
|
|
14
|
+
const reviewsByPr = new Map<PrKey, Set<ReviewVerdict>>()
|
|
15
|
+
const resolvedThreads = new Set<ThreadKey>()
|
|
16
|
+
|
|
17
|
+
function prKey(sessionId: string, workspace: string, prNumber: number): PrKey {
|
|
18
|
+
return `${sessionId}::${workspace}::${prNumber}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function threadKey(sessionId: string, workspace: string, prNumber: number, rootCommentId: string): ThreadKey {
|
|
22
|
+
return `${sessionId}::${workspace}::${prNumber}::${rootCommentId}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resetReviewTurn(sessionId: string): void {
|
|
26
|
+
for (const key of reviewsByPr.keys()) {
|
|
27
|
+
if (key.startsWith(`${sessionId}::`)) reviewsByPr.delete(key)
|
|
28
|
+
}
|
|
29
|
+
for (const key of resolvedThreads) {
|
|
30
|
+
if (key.startsWith(`${sessionId}::`)) resolvedThreads.delete(key)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function recordReview(args: {
|
|
35
|
+
sessionId: string
|
|
36
|
+
workspace: string
|
|
37
|
+
prNumber: number
|
|
38
|
+
verdict: ReviewVerdict
|
|
39
|
+
}): void {
|
|
40
|
+
const key = prKey(args.sessionId, args.workspace, args.prNumber)
|
|
41
|
+
const set = reviewsByPr.get(key) ?? new Set<ReviewVerdict>()
|
|
42
|
+
set.add(args.verdict)
|
|
43
|
+
reviewsByPr.set(key, set)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasReview(args: {
|
|
47
|
+
sessionId: string
|
|
48
|
+
workspace: string
|
|
49
|
+
prNumber: number
|
|
50
|
+
verdict: ReviewVerdict
|
|
51
|
+
}): boolean {
|
|
52
|
+
return reviewsByPr.get(prKey(args.sessionId, args.workspace, args.prNumber))?.has(args.verdict) ?? false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function recordResolvedThread(args: {
|
|
56
|
+
sessionId: string
|
|
57
|
+
workspace: string
|
|
58
|
+
prNumber: number
|
|
59
|
+
rootCommentId: string
|
|
60
|
+
}): void {
|
|
61
|
+
resolvedThreads.add(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hasResolvedThread(args: {
|
|
65
|
+
sessionId: string
|
|
66
|
+
workspace: string
|
|
67
|
+
prNumber: number
|
|
68
|
+
rootCommentId: string
|
|
69
|
+
}): boolean {
|
|
70
|
+
return resolvedThreads.has(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
|
|
71
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { ChannelParticipant } from '@/agent/session-origin'
|
|
@@ -16,10 +16,8 @@ const FILE_VERSION = 4
|
|
|
16
16
|
// UUID, which never matches on disk — every restart silently creates a
|
|
17
17
|
// fresh session and the channel loses its transcript memory.
|
|
18
18
|
//
|
|
19
|
-
// `sessionFile` is optional because
|
|
20
|
-
//
|
|
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.
|
|
19
|
+
// `sessionFile` is optional because a session can exist in memory before a
|
|
20
|
+
// transcript path is known; reopen falls back to a fresh session when absent.
|
|
23
21
|
export type ChannelSessionRecord = {
|
|
24
22
|
adapter: AdapterId
|
|
25
23
|
workspace: string
|
|
@@ -36,16 +34,6 @@ type FileV4 = {
|
|
|
36
34
|
sessions: ChannelSessionRecord[]
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
type FileV3 = {
|
|
40
|
-
version: 3
|
|
41
|
-
sessions: ChannelSessionRecord[]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type FileV2 = {
|
|
45
|
-
version: 2
|
|
46
|
-
sessions: Array<Omit<ChannelSessionRecord, 'sessionFile'>>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
37
|
export type ChannelSessionsLogger = {
|
|
50
38
|
info: (msg: string) => void
|
|
51
39
|
warn: (msg: string) => void
|
|
@@ -62,10 +50,6 @@ export function channelsSessionsPath(agentDir: string): string {
|
|
|
62
50
|
return join(agentDir, 'channels', 'sessions.json')
|
|
63
51
|
}
|
|
64
52
|
|
|
65
|
-
function sessionsDirOf(agentDir: string): string {
|
|
66
|
-
return join(agentDir, 'sessions')
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
export async function loadChannelSessions(
|
|
70
54
|
agentDir: string,
|
|
71
55
|
logger: ChannelSessionsLogger = consoleLogger,
|
|
@@ -94,21 +78,7 @@ export async function loadChannelSessions(
|
|
|
94
78
|
if (!Array.isArray(file.sessions)) return []
|
|
95
79
|
return file.sessions.filter(isValidRecord)
|
|
96
80
|
}
|
|
97
|
-
|
|
98
|
-
const file = parsed as FileV3
|
|
99
|
-
if (!Array.isArray(file.sessions)) return []
|
|
100
|
-
return migrateV3ToV4(file.sessions.filter(isValidRecord), logger)
|
|
101
|
-
}
|
|
102
|
-
if (version === 2) {
|
|
103
|
-
const file = parsed as FileV2
|
|
104
|
-
if (!Array.isArray(file.sessions)) return []
|
|
105
|
-
const v2Records = file.sessions.filter(isValidV2Record)
|
|
106
|
-
const v3Records = await migrateV2Records(agentDir, v2Records, logger)
|
|
107
|
-
return migrateV3ToV4(v3Records, logger)
|
|
108
|
-
}
|
|
109
|
-
logger.warn(
|
|
110
|
-
`[channels] ${path} version ${String(version)} not supported (expected 2, 3, or ${FILE_VERSION}); ignored`,
|
|
111
|
-
)
|
|
81
|
+
logger.warn(`[channels] ${path} version ${String(version)} not supported (expected ${FILE_VERSION}); ignored`)
|
|
112
82
|
return []
|
|
113
83
|
}
|
|
114
84
|
|
|
@@ -130,59 +100,6 @@ export async function saveChannelSessions(
|
|
|
130
100
|
}
|
|
131
101
|
}
|
|
132
102
|
|
|
133
|
-
// One-shot migration from v2 (sessionId only) to v3 (sessionId + sessionFile).
|
|
134
|
-
// pi-coding-agent writes session files as `${ISO_TIMESTAMP}_${UUID}.jsonl`,
|
|
135
|
-
// so we look for any file ending in `_${sessionId}.jsonl`. If a directory
|
|
136
|
-
// scan fails we leave sessionFile undefined; the next reopen attempt will
|
|
137
|
-
// fall back to a fresh session (the same broken behavior v2 had — but at
|
|
138
|
-
// least the next successful create will populate sessionFile correctly and
|
|
139
|
-
// we'll be migrated forward.)
|
|
140
|
-
async function migrateV2Records(
|
|
141
|
-
agentDir: string,
|
|
142
|
-
v2Records: readonly (Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string })[],
|
|
143
|
-
logger: ChannelSessionsLogger,
|
|
144
|
-
): Promise<ChannelSessionRecord[]> {
|
|
145
|
-
if (v2Records.length === 0) return []
|
|
146
|
-
const sessionsDir = sessionsDirOf(agentDir)
|
|
147
|
-
let entries: string[]
|
|
148
|
-
try {
|
|
149
|
-
entries = await readdir(sessionsDir)
|
|
150
|
-
} catch {
|
|
151
|
-
logger.warn(`[channels] could not scan ${sessionsDir} for v2→v3 migration; sessionFile left empty`)
|
|
152
|
-
return v2Records.map((r) => ({ ...r }))
|
|
153
|
-
}
|
|
154
|
-
// pi-coding-agent writes files as `${ISO_TIMESTAMP}_${UUID}.jsonl` where
|
|
155
|
-
// the ISO timestamp uses `-` (no `_`) and the UUID may contain `-`. Split
|
|
156
|
-
// on the FIRST underscore so the trailing portion is the full UUID even
|
|
157
|
-
// when the UUID contains hyphens.
|
|
158
|
-
const bySessionIdSuffix = new Map<string, string>()
|
|
159
|
-
for (const entry of entries) {
|
|
160
|
-
if (!entry.endsWith('.jsonl')) continue
|
|
161
|
-
const underscore = entry.indexOf('_')
|
|
162
|
-
if (underscore < 0) continue
|
|
163
|
-
const trailing = entry.slice(underscore + 1, -'.jsonl'.length)
|
|
164
|
-
bySessionIdSuffix.set(trailing, entry)
|
|
165
|
-
}
|
|
166
|
-
return v2Records.map((r) => {
|
|
167
|
-
const matched = bySessionIdSuffix.get(r.sessionId)
|
|
168
|
-
if (matched === undefined) {
|
|
169
|
-
logger.warn(
|
|
170
|
-
`[channels] v2→v3: no session file matching *_${r.sessionId}.jsonl in ${sessionsDir}; ` +
|
|
171
|
-
`sessionFile left empty (next inbound will create a fresh session for ${r.adapter}:${r.chat}:${r.thread ?? ''})`,
|
|
172
|
-
)
|
|
173
|
-
return { ...r }
|
|
174
|
-
}
|
|
175
|
-
return { ...r, sessionFile: matched }
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function migrateV3ToV4(v3Records: ChannelSessionRecord[], logger: ChannelSessionsLogger): ChannelSessionRecord[] {
|
|
180
|
-
logger.info(
|
|
181
|
-
`[channels] v3→v4: ${v3Records.length} record(s) migrated; first post-upgrade inbound will force fresh session`,
|
|
182
|
-
)
|
|
183
|
-
return v3Records.map((r) => ({ ...r, lastInboundAt: 0 }))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
103
|
function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
|
|
187
104
|
const seen = new Map<string, ChannelSessionRecord>()
|
|
188
105
|
for (const s of sessions) {
|
|
@@ -208,21 +125,6 @@ function isObject(v: unknown): v is Record<string, unknown> {
|
|
|
208
125
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
209
126
|
}
|
|
210
127
|
|
|
211
|
-
function isValidV2Record(
|
|
212
|
-
v: unknown,
|
|
213
|
-
): v is Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string } {
|
|
214
|
-
if (!isObject(v)) return false
|
|
215
|
-
const r = v as Record<string, unknown>
|
|
216
|
-
return (
|
|
217
|
-
typeof r.adapter === 'string' &&
|
|
218
|
-
typeof r.workspace === 'string' &&
|
|
219
|
-
typeof r.chat === 'string' &&
|
|
220
|
-
(r.thread === null || typeof r.thread === 'string') &&
|
|
221
|
-
typeof r.sessionId === 'string' &&
|
|
222
|
-
Array.isArray(r.participants)
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
128
|
function isValidRecord(v: unknown): v is ChannelSessionRecord {
|
|
227
129
|
if (!isObject(v)) return false
|
|
228
130
|
const r = v as Record<string, unknown>
|
package/src/channels/router.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
StickyLedger,
|
|
33
33
|
type EngagementDecision,
|
|
34
34
|
} from './engagement'
|
|
35
|
+
import { resetReviewTurn } from './github-review-turn-ledger'
|
|
35
36
|
import {
|
|
36
37
|
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
37
38
|
type MembershipCount,
|
|
@@ -1844,6 +1845,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1844
1845
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1845
1846
|
live.skipLockedSendTurn = null
|
|
1846
1847
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
1848
|
+
resetReviewTurn(live.sessionId)
|
|
1847
1849
|
const isRealUserTurn = batch.length > 0
|
|
1848
1850
|
await fireSessionTurnStart(live, text)
|
|
1849
1851
|
try {
|
package/src/channels/schema.ts
CHANGED
|
@@ -107,11 +107,9 @@ const quotedReplySchema = z
|
|
|
107
107
|
.default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
|
|
108
108
|
|
|
109
109
|
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
110
|
-
// legacy `allow` field
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// exactly what we want — a hard `.strict()` reject would brick recovery
|
|
114
|
-
// for any user mid-migration.
|
|
110
|
+
// legacy `allow` field. Zod silently drops unknown keys here, which is
|
|
111
|
+
// exactly what we want — the field is ignored, not translated, and a hard
|
|
112
|
+
// `.strict()` reject would brick recovery for any user with an old config.
|
|
115
113
|
const adapterSchema = z.object({
|
|
116
114
|
engagement: engagementSchema,
|
|
117
115
|
history: historySchema,
|
|
@@ -128,6 +126,7 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
128
126
|
'pull_request.ready_for_review',
|
|
129
127
|
'pull_request.review_requested',
|
|
130
128
|
'pull_request.review_request_removed',
|
|
129
|
+
'pull_request.synchronize',
|
|
131
130
|
'discussion.created',
|
|
132
131
|
'pull_request_review.submitted',
|
|
133
132
|
] as const
|
|
@@ -158,6 +157,21 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
158
157
|
'discussion.created',
|
|
159
158
|
'pull_request_review.submitted',
|
|
160
159
|
] as const
|
|
160
|
+
// - v3: added ready_for_review, shipped 0.12.0+ (the default just before
|
|
161
|
+
// synchronize was added). Snapshotted here so configs seeded with the
|
|
162
|
+
// pre-synchronize default unfreeze and re-track the new default.
|
|
163
|
+
const GITHUB_EVENT_ALLOWLIST_V3 = [
|
|
164
|
+
'issue_comment.created',
|
|
165
|
+
'pull_request_review_comment.created',
|
|
166
|
+
'discussion_comment.created',
|
|
167
|
+
'issues.opened',
|
|
168
|
+
'pull_request.opened',
|
|
169
|
+
'pull_request.ready_for_review',
|
|
170
|
+
'pull_request.review_requested',
|
|
171
|
+
'pull_request.review_request_removed',
|
|
172
|
+
'discussion.created',
|
|
173
|
+
'pull_request_review.submitted',
|
|
174
|
+
] as const
|
|
161
175
|
|
|
162
176
|
// Every event-allowlist that `channel add` / `init` has ever seeded verbatim
|
|
163
177
|
// into typeclaw.json, oldest first, current default last. The legacy-shape
|
|
@@ -169,6 +183,7 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
169
183
|
export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
170
184
|
GITHUB_EVENT_ALLOWLIST_V1,
|
|
171
185
|
GITHUB_EVENT_ALLOWLIST_V2,
|
|
186
|
+
GITHUB_EVENT_ALLOWLIST_V3,
|
|
172
187
|
DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
173
188
|
]
|
|
174
189
|
|
package/src/cli/channel.ts
CHANGED
|
@@ -629,8 +629,9 @@ async function promptGithubCredentials(cwd: string): Promise<{
|
|
|
629
629
|
message: 'GitHub authentication type',
|
|
630
630
|
options: [
|
|
631
631
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
632
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
632
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
633
633
|
],
|
|
634
|
+
initialValue: 'app',
|
|
634
635
|
})
|
|
635
636
|
if (isCancel(authType)) {
|
|
636
637
|
cancel('Aborted.')
|
package/src/cli/init.ts
CHANGED
|
@@ -1182,8 +1182,9 @@ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['c
|
|
|
1182
1182
|
message: 'GitHub authentication type',
|
|
1183
1183
|
options: [
|
|
1184
1184
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
1185
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
1185
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
1186
1186
|
],
|
|
1187
|
+
initialValue: 'app',
|
|
1187
1188
|
})
|
|
1188
1189
|
if (isCancel(authType)) return back()
|
|
1189
1190
|
const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
|