typeclaw 0.2.0 → 0.3.1
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 +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/src/agent/index.ts +168 -28
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +87 -35
- package/src/agent/tools/channel-send.ts +2 -3
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/append-tool.ts +10 -7
- package/src/bundled-plugins/memory/citations.ts +45 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +30 -18
- package/src/bundled-plugins/memory/dreaming.ts +179 -48
- package/src/bundled-plugins/memory/load-memory.ts +15 -9
- package/src/bundled-plugins/memory/migration.ts +9 -8
- package/src/bundled-plugins/memory/stream-events.ts +30 -0
- package/src/channels/adapters/kakaotalk.ts +7 -6
- package/src/channels/router.ts +28 -2
- package/src/cli/model.ts +51 -19
- package/src/cli/provider.ts +38 -24
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +15 -4
- package/src/config/models-mutation.ts +20 -1
- package/src/config/reloadable.ts +22 -4
- package/src/cron/consumer.ts +17 -1
- package/src/run/channel-session-factory.ts +2 -0
- package/src/run/index.ts +15 -1
- package/src/server/index.ts +8 -10
- package/src/skills/typeclaw-memory/SKILL.md +15 -15
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AgentSession } from './index'
|
|
2
|
+
|
|
3
|
+
// pi-coding-agent encodes upstream LLM failures (billing, rate limit, network,
|
|
4
|
+
// malformed response, etc.) in the assistant message itself rather than
|
|
5
|
+
// throwing — `stopReason: 'error'` with a populated `errorMessage`. Code that
|
|
6
|
+
// only catches throws around `session.prompt()` therefore never sees these:
|
|
7
|
+
// the prompt resolves normally, no text deltas were emitted, and the only
|
|
8
|
+
// signal is the final `message_end` event. Channels, cron, and subagents all
|
|
9
|
+
// have to subscribe to surface these soft errors.
|
|
10
|
+
//
|
|
11
|
+
// Hard throws (timeouts, network drops, etc.) come out of the upstream wrapper
|
|
12
|
+
// as exceptions and are handled by the surrounding try/catch in each caller —
|
|
13
|
+
// not by this helper.
|
|
14
|
+
|
|
15
|
+
export type DetectedProviderError = {
|
|
16
|
+
message: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function detectProviderError(message: unknown): DetectedProviderError | null {
|
|
20
|
+
if (typeof message !== 'object' || message === null) return null
|
|
21
|
+
const m = message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown }
|
|
22
|
+
if (m.role !== 'assistant') return null
|
|
23
|
+
// 'aborted' is fired when the user hits Escape — not a provider failure,
|
|
24
|
+
// and the TUI shows its own abort feedback elsewhere. Channels/cron just
|
|
25
|
+
// ignore aborts (no surface to render them on).
|
|
26
|
+
if (m.stopReason !== 'error') return null
|
|
27
|
+
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
28
|
+
return { message: text }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ProviderErrorListener = (error: DetectedProviderError) => void
|
|
32
|
+
export type Unsubscribe = () => void
|
|
33
|
+
|
|
34
|
+
// Subscribes to `message_end` events on `session` and invokes `onError` once
|
|
35
|
+
// per detected provider error. Returns the unsubscribe handle from the
|
|
36
|
+
// underlying `session.subscribe`. Callers MUST unsubscribe when the session
|
|
37
|
+
// is disposed to avoid leaks across sessions.
|
|
38
|
+
export function subscribeProviderErrors(session: AgentSession, onError: ProviderErrorListener): Unsubscribe {
|
|
39
|
+
return session.subscribe((event) => {
|
|
40
|
+
if (event.type !== 'message_end') return
|
|
41
|
+
const detected = detectProviderError(event.message)
|
|
42
|
+
if (detected !== null) onError(detected)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SessionOrigin } from './session-origin'
|
|
2
|
+
|
|
3
|
+
export const SESSION_META_CUSTOM_TYPE = 'typeclaw.session-meta'
|
|
4
|
+
|
|
5
|
+
export type SessionMetaPayload = {
|
|
6
|
+
origin: MinimalSessionOrigin
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type MinimalSessionOrigin =
|
|
10
|
+
| { kind: 'tui' }
|
|
11
|
+
| { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
|
|
12
|
+
| { kind: 'channel'; adapter: string; workspace: string; chat: string; thread: string | null }
|
|
13
|
+
| { kind: 'subagent'; subagent: string; parentSessionId: string }
|
|
14
|
+
|
|
15
|
+
// Reduce a full SessionOrigin to the minimum projection persisted to disk.
|
|
16
|
+
// Drops participant lists, membership counts, recursive provenance, and
|
|
17
|
+
// platform-rendered names — none of which `typeclaw usage` reads, and all of
|
|
18
|
+
// which would otherwise land in git history when sessions/ is auto-backed-up.
|
|
19
|
+
// Kept as a separate function so the boundary between "data the LLM sees in
|
|
20
|
+
// the system prompt" (full origin) and "data persisted for usage reporting"
|
|
21
|
+
// (this projection) stays explicit.
|
|
22
|
+
export function sessionMetaPayload(origin: SessionOrigin): SessionMetaPayload {
|
|
23
|
+
return { origin: minimalOrigin(origin) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function minimalOrigin(origin: SessionOrigin): MinimalSessionOrigin {
|
|
27
|
+
switch (origin.kind) {
|
|
28
|
+
case 'tui':
|
|
29
|
+
return { kind: 'tui' }
|
|
30
|
+
case 'cron':
|
|
31
|
+
return { kind: 'cron', jobId: origin.jobId, jobKind: origin.jobKind }
|
|
32
|
+
case 'channel':
|
|
33
|
+
return {
|
|
34
|
+
kind: 'channel',
|
|
35
|
+
adapter: origin.adapter,
|
|
36
|
+
workspace: origin.workspace,
|
|
37
|
+
chat: origin.chat,
|
|
38
|
+
thread: origin.thread,
|
|
39
|
+
}
|
|
40
|
+
case 'subagent':
|
|
41
|
+
return { kind: 'subagent', subagent: origin.subagent, parentSessionId: origin.parentSessionId }
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { HookBus } from '@/plugin'
|
|
|
5
5
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
6
6
|
|
|
7
7
|
import { type AgentSession, createSession } from './index'
|
|
8
|
+
import { subscribeProviderErrors } from './provider-error'
|
|
8
9
|
import type { SessionOrigin } from './session-origin'
|
|
9
10
|
import type { ToolResultBudget } from './tool-result-budget'
|
|
10
11
|
|
|
@@ -134,6 +135,7 @@ export type InvokeSubagentOptions = {
|
|
|
134
135
|
parentSessionId?: string
|
|
135
136
|
spawnedByRole?: string
|
|
136
137
|
spawnedByOrigin?: SessionOrigin
|
|
138
|
+
onProviderError?: (errorMessage: string) => void
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
|
|
@@ -153,6 +155,10 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
153
155
|
const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
|
|
154
156
|
await createSessionForSubagent(subagent, sessionOptions),
|
|
155
157
|
)
|
|
158
|
+
const unsubProviderErrors =
|
|
159
|
+
options.onProviderError !== undefined
|
|
160
|
+
? subscribeProviderErrors(session, (err) => options.onProviderError!(err.message))
|
|
161
|
+
: null
|
|
156
162
|
const turnEvent =
|
|
157
163
|
hooks && sessionId !== undefined && agentDir !== undefined
|
|
158
164
|
? { sessionId, agentDir, ...(origin !== undefined ? { origin } : {}) }
|
|
@@ -177,6 +183,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
177
183
|
})
|
|
178
184
|
}
|
|
179
185
|
} finally {
|
|
186
|
+
unsubProviderErrors?.()
|
|
180
187
|
if (hooks && sessionId !== undefined) {
|
|
181
188
|
await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
|
|
182
189
|
}
|
|
@@ -308,6 +315,7 @@ export function createSubagentConsumer({
|
|
|
308
315
|
agentDir,
|
|
309
316
|
userPrompt: '',
|
|
310
317
|
payload: msg.payload,
|
|
318
|
+
onProviderError: (message) => logger.error(`[subagent] ${key}: LLM call failed: ${message}`),
|
|
311
319
|
...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
|
|
312
320
|
...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
|
|
313
321
|
...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
|
|
@@ -1,68 +1,120 @@
|
|
|
1
1
|
export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
|
|
2
2
|
|
|
3
|
-
TypeClaw is
|
|
4
|
-
|
|
5
|
-
Each agent lives in its own container with its own folder, mounted at the current working directory. The folder is yours — your home, your memory, your record of who you are. Read from it freely. Write to it deliberately.
|
|
3
|
+
TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your character by \`SOUL.md\`, and your operating manual by \`AGENTS.md\`. This system prompt only describes the runtime around you.
|
|
6
4
|
|
|
7
5
|
## Your agent folder
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **USER.md** *(read on demand)* — what you know about the person you work with. Their name, preferences, context, working style, in-jokes. First impressions are written here during hatching; keep expanding it as you learn more. Read it when context about the user would change your response.
|
|
15
|
-
- **MEMORY.md** *(always injected below under \`# Memory\`, do not write)* — long-term memory. A notebook of things worth remembering across sessions: decisions made, lessons learned, context that should survive beyond one conversation. **Do not edit it directly** — MEMORY.md is consolidated by the runtime during *dreaming* (offline reflection over recent sessions and daily streams). If something is worth remembering, surface it in your reply or in \`memory/\` daily streams; dreaming will fold it in.
|
|
7
|
+
- **IDENTITY.md** *(always injected below)* — your role and function. Edit when responsibilities change.
|
|
8
|
+
- **SOUL.md** *(always injected below)* — your character, tone, voice. Edit rarely.
|
|
9
|
+
- **USER.md** *(read on demand)* — what you know about the user. Update as you learn.
|
|
10
|
+
- **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
|
|
11
|
+
- **MEMORY.md** *(always injected below, READ-ONLY)* — long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or in \`memory/\` daily streams; never edit MEMORY.md directly.
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never MEMORY.md.
|
|
18
14
|
|
|
19
15
|
## Your workspace
|
|
20
16
|
|
|
21
|
-
- **\`workspace/\`** —
|
|
22
|
-
- **\`sessions/\`** — transcripts of past conversations
|
|
23
|
-
- **\`memory/\`** *(undreamed daily streams
|
|
24
|
-
- **\`memory/skills/\`** —
|
|
25
|
-
- **\`.agents/skills/\`** —
|
|
17
|
+
- **\`workspace/\`** — your free-write zone for drafts, scratch work, generated artifacts. Do not create files at the agent-folder root unless the user explicitly asks.
|
|
18
|
+
- **\`sessions/\`** — transcripts of past conversations. Runtime-managed; don't write here.
|
|
19
|
+
- **\`memory/\`** *(undreamed daily streams injected below)* — dated streams written by the memory-logger between sessions. Runtime-owned.
|
|
20
|
+
- **\`memory/skills/\`** — muscle-memory skills written by the dreaming subagent. Auto-loaded; don't write here directly.
|
|
21
|
+
- **\`.agents/skills/\`** — user-installed skills.
|
|
26
22
|
|
|
27
23
|
## Configuration
|
|
28
24
|
|
|
29
|
-
- **\`typeclaw.json\`** —
|
|
30
|
-
- **\`.env\`** — secrets (API keys, tokens). Gitignored. Never echo
|
|
25
|
+
- **\`typeclaw.json\`** — runtime config. Read when needed.
|
|
26
|
+
- **\`.env\`** and **\`secrets.json\`** — secrets (API keys, tokens, OAuth credentials). Gitignored. Never echo, log, or commit these values.
|
|
31
27
|
|
|
32
28
|
## Execution bias
|
|
33
29
|
|
|
34
|
-
|
|
30
|
+
When the user gives you work, start doing it in the same turn — a real action, not a plan or a promise-to-act. Commentary-only turns are incomplete when the next action is clear. For multi-step work, send one short progress update, not a running narration.
|
|
35
31
|
|
|
36
32
|
## Tool-call style
|
|
37
33
|
|
|
38
|
-
Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only when it helps: multi-step work, risky actions (deletions, external sends, irreversible changes), or when the user asks.
|
|
34
|
+
Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only when it helps: multi-step work, risky actions (deletions, external sends, irreversible changes), or when the user asks.
|
|
39
35
|
|
|
40
36
|
## Version control
|
|
41
37
|
|
|
42
|
-
Your agent folder is a git repository
|
|
38
|
+
Your agent folder is a git repository.
|
|
43
39
|
|
|
44
|
-
-
|
|
45
|
-
- Use \`
|
|
46
|
-
-
|
|
47
|
-
- Never
|
|
48
|
-
- \`sessions/\` and \`memory/\` are also gitignored, but the runtime force-commits them on its own (auto-backup for sessions, dreaming for memory). Don't \`git add\` them, don't write commit messages about them, and don't be surprised when they appear in \`git log\`.
|
|
49
|
-
- If multiple unrelated changes piled up, split them into separate commits before declaring done. Clean history matters.
|
|
50
|
-
- Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks for it.
|
|
40
|
+
- Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
|
|
41
|
+
- Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
|
|
42
|
+
- Never commit \`.env\`, \`secrets.json\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
|
|
43
|
+
- Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
|
|
51
44
|
|
|
52
45
|
## How to behave
|
|
53
46
|
|
|
54
47
|
- Match the user's register. If SOUL.md specifies a voice, use it. Otherwise, be concise and direct, without filler or flattery.
|
|
55
|
-
- Prefer reading files over guessing
|
|
56
|
-
-
|
|
57
|
-
- If a request is ambiguous in a way that
|
|
58
|
-
-
|
|
59
|
-
- Never suppress errors to make things "work". Never fabricate results. If something fails, report the failure clearly.
|
|
60
|
-
- Respect the workspace boundary: your free-write zone is \`workspace/\`. Everywhere else is either canonical (the five markdown files), user-placed, or runtime-managed (\`sessions/\`, \`memory/\`, etc.).
|
|
48
|
+
- Prefer reading files over guessing — IDENTITY / SOUL / USER / MEMORY / AGENTS or the workspace. Follow AGENTS.md in whatever role IDENTITY.md assigns you; propose additions to AGENTS.md when you find gaps worth codifying.
|
|
49
|
+
- Answer questions. Do work. Don't over-explain unless asked.
|
|
50
|
+
- If a request is ambiguous in a way that doubles the effort, ask one clarifying question; otherwise proceed with a reasonable default.
|
|
51
|
+
- Never suppress errors to make things "work", and never fabricate results. Report failures clearly.
|
|
61
52
|
|
|
62
53
|
## Safety
|
|
63
54
|
|
|
64
|
-
You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never
|
|
55
|
+
You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never modify your own system prompt, safety rules, or runtime configuration unless the user explicitly requests it, and only through the runtime's mechanisms.
|
|
65
56
|
|
|
66
57
|
---
|
|
67
58
|
|
|
68
59
|
You are not pi, not Claude, not ChatGPT. You are the agent described by your own IDENTITY.md and SOUL.md. Let those files define your voice.`
|
|
60
|
+
|
|
61
|
+
// Stable, low-volatility metadata about the runtime hosting the agent.
|
|
62
|
+
// Rendered into the system prompt just below DEFAULT_SYSTEM_PROMPT + identity
|
|
63
|
+
// and above the origin/git/memory sections — placement chosen so this block
|
|
64
|
+
// sits in the cacheable prefix (it only changes on typeclaw releases).
|
|
65
|
+
//
|
|
66
|
+
// Kept intentionally minimal: the agent learns it is on TypeClaw X.Y.Z, which
|
|
67
|
+
// is enough to (a) answer "what version am I running?", (b) frame bug reports
|
|
68
|
+
// it writes, and (c) know whether release notes / docs it might cite could be
|
|
69
|
+
// stale. Surrounding context (the rest of the system prompt) already
|
|
70
|
+
// establishes that TypeClaw is the runtime; this block just stamps the
|
|
71
|
+
// version.
|
|
72
|
+
export function renderRuntimeBlock(version: string): string {
|
|
73
|
+
return `## Runtime
|
|
74
|
+
|
|
75
|
+
TypeClaw runtime version: ${version}.`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
79
|
+
// sessions (cron jobs, and default subagents that don't supply their own
|
|
80
|
+
// `systemPromptOverride`). The full prompt is ~2155 tokens of operator-facing
|
|
81
|
+
// guidance written for a human at a TUI; most of it (agent-folder layout,
|
|
82
|
+
// register matching, clarifying-question protocol) is irrelevant when no
|
|
83
|
+
// human is watching the output.
|
|
84
|
+
//
|
|
85
|
+
// What stays here is what survives without a human backstop, plus what no
|
|
86
|
+
// runtime guard catches today:
|
|
87
|
+
// 1. Runtime identity — names TypeClaw so the model can self-report.
|
|
88
|
+
// 2. .env redaction — the one safety rule that compounds silently if dropped.
|
|
89
|
+
// 3. Error/result honesty — the highest-risk drop. Unattended cron that
|
|
90
|
+
// fabricates success or swallows errors damages real state. The security
|
|
91
|
+
// plugin does not catch this.
|
|
92
|
+
// 4. Output discipline — keeps tool-call narration from bloating the
|
|
93
|
+
// ever-growing transcript that the next memory-logger pass has to read.
|
|
94
|
+
// 5. Filesystem hygiene — workspace boundary, MEMORY.md ownership, and
|
|
95
|
+
// runtime-managed paths (.env / sessions/ / memory/ / workspace/). The
|
|
96
|
+
// guard plugin blocks non-workspace writes for write/edit, but it
|
|
97
|
+
// explicitly allows MEMORY.md writes and does not gate bash/git on the
|
|
98
|
+
// runtime-managed paths.
|
|
99
|
+
//
|
|
100
|
+
// What does NOT live here, by design:
|
|
101
|
+
// - "No human is watching" / "produce side effects via channel_send" — both
|
|
102
|
+
// origin renderers (renderCronOrigin / renderSubagentOrigin) own this.
|
|
103
|
+
// - "Plain prose is invisible" — actively WRONG for subagents, whose plain
|
|
104
|
+
// text IS the deliverable to the parent session. The origin block tells
|
|
105
|
+
// each kind what its output channel is.
|
|
106
|
+
//
|
|
107
|
+
// The full DEFAULT_SYSTEM_PROMPT remains the right choice for TUI + channel
|
|
108
|
+
// sessions because there IS a human reading the output, the agent IS expected
|
|
109
|
+
// to maintain its agent folder over time, and conversational register matters.
|
|
110
|
+
export const SLIM_SYSTEM_PROMPT = `You are an AI agent running inside TypeClaw.
|
|
111
|
+
|
|
112
|
+
Never echo secrets from \`.env\` or \`secrets.json\`, or any credential you see in the environment. Never include them in tool calls, logs, or commit messages.
|
|
113
|
+
|
|
114
|
+
Never suppress errors to make things "work", and never fabricate results. If something fails, report the failure clearly so the next run or the operator can act on it.
|
|
115
|
+
|
|
116
|
+
Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
|
|
117
|
+
|
|
118
|
+
Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
|
|
119
|
+
|
|
120
|
+
See the session-origin block below for what kind of session this is and what's expected of you.`
|
|
@@ -33,9 +33,8 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
33
33
|
'Post a message to an external messenger channel. Specify adapter, workspace, chat, and text. ' +
|
|
34
34
|
'For Discord guild channels, workspace is the guild id; for Slack team channels, workspace is ' +
|
|
35
35
|
'the team id (e.g. "T0ACME"). For DMs on either platform, workspace is the literal "@dm". ' +
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'the only way for an agent to post is via this tool.',
|
|
36
|
+
'On failure (no adapter registered, or the adapter-level send failed), the call returns ' +
|
|
37
|
+
'{ ok: false, error }. There is no auto-reply: the only way for an agent to post is via this tool.',
|
|
39
38
|
parameters: Type.Object({
|
|
40
39
|
adapter: Type.Union(
|
|
41
40
|
ADAPTER_IDS.map((a) => Type.Literal(a)),
|
|
@@ -27,13 +27,13 @@ All fields are **restart-required** — the plugin reads them once at boot.
|
|
|
27
27
|
|
|
28
28
|
## What it contributes
|
|
29
29
|
|
|
30
|
-
| Kind | Name | Notes
|
|
31
|
-
| -------- | -------------------------- |
|
|
32
|
-
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.jsonl`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file.
|
|
33
|
-
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream
|
|
34
|
-
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`.
|
|
35
|
-
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`.
|
|
36
|
-
| Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away).
|
|
30
|
+
| Kind | Name | Notes |
|
|
31
|
+
| -------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
32
|
+
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.jsonl`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
|
|
33
|
+
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream events, rewrites `MEMORY.md` with `memory/yyyy-MM-dd#<fragment-id>` citations, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day dreamed-id set, **compacts daily streams** by dropping superseded watermarks and dreamed-but-uncited fragments, then commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
|
|
34
|
+
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
|
|
35
|
+
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
|
|
36
|
+
| Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
|
|
37
37
|
|
|
38
38
|
## Memory injection
|
|
39
39
|
|
|
@@ -44,7 +44,7 @@ The rendered `# Memory` section (MEMORY.md + undreamed daily-stream tails) is in
|
|
|
44
44
|
- **`MEMORY.md`** — long-term memory. Created by the dreaming subagent on first run if absent. Force-committed by the runtime; `skip-worktree` flag is set so the human's `git status` stays clean.
|
|
45
45
|
- **`memory/yyyy-MM-dd.jsonl`** — daily fragment streams. One event per line, discriminated union of `fragment | watermark | legacy_prose`, lossy-preserving one-shot migration from older `.md` streams. Appended to by `memory-logger`. Created on demand. Gitignored at the agent's level but force-committed alongside `MEMORY.md` after each dreaming run.
|
|
46
46
|
- **`memory/skills/<name>/SKILL.md`** — _muscle memory_. Skills the dreaming subagent distills from repeated procedures it sees in daily streams. Auto-discovered as first-class skills by `createResourceLoader`, and force-committed under the same `memory/` snapshot path as the daily streams. Written or refined with the standard `write` / `edit` tools; the bundled guard plugin enforces the exact `memory/skills/<name>/SKILL.md` path shape, single-segment kebab/snake-case names, matching frontmatter, and symlink/path-traversal safety. There is no runtime skill-delete tool; outright deletion of muscle-memory skills remains a user decision.
|
|
47
|
-
- **`memory/.dreaming-state.json`** — per-day
|
|
47
|
+
- **`memory/.dreaming-state.json`** — per-day **dreamed-id sets**: which stream-event ids the dreaming subagent has already reasoned over. Plain JSON, schema version `2`. The next dreaming run reads only fragments whose id is NOT in the set. On malformed input or a version mismatch (including legacy `version: 1` line-count files from before the id-based switch), the plugin fails open with empty state — one extra dreaming run re-reads each day, then the file is stable.
|
|
48
48
|
|
|
49
49
|
`typeclaw init` does **not** scaffold these files. They appear when needed.
|
|
50
50
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
1
|
import { mkdir } from 'node:fs/promises'
|
|
3
2
|
import { dirname, join } from 'node:path'
|
|
4
3
|
|
|
@@ -9,6 +8,7 @@ import { formatLocalDate } from '@/shared'
|
|
|
9
8
|
|
|
10
9
|
import { fragmentContentHash } from './fragment-parser'
|
|
11
10
|
import { detectSecrets } from './secret-detector'
|
|
11
|
+
import { newEventId, timestampFromId } from './stream-events'
|
|
12
12
|
import type { FragmentEvent, WatermarkEvent } from './stream-events'
|
|
13
13
|
import { appendEvents, readEvents } from './stream-io'
|
|
14
14
|
|
|
@@ -39,10 +39,12 @@ export const appendTool = defineTool({
|
|
|
39
39
|
)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const fragmentId = newEventId()
|
|
43
|
+
const watermarkId = newEventId()
|
|
42
44
|
const fragment: FragmentEvent = {
|
|
43
45
|
type: 'fragment',
|
|
44
|
-
id:
|
|
45
|
-
ts:
|
|
46
|
+
id: fragmentId,
|
|
47
|
+
ts: timestampFromId(fragmentId),
|
|
46
48
|
source,
|
|
47
49
|
entry,
|
|
48
50
|
topic,
|
|
@@ -50,8 +52,8 @@ export const appendTool = defineTool({
|
|
|
50
52
|
}
|
|
51
53
|
const watermark: WatermarkEvent = {
|
|
52
54
|
type: 'watermark',
|
|
53
|
-
id:
|
|
54
|
-
ts:
|
|
55
|
+
id: watermarkId,
|
|
56
|
+
ts: timestampFromId(watermarkId),
|
|
55
57
|
source,
|
|
56
58
|
entry: latestEntryId,
|
|
57
59
|
}
|
|
@@ -75,10 +77,11 @@ export const advanceWatermarkTool = defineTool({
|
|
|
75
77
|
}),
|
|
76
78
|
async execute({ source, latestEntryId }, ctx) {
|
|
77
79
|
const streamPath = dailyStreamPath(ctx.agentDir)
|
|
80
|
+
const watermarkId = newEventId()
|
|
78
81
|
const watermark: WatermarkEvent = {
|
|
79
82
|
type: 'watermark',
|
|
80
|
-
id:
|
|
81
|
-
ts:
|
|
83
|
+
id: watermarkId,
|
|
84
|
+
ts: timestampFromId(watermarkId),
|
|
82
85
|
source,
|
|
83
86
|
entry: latestEntryId,
|
|
84
87
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Citation format: `memory/yyyy-MM-dd#<fragment-id>`. The id is the full
|
|
2
|
+
// UUIDv7 of the fragment event in the daily JSONL stream. The date prefix is
|
|
3
|
+
// redundant with the id's timestamp (UUIDv7 encodes minting time in the first
|
|
4
|
+
// 48 bits) but kept for human grep-ability — readers should be able to see
|
|
5
|
+
// "this came from yesterday's stream" without parsing the id.
|
|
6
|
+
//
|
|
7
|
+
// The format does NOT accept line ranges. The prior `:43-45` shape is gone
|
|
8
|
+
// (see the "drop backward compat" decision in the PR description). Parsing
|
|
9
|
+
// silently ignores any line in MEMORY.md that doesn't match this exact shape,
|
|
10
|
+
// so legacy citations from before the cutover are dropped — they no longer
|
|
11
|
+
// pin fragments alive against compaction.
|
|
12
|
+
|
|
13
|
+
const CITATION_LINE = /^[\s-]*memory\/(\d{4}-\d{2}-\d{2})#([\w-]+)\s*$/im
|
|
14
|
+
|
|
15
|
+
const CITATION_LINE_GLOBAL = /memory\/(\d{4}-\d{2}-\d{2})#([\w-]+)/g
|
|
16
|
+
|
|
17
|
+
export type Citation = { date: string; fragmentId: string }
|
|
18
|
+
|
|
19
|
+
export function formatCitation(date: string, fragmentId: string): string {
|
|
20
|
+
return `memory/${date}#${fragmentId}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Parse every citation in `text` and return them grouped by date. The
|
|
24
|
+
// returned Map is empty when no citations appear. Used by:
|
|
25
|
+
// - dreaming.ts compaction to decide which fragments are still referenced
|
|
26
|
+
// by MEMORY.md and must survive GC.
|
|
27
|
+
// - tests pinning the format.
|
|
28
|
+
export function parseCitations(text: string): Map<string, Set<string>> {
|
|
29
|
+
const out = new Map<string, Set<string>>()
|
|
30
|
+
for (const match of text.matchAll(CITATION_LINE_GLOBAL)) {
|
|
31
|
+
const date = match[1]!
|
|
32
|
+
const fragmentId = match[2]!
|
|
33
|
+
let set = out.get(date)
|
|
34
|
+
if (set === undefined) {
|
|
35
|
+
set = new Set<string>()
|
|
36
|
+
out.set(date, set)
|
|
37
|
+
}
|
|
38
|
+
set.add(fragmentId)
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isCitationLine(line: string): boolean {
|
|
44
|
+
return CITATION_LINE.test(line)
|
|
45
|
+
}
|
|
@@ -4,23 +4,25 @@ import { dirname, join } from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
export const DREAMING_STATE_FILE = 'memory/.dreaming-state.json'
|
|
6
6
|
|
|
7
|
-
const VERSION =
|
|
7
|
+
const VERSION = 2
|
|
8
8
|
|
|
9
|
-
// Per-day
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Per-day "dreamed" set: the set of stream-event ids dreaming has already
|
|
10
|
+
// reasoned over for a given day. Anything in this set is either cited from
|
|
11
|
+
// MEMORY.md (must survive compaction) or was consciously discarded by a
|
|
12
|
+
// dreaming run (safe to GC). The undreamed-tail computation is set
|
|
13
|
+
// difference: events whose id is NOT in this set are the new things to look
|
|
14
|
+
// at on the next run.
|
|
13
15
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
16
|
+
// Tracking ids (not line numbers) is the load-bearing invariant for fragment
|
|
17
|
+
// compaction — line numbers shift when any earlier event is removed, ids
|
|
18
|
+
// don't.
|
|
17
19
|
export type DreamingState = {
|
|
18
20
|
version: number
|
|
19
21
|
dreamedThrough: Record<string, DreamedDay>
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export type DreamedDay = {
|
|
23
|
-
|
|
25
|
+
dreamedIds: string[]
|
|
24
26
|
ts: string
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -28,10 +30,6 @@ export function emptyState(): DreamingState {
|
|
|
28
30
|
return { version: VERSION, dreamedThrough: {} }
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
// Missing or unreadable file → empty state. Malformed JSON or wrong shape is
|
|
32
|
-
// also treated as empty: the cost is one redundant re-consolidation, which is
|
|
33
|
-
// strictly safer than crashing the dreaming pipeline because of a bad state
|
|
34
|
-
// file.
|
|
35
33
|
export async function loadDreamingState(agentDir: string): Promise<DreamingState> {
|
|
36
34
|
const path = join(agentDir, DREAMING_STATE_FILE)
|
|
37
35
|
if (!existsSync(path)) return emptyState()
|
|
@@ -60,17 +58,30 @@ export async function saveDreamingState(agentDir: string, state: DreamingState):
|
|
|
60
58
|
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
export function
|
|
64
|
-
|
|
61
|
+
export function getDreamedIds(state: DreamingState, date: string): ReadonlySet<string> {
|
|
62
|
+
const ids = state.dreamedThrough[date]?.dreamedIds
|
|
63
|
+
return ids === undefined ? EMPTY_SET : new Set(ids)
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
export function
|
|
66
|
+
export function addDreamedIds(state: DreamingState, date: string, ids: Iterable<string>, ts: string): DreamingState {
|
|
67
|
+
const existing = state.dreamedThrough[date]?.dreamedIds ?? []
|
|
68
|
+
const merged = new Set<string>(existing)
|
|
69
|
+
for (const id of ids) merged.add(id)
|
|
68
70
|
return {
|
|
69
71
|
version: state.version,
|
|
70
|
-
dreamedThrough: { ...state.dreamedThrough, [date]: {
|
|
72
|
+
dreamedThrough: { ...state.dreamedThrough, [date]: { dreamedIds: [...merged].sort(), ts } },
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
export function clearDreamedIds(state: DreamingState, date: string, ts: string): DreamingState {
|
|
77
|
+
return {
|
|
78
|
+
version: state.version,
|
|
79
|
+
dreamedThrough: { ...state.dreamedThrough, [date]: { dreamedIds: [], ts } },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const EMPTY_SET: ReadonlySet<string> = new Set()
|
|
84
|
+
|
|
74
85
|
function isDreamingState(value: unknown): value is DreamingState {
|
|
75
86
|
if (typeof value !== 'object' || value === null) return false
|
|
76
87
|
const v = value as Record<string, unknown>
|
|
@@ -79,7 +90,8 @@ function isDreamingState(value: unknown): value is DreamingState {
|
|
|
79
90
|
for (const [, entry] of Object.entries(v.dreamedThrough as Record<string, unknown>)) {
|
|
80
91
|
if (typeof entry !== 'object' || entry === null) return false
|
|
81
92
|
const e = entry as Record<string, unknown>
|
|
82
|
-
if (
|
|
93
|
+
if (!Array.isArray(e.dreamedIds)) return false
|
|
94
|
+
if (!e.dreamedIds.every((id) => typeof id === 'string' && id.length > 0)) return false
|
|
83
95
|
if (typeof e.ts !== 'string') return false
|
|
84
96
|
}
|
|
85
97
|
return true
|