typeclaw 0.37.4 → 0.37.5
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/doctor.ts +6 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +17 -0
- package/src/channels/router.ts +120 -12
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/shell.ts +2 -2
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
package/package.json
CHANGED
package/src/agent/doctor.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
// changedPaths are a wire format: agentDir-relative POSIX paths the container
|
|
4
|
+
// emits and the host re-validates. Resolved with `path.posix` so a win32 test
|
|
5
|
+
// runner keeps `/`-separators instead of rewriting `memory/x.md` to `memory\x.md`.
|
|
6
|
+
const { isAbsolute, normalize } = posix
|
|
2
7
|
|
|
3
8
|
import type {
|
|
4
9
|
PluginCheckResult,
|
package/src/agent/subagents.ts
CHANGED
|
@@ -241,6 +241,11 @@ export type InvokeSubagentOptions = {
|
|
|
241
241
|
sessionId: string | undefined
|
|
242
242
|
abort: () => Promise<void>
|
|
243
243
|
}) => void
|
|
244
|
+
// Sink for the subagent's captured final message (a reviewer `<review>` block,
|
|
245
|
+
// a researcher `<report>` block, or the last free-form assistant text).
|
|
246
|
+
// `runSession` owns the capture so the required-block guard can re-prompt
|
|
247
|
+
// before the result settles; `startSubagent` passes this to receive the output.
|
|
248
|
+
onFinalMessageCaptured?: (msg: string) => void
|
|
244
249
|
}
|
|
245
250
|
|
|
246
251
|
export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
|
|
@@ -261,6 +266,8 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
261
266
|
normalizeSubagentSession(await createSessionForSubagent(subagent, sessionOptions))
|
|
262
267
|
let aborted = false
|
|
263
268
|
let drainWatch: SubagentDrainWatch | undefined
|
|
269
|
+
const requiredBlockTag = REQUIRED_FINAL_BLOCK[name]
|
|
270
|
+
const capture = attachFinalMessageCapture(session, requiredBlockTag, options.onFinalMessageCaptured ?? (() => {}))
|
|
264
271
|
if (options.onSessionCreated !== undefined) {
|
|
265
272
|
options.onSessionCreated({
|
|
266
273
|
session,
|
|
@@ -312,6 +319,28 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
312
319
|
cancelled: () => aborted,
|
|
313
320
|
})
|
|
314
321
|
}
|
|
322
|
+
// Required-block guard (mirrors the channel empty-response guard): a subagent
|
|
323
|
+
// that owes a result block but ended without one gets a bounded re-prompt to
|
|
324
|
+
// emit it as text, then an honest fallback — never a silent stale-preamble
|
|
325
|
+
// result or a loud failure. Runs strictly after the drain settles so it is a
|
|
326
|
+
// final contract-repair pass, not another research phase; it deliberately
|
|
327
|
+
// does NOT re-run the drain.
|
|
328
|
+
if (requiredBlockTag !== undefined) {
|
|
329
|
+
for (
|
|
330
|
+
let attempt = 1;
|
|
331
|
+
!aborted && !capture.hasRequiredBlock() && attempt <= MAX_REQUIRED_BLOCK_RETRIES;
|
|
332
|
+
attempt++
|
|
333
|
+
) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[subagent] ${name} required_block_retry attempt=${attempt}/${MAX_REQUIRED_BLOCK_RETRIES} tag=${requiredBlockTag}`,
|
|
336
|
+
)
|
|
337
|
+
await session.prompt(`${renderTurnTimeAnchor()}\n\n${renderRequiredBlockRetryNudge(requiredBlockTag)}`)
|
|
338
|
+
}
|
|
339
|
+
if (!aborted && !capture.hasRequiredBlock()) {
|
|
340
|
+
console.warn(`[subagent] ${name} required_block_fallback tag=${requiredBlockTag}`)
|
|
341
|
+
capture.setSyntheticFinalMessage(renderMissingRequiredBlockFallback(name, requiredBlockTag))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
315
344
|
if (hooks && sessionId !== undefined) {
|
|
316
345
|
await hooks.runSessionIdle({
|
|
317
346
|
sessionId,
|
|
@@ -432,6 +461,9 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
|
|
|
432
461
|
|
|
433
462
|
const work = invokeSubagent(name, {
|
|
434
463
|
...options,
|
|
464
|
+
onFinalMessageCaptured: (msg) => {
|
|
465
|
+
finalMessage = msg
|
|
466
|
+
},
|
|
435
467
|
onSessionCreated: (event) => {
|
|
436
468
|
handleSettled = true
|
|
437
469
|
abortSession = event.abort
|
|
@@ -439,9 +471,6 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
|
|
|
439
471
|
if (options.onSession !== undefined) {
|
|
440
472
|
options.onSession(event)
|
|
441
473
|
}
|
|
442
|
-
attachFinalMessageCapture(event.session, (msg) => {
|
|
443
|
-
finalMessage = msg
|
|
444
|
-
})
|
|
445
474
|
},
|
|
446
475
|
})
|
|
447
476
|
.then(() => ({ ok: true as const, ...(finalMessage !== undefined ? { finalMessage } : {}) }))
|
|
@@ -497,22 +526,102 @@ function raceSubagentCompletion(
|
|
|
497
526
|
})
|
|
498
527
|
}
|
|
499
528
|
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
//
|
|
506
|
-
|
|
529
|
+
// The tags a subagent can use to wrap its structured result: the reviewer's
|
|
530
|
+
// `<review>`, the researcher's `<report>`. Fixed literals — never user input —
|
|
531
|
+
// so the per-tag patterns below are injection-safe.
|
|
532
|
+
type FinalBlockTag = 'review' | 'report'
|
|
533
|
+
|
|
534
|
+
// A complete <TAG>...</TAG> block. The block IS the result: same-message
|
|
535
|
+
// preamble/trailing chatter or a later summary turn must not become the captured
|
|
536
|
+
// final message. `[\s\S]` spans newlines (the block is multi-line); non-greedy
|
|
537
|
+
// stops at the first close so an incidental `<TAG>` literal in the wrapped text
|
|
538
|
+
// cannot swallow real content. Global so a message with several blocks yields the
|
|
539
|
+
// last (the revision).
|
|
540
|
+
const FINAL_BLOCK_RE: Readonly<Record<FinalBlockTag, RegExp>> = {
|
|
541
|
+
review: /<review>[\s\S]*?<\/review>/g,
|
|
542
|
+
report: /<report>[\s\S]*?<\/report>/g,
|
|
543
|
+
}
|
|
507
544
|
|
|
508
|
-
function
|
|
509
|
-
const matches = text.match(
|
|
545
|
+
function lastTaggedBlock(text: string, tag: FinalBlockTag): string | null {
|
|
546
|
+
const matches = text.match(FINAL_BLOCK_RE[tag])
|
|
510
547
|
return matches === null ? null : (matches[matches.length - 1] ?? null)
|
|
511
548
|
}
|
|
512
549
|
|
|
513
|
-
|
|
550
|
+
// Subagents whose result IS a REQUIRED tagged block — the parent must receive
|
|
551
|
+
// that block or a loud failure, never a stale earlier turn. The researcher's
|
|
552
|
+
// contract (src/bundled-plugins/researcher/researcher.ts) mandates a closing
|
|
553
|
+
// `<report>` block; when an upstream provider retry loop ends the run on
|
|
554
|
+
// unexecuted `write_report` tool calls, the researcher never emits it, and
|
|
555
|
+
// without this gate the capture would silently return its earlier `<analysis>`
|
|
556
|
+
// preamble as a "successful" result — the production regression this guards.
|
|
557
|
+
// Keyed by the stable bundled-subagent registry name. This is STRICTER than the
|
|
558
|
+
// reviewer's `<review>` (preferred, but falls back to free-form text): only the
|
|
559
|
+
// subagents listed here fail loud when their block is absent.
|
|
560
|
+
const REQUIRED_FINAL_BLOCK: Readonly<Record<string, FinalBlockTag>> = { researcher: 'report' }
|
|
561
|
+
|
|
562
|
+
// Bounded re-prompt budget for the required-block guard, mirroring the channel
|
|
563
|
+
// empty-response guard's MAX_EMPTY_TURN_RETRIES. A subagent that owes a result
|
|
564
|
+
// block but ended without one is nudged at most this many times before the
|
|
565
|
+
// honest fallback is installed.
|
|
566
|
+
const MAX_REQUIRED_BLOCK_RETRIES = 2
|
|
567
|
+
|
|
568
|
+
// The recovery nudge. It MUST forbid tools: the known failure mode is a provider
|
|
569
|
+
// retry loop on the report-writing tool call, so re-driving the tool path would
|
|
570
|
+
// just re-trigger the loop. Asking for the block as plain text is the repair.
|
|
571
|
+
function renderRequiredBlockRetryNudge(tag: FinalBlockTag): string {
|
|
572
|
+
return `---
|
|
573
|
+
**[SYSTEM MESSAGE — not from a human]**
|
|
574
|
+
|
|
575
|
+
Your previous turn ended without the required <${tag}> block. This is an automated runtime recovery signal, not a human message.
|
|
576
|
+
|
|
577
|
+
Emit the final <${tag}>...</${tag}> block NOW as plain assistant text. Do NOT call any tools — in particular do NOT call write_report. Do NOT continue researching or spawn more subagents.
|
|
578
|
+
|
|
579
|
+
If the report file was not successfully written, still emit the block: set <report_file>none</report_file>, <confidence>low</confidence> (explain the report artifact was not completed), and note in <open_questions> that the run should be retried.
|
|
580
|
+
|
|
581
|
+
Output exactly one <${tag}> block and nothing else.`
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The terminal graceful fallback when the nudges are exhausted. It fabricates NO
|
|
585
|
+
// findings — only a structured, low-confidence "could not complete" notice — so
|
|
586
|
+
// the parent gets a usable result instead of stale `<analysis>` or a hard error.
|
|
587
|
+
function renderMissingRequiredBlockFallback(name: string, tag: FinalBlockTag): string {
|
|
588
|
+
if (tag === 'report') {
|
|
589
|
+
return `<report>
|
|
590
|
+
<summary>
|
|
591
|
+
The ${name} subagent could not complete a research report in this run: it ended without emitting the required <report> block (a known cause is the report tool not completing). Do not treat any earlier analysis text as findings — rerun the researcher or gather the sources directly if the answer is still needed.
|
|
592
|
+
</summary>
|
|
593
|
+
<report_file>
|
|
594
|
+
none
|
|
595
|
+
</report_file>
|
|
596
|
+
<confidence>
|
|
597
|
+
low — no complete research report artifact was produced.
|
|
598
|
+
</confidence>
|
|
599
|
+
<open_questions>
|
|
600
|
+
The original research request remains unresolved; rerun the ${name} subagent or gather the sources directly.
|
|
601
|
+
</open_questions>
|
|
602
|
+
</report>`
|
|
603
|
+
}
|
|
604
|
+
return `<${tag}>\nThe ${name} subagent ended without emitting the required <${tag}> block and could not recover; rerun it or inspect the transcript.\n</${tag}>`
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
type SubagentCapture = {
|
|
608
|
+
// True once a required-block subagent (researcher) has emitted its `<report>`
|
|
609
|
+
// block; always false for subagents without a required block.
|
|
610
|
+
hasRequiredBlock: () => boolean
|
|
611
|
+
// Install a captured final message directly — used by the required-block guard
|
|
612
|
+
// to set an honest fallback when the block was never emitted, so the parent
|
|
613
|
+
// gets a structured result rather than stale preamble.
|
|
614
|
+
setSyntheticFinalMessage: (msg: string) => void
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function attachFinalMessageCapture(
|
|
618
|
+
session: AgentSession,
|
|
619
|
+
requiredBlockTag: FinalBlockTag | undefined,
|
|
620
|
+
onFinalMessage: (msg: string) => void,
|
|
621
|
+
): SubagentCapture {
|
|
514
622
|
let lastAssistant: string | null = null
|
|
515
623
|
let lastReview: string | null = null
|
|
624
|
+
let lastRequired: string | null = null
|
|
516
625
|
try {
|
|
517
626
|
session.subscribe((event: unknown) => {
|
|
518
627
|
const ev = event as { type?: string; message?: { role?: string; content?: unknown } }
|
|
@@ -524,7 +633,23 @@ function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg:
|
|
|
524
633
|
const text = extractFinalMessageText(ev.message?.content)
|
|
525
634
|
if (text === null) return
|
|
526
635
|
lastAssistant = text
|
|
527
|
-
|
|
636
|
+
|
|
637
|
+
// Required-block contract (researcher): the result IS the block. A turn
|
|
638
|
+
// with text but no block — the `<analysis>` preamble, a process narrative —
|
|
639
|
+
// must NOT become the captured result, so `hasRequiredBlock` stays false and
|
|
640
|
+
// the guard in runSession re-prompts rather than returning stale preamble.
|
|
641
|
+
if (requiredBlockTag !== undefined) {
|
|
642
|
+
const block = lastTaggedBlock(text, requiredBlockTag)
|
|
643
|
+
if (block !== null) {
|
|
644
|
+
lastRequired = block
|
|
645
|
+
onFinalMessage(lastRequired)
|
|
646
|
+
}
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Preferred-block contract (reviewer) / free-form: a `<review>` block wins
|
|
651
|
+
// when present; otherwise the last free-form assistant text is the result.
|
|
652
|
+
const review = lastTaggedBlock(text, 'review')
|
|
528
653
|
if (review !== null) lastReview = review
|
|
529
654
|
onFinalMessage(lastReview ?? lastAssistant)
|
|
530
655
|
})
|
|
@@ -532,6 +657,13 @@ function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg:
|
|
|
532
657
|
// session.subscribe is a stable upstream API; defensive try is for test
|
|
533
658
|
// doubles that don't implement it.
|
|
534
659
|
}
|
|
660
|
+
return {
|
|
661
|
+
hasRequiredBlock: () => lastRequired !== null,
|
|
662
|
+
setSyntheticFinalMessage: (msg) => {
|
|
663
|
+
lastRequired = msg
|
|
664
|
+
onFinalMessage(msg)
|
|
665
|
+
},
|
|
666
|
+
}
|
|
535
667
|
}
|
|
536
668
|
|
|
537
669
|
function extractFinalMessageText(content: unknown): string | null {
|
package/src/agent/todo/scope.ts
CHANGED
|
@@ -51,6 +51,8 @@ export function resolveTodoScope(origin: SessionOrigin): TodoScope | null {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const CHANNEL_SCOPE_SEPARATOR = ','
|
|
55
|
+
|
|
54
56
|
function channelScopeKey(origin: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
55
57
|
const parts = [
|
|
56
58
|
encodeComponent(origin.adapter),
|
|
@@ -58,7 +60,7 @@ function channelScopeKey(origin: { adapter: string; workspace: string; chat: str
|
|
|
58
60
|
encodeComponent(origin.chat),
|
|
59
61
|
encodeComponent(origin.thread),
|
|
60
62
|
]
|
|
61
|
-
return `channel/${parts.join(
|
|
63
|
+
return `channel/${parts.join(CHANNEL_SCOPE_SEPARATOR)}`
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// Encode one scope component injectively. Every component is emitted as a
|
|
@@ -69,7 +71,7 @@ function channelScopeKey(origin: { adapter: string; workspace: string; chat: str
|
|
|
69
71
|
// confused: a null thread vs a literal "n" string, an empty string vs a
|
|
70
72
|
// literal "_empty" string, and any value vs another whose unsafe chars happen
|
|
71
73
|
// to map together. `encodeURIComponent` is itself injective and never emits
|
|
72
|
-
// `/` or
|
|
74
|
+
// `/` or `,`, so the joined key is both a single filesystem-safe path segment
|
|
73
75
|
// and a collision-free identity for the conversation whose todo file it names.
|
|
74
76
|
function encodeComponent(value: string | null): string {
|
|
75
77
|
if (value === null) return 'n'
|
|
@@ -78,15 +78,13 @@ export function createChannelReplyTool({
|
|
|
78
78
|
},
|
|
79
79
|
),
|
|
80
80
|
),
|
|
81
|
-
continue: Type.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}),
|
|
89
|
-
),
|
|
81
|
+
continue: Type.Boolean({
|
|
82
|
+
description:
|
|
83
|
+
'REQUIRED on every channel_reply — you must explicitly choose, there is no default. Set `true` when this reply is a mid-turn status update (e.g. "working on it…") and you still have work to do THIS turn — fetching data, running a tool, spawning a subagent, then replying again; `true` keeps the turn alive so that follow-up actually runs. ' +
|
|
84
|
+
'Set `false` when this reply is your final message for the turn (the common case). ' +
|
|
85
|
+
'This choice is mandatory precisely because a missing value used to default to ending the turn silently: a successful reply ends the turn unless `continue` is `true`, so a `false` on an ack you meant to keep working from drops the work you promised. ' +
|
|
86
|
+
'Do not set `true` just to seem responsive; only when genuine multi-step work follows in the same turn.',
|
|
87
|
+
}),
|
|
90
88
|
resolve_review_thread: Type.Optional(
|
|
91
89
|
Type.Boolean({
|
|
92
90
|
description:
|
|
@@ -155,12 +155,17 @@ const VECTOR_TURN_TOP_K = 10
|
|
|
155
155
|
// without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
|
|
156
156
|
// testing hook orchestration — without leaking state across other tests in the
|
|
157
157
|
// same worker. Production uses the real `embed` and `hybridSearch`.
|
|
158
|
-
type MemoryPluginDeps = {
|
|
158
|
+
export type MemoryPluginDeps = {
|
|
159
159
|
hybridSearch: typeof hybridSearch
|
|
160
160
|
queryEmbedFn: EmbedFn
|
|
161
|
+
openAppendVectorStore: (agentDir: string) => VectorStore
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
const defaultDeps: MemoryPluginDeps = {
|
|
164
|
+
const defaultDeps: MemoryPluginDeps = {
|
|
165
|
+
hybridSearch,
|
|
166
|
+
queryEmbedFn: embed,
|
|
167
|
+
openAppendVectorStore: (agentDir) => VectorStore.open(join(agentDir, 'memory', '.vectors', 'index.db')),
|
|
168
|
+
}
|
|
164
169
|
|
|
165
170
|
// Builds the per-turn user-prompt memory block for a vector agent. Non-channel
|
|
166
171
|
// turns always use top-K hybrid search, regardless of total shard size. Repeated
|
|
@@ -231,7 +236,7 @@ async function renderVectorTurnMemory(
|
|
|
231
236
|
}
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
239
|
+
export function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
235
240
|
return definePlugin({
|
|
236
241
|
configSchema: memoryConfigSchema,
|
|
237
242
|
plugin: async (ctx) => {
|
|
@@ -401,9 +406,7 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
// Open a long-lived VectorStore for append-time indexing when vector is enabled.
|
|
404
|
-
const appendVectorStore = ctx.config.vector.enabled
|
|
405
|
-
? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
|
|
406
|
-
: undefined
|
|
409
|
+
const appendVectorStore = ctx.config.vector.enabled ? deps.openAppendVectorStore(ctx.agentDir) : undefined
|
|
407
410
|
|
|
408
411
|
return {
|
|
409
412
|
subagents: {
|
|
@@ -6,6 +6,7 @@ import type { SessionOrigin } from '@/agent/session-origin'
|
|
|
6
6
|
import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, type InjectionPlan } from './injection-plan'
|
|
7
7
|
import { loadAllShards, type TopicShard } from './load-shards'
|
|
8
8
|
import { topicsDir } from './paths'
|
|
9
|
+
import { slugIsHeadingEcho } from './slug'
|
|
9
10
|
import type { DedupedRetrievedItem } from './turn-dedup'
|
|
10
11
|
|
|
11
12
|
const MAX_FILE_BYTES = 12 * 1024
|
|
@@ -130,7 +131,7 @@ export function renderRetrievedMemorySection(
|
|
|
130
131
|
lines.push(`## ${item.heading}`)
|
|
131
132
|
lines.push(item.excerpt.trimEnd(), '')
|
|
132
133
|
} else if (item.source === 'topic' || item.source === 'reference') {
|
|
133
|
-
lines.push(
|
|
134
|
+
lines.push(topicIndexEntry(item.heading, item.key))
|
|
134
135
|
} else {
|
|
135
136
|
lines.push(`- ${item.heading} _(recent observation)_`)
|
|
136
137
|
}
|
|
@@ -150,11 +151,24 @@ export function renderTopicIndexMemorySection(
|
|
|
150
151
|
if (options.origin?.kind === 'channel') lines.push(...CHANNEL_MEMORY_BOUNDARY, '')
|
|
151
152
|
lines.push(topicIndexDirective(options), '')
|
|
152
153
|
for (const shard of shards) {
|
|
153
|
-
lines.push(
|
|
154
|
+
lines.push(topicIndexEntry(shard.frontmatter.heading, shard.slug))
|
|
154
155
|
}
|
|
155
156
|
return lines.join('\n').trimEnd()
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
// A topic-index line names a topic so the model can decide whether to open it
|
|
160
|
+
// (the slug is the `memory_search({ topic })` key). When the slug is just a kebab
|
|
161
|
+
// echo of the heading the heading adds no signal, so render the slug alone; keep
|
|
162
|
+
// both when they diverge (e.g. `gh-api-labels-array-syntax` vs "GitHub API label
|
|
163
|
+
// management in the agent environment") or when the heading has no ASCII form
|
|
164
|
+
// (e.g. CJK), where `slugIsHeadingEcho` returns false and the readable name stays.
|
|
165
|
+
function topicIndexEntry(heading: string, slug: string): string {
|
|
166
|
+
if (slugIsHeadingEcho(heading, slug)) {
|
|
167
|
+
return `- \`${slug}\``
|
|
168
|
+
}
|
|
169
|
+
return `- ${heading} \`${slug}\``
|
|
170
|
+
}
|
|
171
|
+
|
|
158
172
|
function topicIndexDirective(options: Pick<LoadMemoryOptions, 'origin'>): string {
|
|
159
173
|
if (options.origin?.kind === 'channel') {
|
|
160
174
|
return 'Memory shown as headings only in channels. Call `memory_search({ topic: "<slug>" })` with a slug below to read a full body.'
|
|
@@ -20,6 +20,25 @@ export function headingToSlug(heading: string, existingSlugs: Set<string>): stri
|
|
|
20
20
|
return slug
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// True only when `slug` is a clean kebab echo of `heading` (the readable form
|
|
24
|
+
// adds nothing the slug doesn't). `headingToSlug` maps every non-ASCII letter,
|
|
25
|
+
// ideograph, or symbol to `-` (or to an `untitled-<hash>` when nothing survives),
|
|
26
|
+
// so a heading like `한글 memo` slugifies to `memo` and an all-CJK/emoji heading to
|
|
27
|
+
// the fallback — collapsing either would drop the only human-readable name. Guard
|
|
28
|
+
// by requiring the diacritic-folded heading to consist solely of ASCII
|
|
29
|
+
// alphanumerics and separators/punctuation; any surviving CJK/emoji/symbol means
|
|
30
|
+
// normalization discarded content, so it is never an echo. (Diacritics are
|
|
31
|
+
// transliterated, not dropped — `café` → `cafe` stays a legitimate echo.)
|
|
32
|
+
const ECHO_SAFE_HEADING = /^[A-Za-z0-9\s\p{P}]*$/u
|
|
33
|
+
|
|
34
|
+
export function slugIsHeadingEcho(heading: string, slug: string): boolean {
|
|
35
|
+
const folded = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
36
|
+
if (!ECHO_SAFE_HEADING.test(folded)) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
return headingToSlug(heading, new Set<string>()) === slug
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
function normalizeHeading(heading: string): string {
|
|
24
43
|
let normalized = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
25
44
|
|
|
@@ -178,7 +178,10 @@ function matchHidden(
|
|
|
178
178
|
}
|
|
179
179
|
for (const dir of deniedDirs) {
|
|
180
180
|
const realDir = realpathRealIntendedPath(dir)
|
|
181
|
-
|
|
181
|
+
// realpathRealIntendedPath joins with the platform separator, so the
|
|
182
|
+
// under-dir test must use path.sep too — a hardcoded "/" never matches the
|
|
183
|
+
// "\"-joined paths a win32 test runner produces.
|
|
184
|
+
if (resolved === realDir || resolved.startsWith(`${realDir}${path.sep}`)) return dir
|
|
182
185
|
}
|
|
183
186
|
return undefined
|
|
184
187
|
}
|
|
@@ -61,56 +61,81 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const delivery = req.headers.get('x-github-delivery') ?? ''
|
|
64
|
-
if (delivery !== '' && options.dedup.has(delivery)) {
|
|
65
|
-
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
66
|
-
return ok()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
64
|
const event = req.headers.get('x-github-event') ?? ''
|
|
70
65
|
const payload = parseJson(body)
|
|
71
66
|
if (payload === null) return ok()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const author = readAuthor(event, payload)
|
|
78
|
-
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
79
|
-
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
80
|
-
options.logger.info(
|
|
81
|
-
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
82
|
-
)
|
|
83
|
-
return ok()
|
|
84
|
-
}
|
|
67
|
+
// The HTTP request is only transport; event handling is the shared core.
|
|
68
|
+
await processVerifiedGithubDelivery(options, { event, delivery, payload })
|
|
69
|
+
return ok()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
73
|
+
// Post-verification core shared by the live HTTP handler AND the missed-delivery
|
|
74
|
+
// recovery sweep (recover-failed-deliveries.ts), which pulls lost payloads from
|
|
75
|
+
// GitHub's authenticated deliveries API and re-injects them with no HTTP request
|
|
76
|
+
// or signature. Routing both through this one function is the load-bearing
|
|
77
|
+
// invariant: recovery must never become a second, divergent event pipeline.
|
|
78
|
+
// `delivery` is the `X-GitHub-Delivery` GUID (stable across redeliveries, equal
|
|
79
|
+
// to a delivery's `guid`) used as the dedup key, so a recovered event and its
|
|
80
|
+
// later/duplicate live delivery collapse to one route. HMAC is the caller's job:
|
|
81
|
+
// the live handler verifies the body; the sweep trusts the authenticated API.
|
|
82
|
+
export async function processVerifiedGithubDelivery(
|
|
83
|
+
options: GithubWebhookHandlerOptions,
|
|
84
|
+
input: { event: string; delivery: string; payload: Record<string, unknown> },
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const { event, delivery, payload } = input
|
|
87
|
+
if (delivery !== '') {
|
|
88
|
+
if (options.dedup.has(delivery)) {
|
|
89
|
+
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
90
|
+
return
|
|
98
91
|
}
|
|
92
|
+
// Reserve the delivery id synchronously, BEFORE the awaits below, so a live
|
|
93
|
+
// webhook and the recovery sweep can never both clear the dedup gate for the
|
|
94
|
+
// same event and route it twice. JS is single-threaded: nothing else runs
|
|
95
|
+
// between this has-check and add, so the reservation is atomic. The awaits
|
|
96
|
+
// and classify that follow are all throw-safe, so reserving early cannot
|
|
97
|
+
// strand a routable event.
|
|
98
|
+
options.dedup.add(delivery)
|
|
99
|
+
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return ok()
|
|
101
|
+
const action = readString(payload, 'action')
|
|
102
|
+
if (!isGithubEventAllowed(options.allowlist(), event, action)) return
|
|
103
|
+
|
|
104
|
+
const selfId = options.selfId()
|
|
105
|
+
const selfLogin = options.selfLogin()
|
|
106
|
+
const author = readAuthor(event, payload)
|
|
107
|
+
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
108
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
109
|
+
options.logger.info(
|
|
110
|
+
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// A push to an open PR (`synchronize`) is not a message to react to — it is
|
|
116
|
+
// a trigger to re-evaluate the bot's own outstanding review obligations on
|
|
117
|
+
// this PR: unresolved review threads it authored AND a sticky
|
|
118
|
+
// CHANGES_REQUESTED block (which leaves no threads when filed as a top-level
|
|
119
|
+
// verdict — the black hole this path closes). Both need an API round-trip,
|
|
120
|
+
// so it runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
|
|
121
|
+
// session when an obligation is outstanding. Returning here also keeps
|
|
122
|
+
// synchronize out of the generic awareness-only fallthrough below.
|
|
123
|
+
if (event === 'pull_request' && action === 'synchronize') {
|
|
124
|
+
scheduleReviewFollowup({ payload, selfLogin, options })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
129
|
+
const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
|
|
130
|
+
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
131
|
+
teamIsBotMember,
|
|
132
|
+
authType: options.authType?.() ?? 'pat',
|
|
133
|
+
reviewOn: options.reviewOn?.() ?? 'review_requested',
|
|
134
|
+
...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
|
|
135
|
+
})
|
|
136
|
+
if (classified === null) return
|
|
137
|
+
|
|
138
|
+
options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
export const PR_APPROVAL_DISABLED_NOTE =
|