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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  6. package/src/agent/tools/channel-react.ts +9 -3
  7. package/src/agent/tools/channel-reply.ts +30 -1
  8. package/src/agent/tools/channel-send.ts +94 -1
  9. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  11. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  12. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  13. package/src/bundled-plugins/memory/README.md +3 -21
  14. package/src/bundled-plugins/memory/index.ts +1 -149
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  16. package/src/channels/adapters/github/inbound.ts +103 -0
  17. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  18. package/src/channels/github-false-receipt.ts +87 -0
  19. package/src/channels/github-review-claim.ts +91 -0
  20. package/src/channels/github-review-turn-ledger.ts +71 -0
  21. package/src/channels/persistence.ts +4 -102
  22. package/src/channels/router.ts +2 -0
  23. package/src/channels/schema.ts +20 -5
  24. package/src/cli/channel.ts +2 -1
  25. package/src/cli/init.ts +2 -1
  26. package/src/config/config.ts +19 -288
  27. package/src/container/start.ts +0 -2
  28. package/src/cron/index.ts +3 -44
  29. package/src/cron/schema.ts +2 -96
  30. package/src/init/gitignore.ts +1 -2
  31. package/src/secrets/defaults.ts +1 -18
  32. package/src/secrets/index.ts +0 -2
  33. package/src/secrets/schema.ts +4 -90
  34. package/src/secrets/storage.ts +0 -2
  35. package/src/server/index.ts +0 -4
  36. package/src/skills/typeclaw-config/SKILL.md +9 -11
  37. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  38. package/typeclaw.schema.json +1 -0
  39. package/src/agent/tools/normalize-ref.ts +0 -11
  40. package/src/bundled-plugins/memory/migration.ts +0 -633
  41. package/src/secrets/migrate-kakaotalk.ts +0 -82
  42. 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, readdir, readFile, writeFile } from 'node:fs/promises'
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 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.
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
- if (version === 3) {
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>
@@ -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 {
@@ -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 (`migrateLegacyConfigShape` lifts it into
111
- // `roles.member.match[]` on load, but a between-reload window can
112
- // briefly contain both). Zod silently drops unknown keys here, which is
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
 
@@ -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()