typeclaw 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -30,7 +30,7 @@ import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystem
30
30
  import { createReloadTool } from './reload-tool'
31
31
  import { loadSelf } from './self'
32
32
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
33
- import { DEFAULT_SYSTEM_PROMPT } from './system-prompt'
33
+ import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock } from './system-prompt'
34
34
  import {
35
35
  createBudgetState,
36
36
  type ToolResultBudget,
@@ -104,6 +104,13 @@ export type CreateSessionOptions = {
104
104
  // Enables the `restart` tool. Set when the agent is running inside a
105
105
  // typeclaw-managed container. Read from TYPECLAW_CONTAINER_NAME at the call site.
106
106
  containerName?: string
107
+ // The typeclaw runtime version (`package.json#version` of the executing
108
+ // CLI) to surface in the system prompt under `## Runtime`. Threaded from
109
+ // `startAgent` via `CLI_VERSION` so every session — TUI, channel, cron,
110
+ // plugin subagent — sees the same value. Omitted in stand-alone test
111
+ // callers, in which case the runtime block is skipped (no token cost, no
112
+ // misleading "unknown" value).
113
+ runtimeVersion?: string
107
114
  // The permission service the runtime resolved at boot. When provided, the
108
115
  // resolved role and permission list for `options.origin` are rendered into
109
116
  // the system prompt under `## Your role in this session`. The block is
@@ -171,11 +178,17 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
171
178
 
172
179
  const resourceLoader =
173
180
  options.systemPromptOverride !== undefined
174
- ? await createOverrideResourceLoader(options.systemPromptOverride, options.origin, options.permissions)
181
+ ? await createOverrideResourceLoader(
182
+ options.systemPromptOverride,
183
+ options.origin,
184
+ options.permissions,
185
+ options.runtimeVersion,
186
+ )
175
187
  : await createResourceLoader({
176
188
  ...(options.plugins ? { plugins: options.plugins, materializedSkills } : {}),
177
189
  ...(options.origin ? { origin: options.origin } : {}),
178
190
  ...(options.permissions ? { permissions: options.permissions } : {}),
191
+ ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
179
192
  })
180
193
 
181
194
  const getOrigin: () => SessionOrigin | undefined =
@@ -475,8 +488,11 @@ export async function createOverrideResourceLoader(
475
488
  systemPrompt: string,
476
489
  origin?: SessionOrigin,
477
490
  permissions?: PermissionService,
491
+ runtimeVersion?: string,
478
492
  ): Promise<DefaultResourceLoader> {
479
- const finalPrompt = withOrigin(systemPrompt, origin, permissions)
493
+ const withRuntime =
494
+ runtimeVersion !== undefined ? `${systemPrompt}\n\n${renderRuntimeBlock(runtimeVersion)}` : systemPrompt
495
+ const finalPrompt = withOrigin(withRuntime, origin, permissions)
480
496
  const loader = new DefaultResourceLoader({
481
497
  systemPromptOverride: () => finalPrompt,
482
498
  appendSystemPromptOverride: () => [],
@@ -491,6 +507,7 @@ export type CreateResourceLoaderOptions = {
491
507
  materializedSkills?: MaterializedSkills | null
492
508
  origin?: SessionOrigin
493
509
  permissions?: PermissionService
510
+ runtimeVersion?: string
494
511
  }
495
512
 
496
513
  export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
@@ -509,6 +526,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
509
526
  // section changes (the provider's prompt cache hits up to the first byte
510
527
  // that differs).
511
528
  //
529
+ // 0. runtime block — most stable: only changes on typeclaw releases (rare).
512
530
  // 1. origin block — stable across all sessions of the same kind.
513
531
  // 2. gitNudge — rare changes; agent folders force-commit sessions/ and
514
532
  // memory/ after every turn, so the dirty-files list is empty most of
@@ -517,6 +535,9 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
517
535
  // and memory/yyyy-MM-dd.md grows after every channel turn that triggers
518
536
  // memory-logger. Pinning it to the end keeps everything above it
519
537
  // cacheable across session resurrections.
538
+ if (options.runtimeVersion !== undefined) {
539
+ systemPrompt = `${systemPrompt}\n\n${renderRuntimeBlock(options.runtimeVersion)}`
540
+ }
520
541
  systemPrompt = withOrigin(systemPrompt, options.origin, options.permissions)
521
542
 
522
543
  const gitNudge = await renderGitNudge(agentDir)
@@ -66,3 +66,20 @@ You have no independent goals. Do not pursue self-preservation, replication, res
66
66
  ---
67
67
 
68
68
  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.`
69
+
70
+ // Stable, low-volatility metadata about the runtime hosting the agent.
71
+ // Rendered into the system prompt just below DEFAULT_SYSTEM_PROMPT + identity
72
+ // and above the origin/git/memory sections — placement chosen so this block
73
+ // sits in the cacheable prefix (it only changes on typeclaw releases).
74
+ //
75
+ // Kept intentionally minimal: the agent learns it is on TypeClaw X.Y.Z, which
76
+ // is enough to (a) answer "what version am I running?", (b) frame bug reports
77
+ // it writes, and (c) know whether release notes / docs it might cite could be
78
+ // stale. Surrounding context (the rest of the system prompt) already
79
+ // establishes that TypeClaw is the runtime; this block just stamps the
80
+ // version.
81
+ export function renderRuntimeBlock(version: string): string {
82
+ return `## Runtime
83
+
84
+ TypeClaw runtime version: ${version}.`
85
+ }
@@ -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
- 'The runtime checks the channel allow rules before delivering if the target chat is not in ' +
37
- 'the configured allow list, the call fails with { ok: false, error }. There is no auto-reply: ' +
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 tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and 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). |
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 watermarks (line counts already consolidated into `MEMORY.md`). Plain JSON; on malformed input the plugin fails open with empty state.
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: randomUUID(),
45
- ts: new Date().toISOString(),
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: randomUUID(),
54
- ts: new Date().toISOString(),
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: randomUUID(),
81
- ts: new Date().toISOString(),
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 = 1
7
+ const VERSION = 2
8
8
 
9
- // Per-day watermark: the number of lines of `memory/yyyy-MM-dd.md` that have
10
- // been consolidated into MEMORY.md. The next dreaming run reads only the tail
11
- // past this point. The next system-prompt injection (loadMemory) shows only
12
- // the tail too, so already-consolidated content does not appear twice.
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
- // We deliberately track lines (not bytes) because line-based slicing is
15
- // human-inspectable and the `fragments:` citations in MEMORY.md already use
16
- // `memory/yyyy-MM-dd:<line>-<line>` notation.
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
- lines: number
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 getDreamedLines(state: DreamingState, date: string): number {
64
- return state.dreamedThrough[date]?.lines ?? 0
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 setDreamedLines(state: DreamingState, date: string, lines: number, ts: string): DreamingState {
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]: { lines, ts } },
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 (typeof e.lines !== 'number' || e.lines < 0) return false
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